#!/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) => `
' : '');
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);
}