#!/usr/bin/env node
// build-html.mjs — render norske kronikker som selvstendige, annoterbare HTML-filer.
// Bruk: node build-html.mjs utkast/01-open-source-edge-modeller.md [flere.md ...]
// Skriver til review/.html. Ingen npm-avhengigheter, ingen nett.
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// ---------------------------------------------------------------------------
// YAML front matter (minimal: flate key: "value"-par mellom --- ... ---)
// ---------------------------------------------------------------------------
function parseFrontMatter(raw) {
const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
if (!m) return { meta: {}, body: raw };
const meta = {};
for (const line of m[1].split(/\r?\n/)) {
const mm = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
if (!mm) continue;
let val = mm[2].trim();
if (
(val.startsWith('"') && val.endsWith('"')) ||
(val.startsWith("'") && val.endsWith("'"))
) {
val = val.slice(1, -1);
}
meta[mm[1]] = val;
}
return { meta, body: m[2].replace(/^\r?\n+/, "") };
}
// ---------------------------------------------------------------------------
// HTML-escape for tekstinnhold
// ---------------------------------------------------------------------------
function esc(s) {
return s
.replace(/&/g, "&")
.replace(//g, ">");
}
// ---------------------------------------------------------------------------
// Inline markdown: `kode`, **fet**, *kursiv*. «» og — beholdes uendret.
// Tar uescapet tekst, returnerer escaped HTML med inline-tagger.
// ---------------------------------------------------------------------------
export function inline(text) {
let out = esc(text);
// `kode` først, slik at * og ** inni en kode-span ikke tolkes som fet/kursiv
out = out.replace(/`([^`]+)`/g, (_, c) => `${c}`);
// **fet** før *kursiv* for å unngå konflikt
out = out.replace(/\*\*([^*]+)\*\*/g, (_, c) => `${c}`);
out = out.replace(/\*([^*]+)\*/g, (_, c) => `${c}`);
return out;
}
// ---------------------------------------------------------------------------
// Overskrift: # ->
... #### ->
(klemmes til h4; dypere nivåer
// kollapser til
). Tar antall #-tegn + raw heading-tekst.
// ---------------------------------------------------------------------------
export function renderHeading(hashes, text) {
const level = Math.min(hashes, 4); // # .. #### ->
..
return `${inline(text.trim())}`;
}
// ---------------------------------------------------------------------------
// Tabell: en sammenhengende blokk av `| a | b |`-rader ->
.
// Rad 1 = header (
). En påfølgende separator-rad (| --- | --- |) hoppes
// over. Øvrige rader =
. Ujevn celletall tolereres (ingen kast).
// ---------------------------------------------------------------------------
function splitRow(line) {
// Fjern ledende/avsluttende pipe, del på resten.
return line
.trim()
.replace(/^\|/, "")
.replace(/\|$/, "")
.split("|")
.map((c) => c.trim());
}
function isSeparatorRow(line) {
// | --- | :---: | ---: | osv.
return /^\|?\s*:?-{1,}:?\s*(\|\s*:?-{1,}:?\s*)*\|?$/.test(line.trim());
}
export function renderTable(rows) {
if (!rows.length) return "";
let bodyRows = rows;
const header = splitRow(rows[0]);
let startIdx = 1;
if (rows.length > 1 && isSeparatorRow(rows[1])) startIdx = 2;
bodyRows = rows.slice(startIdx);
const thead =
"
" +
header.map((c) => `
${inline(c)}
`).join("") +
"
";
const tbody =
"
" +
bodyRows
.map(
(r) =>
"
" +
splitRow(r)
.map((c) => `
${inline(c)}
`)
.join("") +
"
"
)
.join("") +
"";
return `
${thead}${tbody}
`;
}
// A markdown table line: starts with `|` and has at least one more `|`.
function isTableLine(t) {
return /^\|.*\|/.test(t);
}
// ---------------------------------------------------------------------------
// Kompakt markdown -> HTML for body.
// Håndterer: # .. #### overskrifter, | tabeller |, - punktlister,
// 1. nummererte lister, > blockquote, --- horisontal linje, `kode`, og
// avsnitt (blanklinje-separert).
// Første avsnitt får drop-cap-klasse. Avsnitt etter det første: .indent.
// ---------------------------------------------------------------------------
export function markdownToHtml(body) {
const lines = body.replace(/\r\n/g, "\n").split("\n");
const blocks = [];
let i = 0;
let paraCount = 0;
function flushPara(buf) {
if (!buf.length) return;
const text = buf.join(" ").trim();
if (!text) return;
paraCount++;
const cls = paraCount === 1 ? "lede" : "indent";
blocks.push(`
${inline(text)}
`);
}
while (i < lines.length) {
const line = lines[i];
const trimmed = line.trim();
// Horisontal linje
if (/^---+$/.test(trimmed)) {
blocks.push("");
i++;
continue;
}
// Overskrifter (# .. ###### ->
..
, dypere klemmes til h4)
let hm = trimmed.match(/^(#{1,6})\s+(.*)$/);
if (hm) {
blocks.push(renderHeading(hm[1].length, hm[2]));
i++;
continue;
}
// Tabell (sammenhengende | a | b | -rader)
if (isTableLine(trimmed)) {
const rows = [];
while (i < lines.length && isTableLine(lines[i].trim())) {
rows.push(lines[i].trim());
i++;
}
blocks.push(renderTable(rows));
continue;
}
// Blockquote (sammenhengende > -linjer)
if (/^>\s?/.test(trimmed)) {
const qbuf = [];
while (i < lines.length && /^>\s?/.test(lines[i].trim())) {
qbuf.push(lines[i].trim().replace(/^>\s?/, ""));
i++;
}
blocks.push(`
${inline(qbuf.join(" ").trim())}
`);
continue;
}
// Punktliste
if (/^[-*]\s+/.test(trimmed)) {
const items = [];
while (i < lines.length && /^[-*]\s+/.test(lines[i].trim())) {
items.push(lines[i].trim().replace(/^[-*]\s+/, ""));
i++;
}
blocks.push(
"
" + items.map((it) => `
${inline(it)}
`).join("") + "
"
);
continue;
}
// Nummerert liste
if (/^\d+\.\s+/.test(trimmed)) {
const items = [];
while (i < lines.length && /^\d+\.\s+/.test(lines[i].trim())) {
items.push(lines[i].trim().replace(/^\d+\.\s+/, ""));
i++;
}
blocks.push(
"" + items.map((it) => `