#!/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`, [lenke](url), **fet**, *kursiv*. «» og — beholdes.
// 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}`);
// [tekst](url) — kun http(s)/mailto får href; andre skjemaer beholder teksten
out = out.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, label, url) =>
/^(https?:|mailto:)/i.test(url)
? `${label}`
: label
);
// **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 (flerlinjers, med FIGUR-markør),
// [lenker](url), --- horisontal linje, `kode`, og avsnitt (blanklinje-separert).
// Første avsnitt får drop-cap-klasse. Avsnitt etter det første: .indent.
// ---------------------------------------------------------------------------
// Figur-markør: les figurer/figN*.svg fra serie-mappa (process.cwd()).
// Returnerer rå SVG-streng, eller null hvis ingen matchende fil finnes.
function readFigureSvg(n) {
const figDir = path.join(process.cwd(), "figurer");
if (!fs.existsSync(figDir)) return null;
const re = new RegExp("^fig" + n + "(?:[-.].*)?\\.svg$", "i");
const match = fs.readdirSync(figDir).filter((f) => re.test(f)).sort()[0];
if (!match) return null;
return fs
.readFileSync(path.join(figDir, match), "utf8")
.replace(/<\?xml[^>]*\?>\s*/i, "")
.trim();
}
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; tom > -linje = nytt avsnitt i samme quote)
if (/^>\s?/.test(trimmed)) {
const qlines = [];
while (i < lines.length && /^>\s?/.test(lines[i].trim())) {
qlines.push(lines[i].trim().replace(/^>\s?/, ""));
i++;
}
const paras = [];
let cur = [];
for (const ql of qlines) {
if (ql.trim() === "") {
if (cur.length) { paras.push(cur.join(" ").trim()); cur = []; }
} else cur.push(ql);
}
if (cur.length) paras.push(cur.join(" ").trim());
// Figur-markør: blockquote som starter med **[FIGUR N — ...]**.
// Finnes figurer/figN*.svg → inline SVG (rendres i nettleser).
// Ellers: vis spec'en som vanlig blockquote (uendret oppførsel).
const figm = paras.length && paras[0].match(/^\*\*\[FIGUR\s+(\d+)\b/);
if (figm) {
const svg = readFigureSvg(figm[1]);
if (svg) {
const capm = paras[0].match(/«([^»]+)»/);
const caption = capm ? `Figur ${figm[1]} — ${capm[1]}` : "";
blocks.push(
`${svg}` +
(caption ? `${inline(caption)}` : "") +
""
);
continue;
}
}
blocks.push("
" + paras.map((p) => `
${inline(p)}
`).join("") + "
");
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) => `
' : '');
item.querySelector(".edit").addEventListener("click", function () {
openEdit(a.id, item.getBoundingClientRect());
});
item.querySelector(".del").addEventListener("click", function () {
annotations = annotations.filter(function (x) { return x.id !== a.id; });
save(); render();
});
listEl.appendChild(item);
});
}
// --- Eksport: kompakt annoteringsliste (kun annoteringer, ikke brødtekst) -
function buildAnnotatedMarkdown() {
var header = "# Annoteringer — " + SOURCE_FILE + " · «" + TITLE + "»";
if (!annotations.length) {
return header + "\n\n(Ingen annoteringer.)\n";
}
function occurrences(s) {
if (!s) return 0;
var hay = article.textContent.replace(/\s+/g, " ");
var n = 0, i = 0;
while ((i = hay.indexOf(s, i)) !== -1) { n++; i += s.length; }
return n;
}
var blocks = annotations.map(function (a, idx) {
var lines = [(idx + 1) + ". [" + INTENTS[a.intent].upper + "] «" + a.text + "»"];
// Ta med kontekst kun når markeringen er kort eller forekommer flere ganger
// (ellers holder vi eksporten kompakt).
if (a.context && (a.text.length < 30 || occurrences(a.text) > 1)) {
lines.push(" ↳ i: «" + a.context + "»");
}
lines.push(" → " + (a.comment || ""));
return lines.join("\n");
});
return header + "\n\n" + blocks.join("\n\n") + "\n";
}
function showExport() {
var overlay = document.getElementById("exportOverlay");
var ta = document.getElementById("exportText");
ta.value = buildAnnotatedMarkdown();
overlay.classList.add("show");
ta.focus(); ta.select();
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(ta.value).catch(function () {});
}
}
// --- Helpers --------------------------------------------------------------
function truncate(s, n) { return s.length > n ? s.slice(0, n - 1) + "…" : s; }
function escHtml(s) {
return s.replace(/&/g, "&").replace(//g, ">");
}
// --- Bind topp-/panel-knapper --------------------------------------------
document.getElementById("btnExport").addEventListener("click", showExport);
document.getElementById("btnExportTop").addEventListener("click", showExport);
document.getElementById("btnClear").addEventListener("click", function () {
if (!annotations.length) return;
if (confirm("Tøm alle annoteringer for denne artikkelen?")) {
annotations = []; save(); render();
}
});
document.getElementById("exportClose").addEventListener("click", function () {
document.getElementById("exportOverlay").classList.remove("show");
});
document.getElementById("exportCopy").addEventListener("click", function () {
var ta = document.getElementById("exportText");
ta.select();
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(ta.value);
} else { document.execCommand("copy"); }
});
document.getElementById("sidebarToggle").addEventListener("click", function () {
sidebar.classList.toggle("mobile-show");
});
// Skjul verktøylinje ved klikk utenfor
document.addEventListener("mousedown", function (e) {
if (!toolbar.contains(e.target) && !popover.contains(e.target)) {
if (!window.getSelection().toString().trim()) hideToolbar();
}
});
render();
})();
`;
// ---------------------------------------------------------------------------
// HTML-shell
// ---------------------------------------------------------------------------
function buildPage(meta, body, articleKey, sourceFile) {
const bodyHtml = markdownToHtml(body);
const title = meta.title || "Kronikk";
const metaLine = [meta.serie, meta.lesetid].filter(Boolean).join(" · ");
return `
${esc(title)}
${meta.kicker ? `
${esc(meta.kicker)}
` : ""}
${inline(title)}
${meta.subtitle ? `
${inline(meta.subtitle)}
` : ""}
${meta.byline ? `
${esc(meta.byline)}
` : ""}
${metaLine ? `
${esc(metaLine)}
` : ""}
${bodyHtml}
Annoteringer — kopier og lim tilbake
`;
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
// Returnerer antall HTML-filer skrevet. Eksitkoden settes av CLI-guarden under
// (S14/F7): main() kaller aldri process.exit() selv, slik at modulen kan
// importeres/testes uten å drepe prosessen.
export function main() {
const args = process.argv.slice(2);
if (!args.length) {
console.error("Bruk: node build-html.mjs [flere.md ...]");
return 0;
}
// Output følger serien (kjøres fra serie-mappa), ikke scriptet i tools/.
const outDir = path.join(process.cwd(), "review");
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
let written = 0;
for (const arg of args) {
const inPath = path.isAbsolute(arg) ? arg : path.join(process.cwd(), arg);
if (!fs.existsSync(inPath)) {
console.error(`Fant ikke: ${inPath}`);
continue;
}
const raw = fs.readFileSync(inPath, "utf8");
const { meta, body } = parseFrontMatter(raw);
const sourceFile = path.basename(inPath);
const base = sourceFile.replace(/\.md$/i, "");
const html = buildPage(meta, body, base, sourceFile);
const outPath = path.join(outDir, base + ".html");
fs.writeFileSync(outPath, html, "utf8");
console.log(`Skrev ${outPath} (${(html.length / 1024).toFixed(1)} KB)`);
written++;
}
// S14/F7: en typo'd/manglende input-fil ga tidligere exit 0 uten HTML (stille
// footgun). Skrev vi ingenting, er det en feil — rapporter og la CLI-guarden
// sette ikke-null exit.
if (written === 0) {
console.error(`Ingen HTML produsert (0 av ${args.length} input-fil(er) funnet) — sjekk filnavn og sti.`);
}
return written;
}
// CLI-guard: kjør kun når scriptet startes direkte, ikke ved import
// (mønster fra hooks/scripts/state-updater.mjs). Exit non-zero hvis ingen HTML.
if (import.meta.url === `file://${process.argv[1]}`) {
process.exit(main() > 0 ? 0 : 1);
}