1064 lines
36 KiB
JavaScript
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, "&")
|
|
.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) => `<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, "&").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 `<!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();
|
|
}
|