ktg-plugin-marketplace/plugins/linkedin-thought-leadership/render/build-html.mjs
2026-05-26 23:05:54 +02:00

1064 lines
36 KiB
JavaScript

#!/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/<samme-navn>.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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
// ---------------------------------------------------------------------------
// 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) => `<code>${c}</code>`);
// **fet** før *kursiv* for å unngå konflikt
out = out.replace(/\*\*([^*]+)\*\*/g, (_, c) => `<strong>${c}</strong>`);
out = out.replace(/\*([^*]+)\*/g, (_, c) => `<em>${c}</em>`);
return out;
}
// ---------------------------------------------------------------------------
// Overskrift: # -> <h1> ... #### -> <h4> (klemmes til h4; dypere nivåer
// kollapser til <h4>). Tar antall #-tegn + raw heading-tekst.
// ---------------------------------------------------------------------------
export function renderHeading(hashes, text) {
const level = Math.min(hashes, 4); // # .. #### -> <h1> .. <h4>
return `<h${level}>${inline(text.trim())}</h${level}>`;
}
// ---------------------------------------------------------------------------
// Tabell: en sammenhengende blokk av `| a | b |`-rader -> <table>.
// Rad 1 = header (<th>). En påfølgende separator-rad (| --- | --- |) hoppes
// over. Øvrige rader = <td>. 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 =
"<thead><tr>" +
header.map((c) => `<th>${inline(c)}</th>`).join("") +
"</tr></thead>";
const tbody =
"<tbody>" +
bodyRows
.map(
(r) =>
"<tr>" +
splitRow(r)
.map((c) => `<td>${inline(c)}</td>`)
.join("") +
"</tr>"
)
.join("") +
"</tbody>";
return `<table>${thead}${tbody}</table>`;
}
// 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(`<p class="${cls}">${inline(text)}</p>`);
}
while (i < lines.length) {
const line = lines[i];
const trimmed = line.trim();
// Horisontal linje
if (/^---+$/.test(trimmed)) {
blocks.push("<hr>");
i++;
continue;
}
// Overskrifter (# .. ###### -> <h1> .. <h4>, 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(`<blockquote><p>${inline(qbuf.join(" ").trim())}</p></blockquote>`);
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(
"<ul>" + items.map((it) => `<li>${inline(it)}</li>`).join("") + "</ul>"
);
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(
"<ol>" + items.map((it) => `<li>${inline(it)}</li>`).join("") + "</ol>"
);
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 <article> 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 = '<span class="swatch" style="background:' + SWATCH[key] + '"></span>' + 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 = '<span class="swatch" style="background:' + SWATCH[key] + '"></span>' + 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 =
'<div class="top">' +
'<span class="num">' + (idx + 1) + '</span>' +
'<span class="badge badge-' + cls + '">' + INTENTS[a.intent].label + '</span>' +
'<button class="edit" title="Endre">Endre</button>' +
'<button class="del" title="Slett">Slett</button>' +
'</div>' +
'<div class="quote">«' + escHtml(truncate(a.text, 90)) + '»</div>' +
(a.comment ? '<div class="cmt">' + escHtml(a.comment) + '</div>' : '');
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
// --- 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 `<!DOCTYPE html>
<html lang="no">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${esc(title)}</title>
<style>${CSS}</style>
</head>
<body>
<button class="sidebar-toggle" id="sidebarToggle">Annoteringer</button>
<div class="page">
<article>
${meta.kicker ? `<p class="kicker">${esc(meta.kicker)}</p>` : ""}
<h1 class="title">${inline(title)}</h1>
${meta.subtitle ? `<p class="subtitle">${inline(meta.subtitle)}</p>` : ""}
<div class="byline-wrap">
${meta.byline ? `<p class="byline">${esc(meta.byline)}</p>` : ""}
${metaLine ? `<p class="meta">${esc(metaLine)}</p>` : ""}
</div>
<div class="body">
${bodyHtml}
</div>
</article>
<aside class="sidebar" id="annoSidebar">
<h2>Annoteringer</h2>
<div class="actions">
<button class="primary" id="btnExport">Kopier annoteringer</button>
<button id="btnClear">Tøm alle</button>
</div>
<div id="annoList"></div>
</aside>
</div>
<div class="anno-toolbar" id="annoToolbar"></div>
<div class="anno-popover" id="annoPopover">
<div class="ph"></div>
<div class="intent-pick"></div>
<textarea placeholder="Skriv kommentar (valgfritt)…"></textarea>
<div class="row">
<button class="del-edit" style="display:none;">Slett</button>
<button class="cancel">Avbryt</button>
<button class="primary">Lagre</button>
</div>
</div>
<div class="export-overlay" id="exportOverlay">
<div class="export-box">
<h3>Annoteringer — kopier og lim tilbake</h3>
<button class="primary" id="btnExportTop" style="margin-bottom:8px;">Kopier til utklippstavle</button>
<textarea id="exportText" readonly></textarea>
<div class="row">
<button id="exportCopy">Kopier</button>
<button class="primary" id="exportClose">Lukk</button>
</div>
</div>
</div>
<script>
window.__ARTICLE_KEY__ = ${toJsString(articleKey)};
window.__BODY_MD__ = ${toJsString(body)};
window.__SOURCE_FILE__ = ${toJsString(sourceFile)};
window.__TITLE__ = ${toJsString(title)};
</script>
<script>
${CLIENT_JS}
</script>
</body>
</html>
`;
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
export function main() {
const args = process.argv.slice(2);
if (!args.length) {
console.error("Bruk: node build-html.mjs <fil.md> [flere.md ...]");
process.exit(1);
}
// 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 });
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)`);
}
}
// CLI-guard: kjør kun når scriptet startes direkte, ikke ved import
// (mønster fra hooks/scripts/state-updater.mjs).
if (import.meta.url === `file://${process.argv[1]}`) {
main();
}