#!/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 (" + header.map((c) => ``).join("") + ""; const tbody = "" + bodyRows .map( (r) => "" + splitRow(r) .map((c) => ``) .join("") + "" ) .join("") + ""; return `
). 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 = "
${inline(c)}
${inline(c)}
${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( "" ); 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) => `
  1. ${inline(it)}
  2. `).join("") + "
" ); continue; } // Blank linje -> avsnittsgrense if (trimmed === "") { i++; continue; } // Vanlig avsnitt: samle til blank/strukturlinje const pbuf = []; while (i < lines.length) { const t = lines[i].trim(); if ( t === "" || /^---+$/.test(t) || /^(#{1,6})\s+/.test(t) || isTableLine(t) || /^>\s?/.test(t) || /^[-*]\s+/.test(t) || /^\d+\.\s+/.test(t) ) { break; } pbuf.push(t); i++; } flushPara(pbuf); } return blocks.join("\n"); } // --------------------------------------------------------------------------- // JS-streng-escape (for ordrett innbygging av body-markdown og meta) // --------------------------------------------------------------------------- function toJsString(s) { return JSON.stringify(s); } // --------------------------------------------------------------------------- // CSS — redaksjonell avis-stil, self-contained // --------------------------------------------------------------------------- const CSS = ` :root { --bg: #FBFAF7; --ink: #1A1A1A; --muted: #555555; --accent: #9A3324; --rule: #d8d4cb; --serif: "Iowan Old Style","Palatino Linotype",Palatino,"Book Antiqua",Georgia,serif; --sans: -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; --c-change: #e6a817; --c-add: #2e8b57; --c-remove: #c0392b; --c-clarify: #2d6cdf; --c-risk: #7d3c98; } * { box-sizing: border-box; } html, body { margin: 0; padding: 0; } body { background: var(--bg); color: var(--ink); font-family: var(--serif); font-size: 20px; line-height: 1.65; -webkit-font-smoothing: antialiased; } .page { display: flex; justify-content: center; gap: 2rem; padding: 4rem 1.5rem 8rem; } article { max-width: 34em; width: 100%; } .kicker { font-family: var(--sans); text-transform: uppercase; letter-spacing: 0.12em; font-size: 0.72rem; font-weight: 600; color: var(--accent); margin: 0 0 0.9rem; } h1.title { font-family: var(--serif); font-size: 2.6rem; line-height: 1.1; font-weight: 700; margin: 0 0 1rem; letter-spacing: -0.01em; } .subtitle { font-family: var(--serif); font-style: italic; font-size: 1.25rem; line-height: 1.4; color: var(--muted); margin: 0 0 1.6rem; } .byline-wrap { border-top: 1px solid var(--rule); padding-top: 0.9rem; margin-bottom: 2.4rem; } .byline { font-family: var(--sans); text-transform: uppercase; letter-spacing: 0.06em; font-size: 0.7rem; color: var(--muted); margin: 0 0 0.3rem; } .meta { font-family: var(--sans); font-size: 0.7rem; color: var(--muted); letter-spacing: 0.04em; } .body p { margin: 0; text-align: left; } .body p.indent { text-indent: 1.4em; } .body p.lede::first-letter { float: left; font-size: 3.1em; line-height: 0.82; padding: 0.05em 0.08em 0 0; color: var(--accent); font-weight: 700; } .body h1 { font-size: 1.8rem; font-weight: 700; margin: 2.2rem 0 0.7rem; line-height: 1.15; } .body h2 { font-size: 1.5rem; font-weight: 700; margin: 2rem 0 0.6rem; line-height: 1.2; } .body h3 { font-size: 1.2rem; font-weight: 700; margin: 1.6rem 0 0.5rem; line-height: 1.25; } .body h4 { font-size: 1.05rem; font-weight: 700; margin: 1.3rem 0 0.4rem; line-height: 1.3; } .body code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.86em; background: #efece4; padding: 0.08em 0.35em; border-radius: 3px; } .body table { border-collapse: collapse; width: 100%; margin: 1.4rem 0; font-family: var(--sans); font-size: 0.86rem; } .body th, .body td { border: 1px solid var(--rule); padding: 0.5em 0.7em; text-align: left; vertical-align: top; } .body th { background: #efece4; font-weight: 600; } .body ul, .body ol { margin: 0.8rem 0; padding-left: 1.5em; } .body li { margin: 0.3rem 0; } .body blockquote { margin: 1.4rem 0; padding-left: 1.1em; border-left: 3px solid var(--accent); font-style: italic; color: #333; } .body hr { border: 0; border-top: 1px solid var(--rule); margin: 2rem auto; width: 40%; } /* Annoterte markeringer */ .anno { border-radius: 2px; padding: 0 1px; cursor: pointer; } .anno-change { background: rgba(230,168,23,0.30); } .anno-add { background: rgba(46,139,87,0.25); } .anno-remove { background: rgba(192,57,43,0.22); } .anno-clarify{ background: rgba(45,108,223,0.20); } .anno-risk { background: rgba(125,60,152,0.20); } .anno-num { font-family: var(--sans); font-size: 0.6em; font-weight: 700; vertical-align: super; margin-left: 1px; color: var(--accent); } /* Flytende verktøylinje */ .anno-toolbar { position: absolute; z-index: 1000; display: none; background: #1A1A1A; border-radius: 8px; padding: 4px; box-shadow: 0 6px 24px rgba(0,0,0,0.28); gap: 2px; } .anno-toolbar.show { display: flex; } .anno-toolbar button { font-family: var(--sans); font-size: 0.72rem; border: 0; background: transparent; color: #fff; padding: 6px 9px; border-radius: 5px; cursor: pointer; } .anno-toolbar button:hover { background: rgba(255,255,255,0.16); } .anno-toolbar .swatch { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 5px; vertical-align: middle; } /* Kommentarboks (popover) */ .anno-popover { position: absolute; z-index: 1001; display: none; background: #fff; border: 1px solid var(--rule); border-radius: 8px; padding: 10px; width: 280px; box-shadow: 0 8px 30px rgba(0,0,0,0.22); font-family: var(--sans); } .anno-popover.show { display: block; } .anno-popover .ph { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); margin-bottom: 6px; font-weight: 600; } .anno-popover textarea { width: 100%; min-height: 70px; font-family: var(--sans); font-size: 0.85rem; border: 1px solid var(--rule); border-radius: 5px; padding: 6px; resize: vertical; } .anno-popover .row { display: flex; justify-content: flex-end; gap: 6px; margin-top: 8px; } .anno-popover button { font-family: var(--sans); font-size: 0.78rem; padding: 5px 12px; border-radius: 5px; border: 1px solid var(--rule); background: #f4f2ec; cursor: pointer; } .anno-popover button.primary { background: var(--accent); color: #fff; border-color: var(--accent); } .anno-popover .intent-pick { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 8px; } .anno-popover .intent-pick button { font-family: var(--sans); font-size: 0.72rem; padding: 4px 8px; border-radius: 5px; border: 1px solid var(--rule); background: #f4f2ec; cursor: pointer; } .anno-popover .intent-pick button.active { background: #1A1A1A; color: #fff; border-color: #1A1A1A; } .anno-popover .del-edit { margin-right: auto; color: var(--c-remove); background: #fff; } /* Sidepanel */ .sidebar { width: 320px; flex: 0 0 320px; font-family: var(--sans); position: sticky; top: 2rem; align-self: flex-start; max-height: calc(100vh - 4rem); overflow: auto; } .sidebar h2 { font-family: var(--sans); font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); margin: 0 0 0.8rem; } .sidebar .actions { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 1rem; } .sidebar .actions button { font-family: var(--sans); font-size: 0.74rem; padding: 6px 10px; border-radius: 6px; border: 1px solid var(--rule); background: #fff; cursor: pointer; } .sidebar .actions button.primary { background: var(--accent); color: #fff; border-color: var(--accent); } .anno-item { border: 1px solid var(--rule); border-radius: 8px; padding: 9px 10px; margin-bottom: 8px; background: #fff; } .anno-item .top { display: flex; align-items: center; gap: 6px; margin-bottom: 5px; } .anno-item .num { font-weight: 700; font-size: 0.72rem; background: #efece4; width: 20px; height: 20px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; } .anno-item .badge { font-size: 0.65rem; text-transform: uppercase; letter-spacing: 0.05em; font-weight: 700; padding: 2px 7px; border-radius: 10px; color: #fff; } .badge-change { background: var(--c-change); color:#3a2a00; } .badge-add { background: var(--c-add); } .badge-remove { background: var(--c-remove); } .badge-clarify{ background: var(--c-clarify); } .badge-risk { background: var(--c-risk); } .anno-item .quote { font-size: 0.78rem; color:#444; font-style: italic; margin: 4px 0; } .anno-item .cmt { font-size: 0.84rem; color: var(--ink); } .anno-item .edit { margin-left: auto; border: 0; background: transparent; color: var(--c-clarify); cursor: pointer; font-size: 0.74rem; } .anno-item .del { margin-left: 4px; border: 0; background: transparent; color: var(--c-remove); cursor: pointer; font-size: 0.74rem; } .sidebar .empty { font-size: 0.8rem; color: var(--muted); font-style: italic; } .sidebar-toggle { position: fixed; top: 1rem; right: 1rem; z-index: 900; font-family: var(--sans); font-size: 0.74rem; padding: 7px 12px; border-radius: 6px; border: 1px solid var(--rule); background: #fff; cursor: pointer; box-shadow: 0 2px 10px rgba(0,0,0,0.08); } /* Eksport-overlay */ .export-overlay { position: fixed; inset: 0; z-index: 2000; display: none; background: rgba(0,0,0,0.45); align-items: center; justify-content: center; } .export-overlay.show { display: flex; } .export-box { background: #fff; border-radius: 10px; padding: 16px; width: min(720px, 92vw); font-family: var(--sans); box-shadow: 0 20px 60px rgba(0,0,0,0.35); } .export-box h3 { margin: 0 0 8px; font-size: 0.95rem; } .export-box textarea { width: 100%; height: 50vh; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.8rem; border: 1px solid var(--rule); border-radius: 6px; padding: 10px; } .export-box .row { display: flex; justify-content: flex-end; gap: 8px; margin-top: 10px; } .export-box button { font-family: var(--sans); font-size: 0.82rem; padding: 7px 14px; border-radius: 6px; border: 1px solid var(--rule); background: #f4f2ec; cursor: pointer; } .export-box button.primary { background: var(--accent); color: #fff; border-color: var(--accent); } @media (max-width: 1100px) { .sidebar { display: none; } .sidebar.mobile-show { display: block; position: fixed; right: 0; top: 0; bottom: 0; z-index: 950; width: 86vw; max-width: 360px; background: var(--bg); padding: 1.2rem; box-shadow: -6px 0 30px rgba(0,0,0,0.2); max-height: 100vh; } } @media print { .sidebar, .sidebar-toggle, .anno-toolbar, .anno-popover, .export-overlay { display: none !important; } .anno-num { display: none !important; } .anno { background: transparent !important; } body { font-size: 12pt; background: #fff; } .page { padding: 0; } article { max-width: 100%; } } `; // --------------------------------------------------------------------------- // Klient-JS (annoteringsverktøy). Bygges inn ordrett. // --------------------------------------------------------------------------- const CLIENT_JS = String.raw` (function () { "use strict"; var STORE_KEY = "anno:" + window.__ARTICLE_KEY__; var BODY_MD = window.__BODY_MD__; var SOURCE_FILE = window.__SOURCE_FILE__; var TITLE = window.__TITLE__; var INTENTS = { change: { label: "Endre", cls: "change", upper: "CHANGE" }, add: { label: "Legg til", cls: "add", upper: "ADD" }, remove: { label: "Fjern", cls: "remove", upper: "REMOVE" }, clarify: { label: "Avklar", cls: "clarify", upper: "CLARIFY" }, risk: { label: "Risiko", cls: "risk", upper: "RISK" } }; var SWATCH = { change: "#e6a817", add: "#2e8b57", remove: "#c0392b", clarify: "#2d6cdf", risk: "#7d3c98" }; // Hele
er markerbart (tittel + ingress + brødtekst), ikke bare .body. var article = document.querySelector("article"); var toolbar = document.getElementById("annoToolbar"); var popover = document.getElementById("annoPopover"); var sidebar = document.getElementById("annoSidebar"); var listEl = document.getElementById("annoList"); var annotations = load(); var pendingRange = null; // {text} for ny annotering var editingId = null; // id når en eksisterende annotering redigeres function load() { try { var raw = localStorage.getItem(STORE_KEY); return raw ? JSON.parse(raw) : []; } catch (e) { return []; } } function save() { try { localStorage.setItem(STORE_KEY, JSON.stringify(annotations)); } catch (e) {} } // --- Markering -> verktøylinje ------------------------------------------- document.addEventListener("mouseup", function (e) { if (toolbar.contains(e.target) || popover.contains(e.target)) return; setTimeout(handleSelection, 0); }); // Hent kontekst rundt markeringen (ordene foran/bak i samme avsnitt), // slik at korte markeringer (ett ord) kan plasseres entydig i eksporten. function getContext(range, selText) { var BLOCK = /^(P|LI|H1|H2|H3|H4|BLOCKQUOTE|TD)$/; var block = range.commonAncestorContainer; if (block.nodeType === 3) block = block.parentElement; while (block && block !== article && !BLOCK.test(block.tagName)) block = block.parentElement; if (!block || block === article) return ""; try { var beforeR = document.createRange(); beforeR.selectNodeContents(block); beforeR.setEnd(range.startContainer, range.startOffset); var afterR = document.createRange(); afterR.selectNodeContents(block); afterR.setStart(range.endContainer, range.endOffset); var before = beforeR.toString().replace(/\s+/g, " "); var after = afterR.toString().replace(/\s+/g, " "); var W = 55; if (before.length > W) before = "…" + before.slice(-W); if (after.length > W) after = after.slice(0, W) + "…"; return (before + "〈" + selText + "〉" + after).trim(); } catch (e) { return ""; } } function handleSelection() { var sel = window.getSelection(); if (!sel || sel.isCollapsed) { hideToolbar(); return; } var text = sel.toString().replace(/\s+/g, " ").trim(); if (text.length < 2) { hideToolbar(); return; } var range = sel.getRangeAt(0); if (!article.contains(range.commonAncestorContainer)) { hideToolbar(); return; } var rect = range.getBoundingClientRect(); pendingRange = { text: text, context: getContext(range, text) }; showToolbar(rect); } function showToolbar(rect) { toolbar.classList.add("show"); var tw = toolbar.offsetWidth; var x = window.scrollX + rect.left + rect.width / 2 - tw / 2; var y = window.scrollY + rect.top - toolbar.offsetHeight - 8; x = Math.max(8, x); if (y < window.scrollY + 4) y = window.scrollY + rect.bottom + 8; toolbar.style.left = x + "px"; toolbar.style.top = y + "px"; popover.classList.remove("show"); } function hideToolbar() { toolbar.classList.remove("show"); } // Bygg verktøylinje-knapper Object.keys(INTENTS).forEach(function (key) { var b = document.createElement("button"); b.innerHTML = '' + INTENTS[key].label; b.addEventListener("mousedown", function (ev) { ev.preventDefault(); }); b.addEventListener("click", function () { openPopover(key); }); toolbar.appendChild(b); }); // Intent-velger i popoveren (brukes både ved ny og ved redigering) var pick = popover.querySelector(".intent-pick"); Object.keys(INTENTS).forEach(function (key) { var b = document.createElement("button"); b.type = "button"; b.dataset.intent = key; b.innerHTML = '' + INTENTS[key].label; b.addEventListener("mousedown", function (ev) { ev.preventDefault(); }); b.addEventListener("click", function () { popover.dataset.intent = key; updateIntentPick(key); }); pick.appendChild(b); }); function updateIntentPick(sel) { Array.prototype.forEach.call(pick.children, function (b) { b.classList.toggle("active", b.dataset.intent === sel); }); } function positionPopover(rect) { popover.style.left = Math.max(8, window.scrollX + rect.left) + "px"; popover.style.top = (window.scrollY + rect.top) + "px"; } function openPopover(intentKey) { if (!pendingRange) return; editingId = null; popover.querySelector(".ph").textContent = INTENTS[intentKey].label + " — «" + truncate(pendingRange.text, 60) + "»"; var ta = popover.querySelector("textarea"); ta.value = ""; popover.dataset.intent = intentKey; updateIntentPick(intentKey); popover.querySelector(".del-edit").style.display = "none"; popover.classList.add("show"); positionPopover(toolbar.getBoundingClientRect()); toolbar.classList.remove("show"); setTimeout(function () { ta.focus(); }, 10); } // Åpne popover for å REDIGERE en eksisterende annotering function openEdit(id, rect) { var a = annotations.filter(function (x) { return x.id === id; })[0]; if (!a) return; editingId = id; pendingRange = null; popover.querySelector(".ph").textContent = "Rediger — «" + truncate(a.text, 60) + "»"; var ta = popover.querySelector("textarea"); ta.value = a.comment || ""; popover.dataset.intent = a.intent; updateIntentPick(a.intent); popover.querySelector(".del-edit").style.display = ""; popover.classList.add("show"); positionPopover(rect); toolbar.classList.remove("show"); window.getSelection().removeAllRanges(); setTimeout(function () { ta.focus(); }, 10); } // Klikk på en markering i artikkelen -> rediger den article.addEventListener("click", function (e) { var span = e.target.closest ? e.target.closest("span.anno") : null; if (!span || !span.dataset.id) return; e.preventDefault(); openEdit(span.dataset.id, span.getBoundingClientRect()); }); popover.querySelector(".cancel").addEventListener("click", function () { popover.classList.remove("show"); pendingRange = null; editingId = null; }); popover.querySelector(".del-edit").addEventListener("click", function () { if (!editingId) return; annotations = annotations.filter(function (x) { return x.id !== editingId; }); editingId = null; save(); popover.classList.remove("show"); render(); }); popover.querySelector(".primary").addEventListener("click", function () { var intent = popover.dataset.intent; var cmt = popover.querySelector("textarea").value.trim(); if (editingId) { annotations.forEach(function (a) { if (a.id === editingId) { a.intent = intent; a.comment = cmt; } }); editingId = null; save(); popover.classList.remove("show"); render(); return; } if (!pendingRange) return; annotations.push({ id: Date.now() + "-" + Math.floor(Math.random() * 1e6), intent: intent, text: pendingRange.text, context: pendingRange.context || "", comment: cmt }); save(); popover.classList.remove("show"); pendingRange = null; window.getSelection().removeAllRanges(); render(); }); // --- Rendering: marker tekst i artikkelen og bygg sidepanel -------------- function render() { clearMarks(); annotations.forEach(function (a, idx) { markInArticle(a, idx + 1); }); renderList(); } function clearMarks() { var marks = article.querySelectorAll("span.anno"); marks.forEach(function (m) { var parent = m.parentNode; while (m.firstChild) { if (m.firstChild.classList && m.firstChild.classList.contains("anno-num")) { m.removeChild(m.firstChild); } else { parent.insertBefore(m.firstChild, m); } } parent.removeChild(m); parent.normalize(); }); } // Finn første tekst-treff i artikkelen og pakk det inn. Teller per-tekst // forekomster slik at like sitater markeres i rekkefølge. var occCounters = {}; function markInArticle(a, num) { var needle = a.text; if (!occCounters[needle]) occCounters[needle] = 0; var skip = occCounters[needle]; occCounters[needle]++; var walker = document.createTreeWalker(article, NodeFilter.SHOW_TEXT, null); var node, found = 0; while ((node = walker.nextNode())) { var nv = node.nodeValue; var pos = nv.indexOf(needle); while (pos !== -1) { if (found === skip) { wrapTextNode(node, pos, needle.length, a, num); return; } found++; pos = nv.indexOf(needle, pos + 1); } } } function wrapTextNode(node, start, len, a, num) { var range = document.createRange(); range.setStart(node, start); range.setEnd(node, start + len); var span = document.createElement("span"); span.className = "anno anno-" + INTENTS[a.intent].cls; span.dataset.id = a.id; span.title = INTENTS[a.intent].label + (a.comment ? ": " + a.comment : ""); try { range.surroundContents(span); var sup = document.createElement("sup"); sup.className = "anno-num"; sup.textContent = num; span.appendChild(sup); } catch (e) { /* range spente over elementgrense — hopp over markering */ } } function renderList() { occCounters = {}; // nullstill for neste render listEl.innerHTML = ""; if (!annotations.length) { var em = document.createElement("div"); em.className = "empty"; em.textContent = "Ingen annoteringer ennå. Marker tekst i artikkelen for å begynne."; listEl.appendChild(em); return; } annotations.forEach(function (a, idx) { var item = document.createElement("div"); item.className = "anno-item"; var cls = INTENTS[a.intent].cls; item.innerHTML = '
' + '' + (idx + 1) + '' + '' + INTENTS[a.intent].label + '' + '' + '' + '
' + '
«' + escHtml(truncate(a.text, 90)) + '»
' + (a.comment ? '
' + escHtml(a.comment) + '
' : ''); 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)}

` : ""}
${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); }