diff --git a/plugins/linkedin-thought-leadership/render/OFL.txt b/plugins/linkedin-thought-leadership/render/OFL.txt
new file mode 100644
index 0000000..8a4d081
--- /dev/null
+++ b/plugins/linkedin-thought-leadership/render/OFL.txt
@@ -0,0 +1,91 @@
+Copyright (c) The Inter Project Authors (https://github.com/rsms/inter)
+Copyright (c) The Newsreader Project Authors (https://github.com/productiontype/Newsreader)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+https://openfontlicense.org
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply to any
+document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may include
+source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical writer or
+other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining a
+copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components, in
+Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or in
+the appropriate machine-readable metadata fields within text or binary
+files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name
+as presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any Modified
+Version, except to acknowledge the contribution(s) of the Copyright
+Holder(s) and the Author(s) or with their explicit written permission.
+
+5) The Font Software, modified or unmodified, in part or in whole, must be
+distributed entirely under this license, and must not be distributed under
+any other license. The requirement for fonts to remain under this license
+does not apply to any document created using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are not
+met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF
+COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS
+IN THE FONT SOFTWARE.
diff --git a/plugins/linkedin-thought-leadership/render/__tests__/weasyprint-degradation.test.mjs b/plugins/linkedin-thought-leadership/render/__tests__/weasyprint-degradation.test.mjs
new file mode 100644
index 0000000..fccda48
--- /dev/null
+++ b/plugins/linkedin-thought-leadership/render/__tests__/weasyprint-degradation.test.mjs
@@ -0,0 +1,36 @@
+import { describe, test } from 'node:test';
+import assert from 'node:assert/strict';
+import { resolveWeasyprint as resolvePdf } from '../build-pdf.mjs';
+import { resolveWeasyprint as resolveCarousel } from '../build-carousel.mjs';
+
+// S1 (correction #3): when weasyprint is not resolvable on PATH, the degradation
+// helper must return a skip-signal (NOT throw) and emit an install hint, so the
+// render scripts can skip the PDF step gracefully instead of crashing.
+
+for (const [name, resolveWeasyprint] of [
+ ['build-pdf', resolvePdf],
+ ['build-carousel', resolveCarousel],
+]) {
+ describe(`resolveWeasyprint — ${name}`, () => {
+ test('returns a skip-signal (not a throw) when weasyprint is absent', () => {
+ let result;
+ assert.doesNotThrow(() => {
+ result = resolveWeasyprint(() => false);
+ });
+ assert.equal(result.available, false);
+ });
+
+ test('emits an install hint when absent', () => {
+ const result = resolveWeasyprint(() => false);
+ assert.ok(typeof result.hint === 'string' && result.hint.length > 0);
+ assert.match(result.hint, /weasyprint/i);
+ assert.match(result.hint, /install/i);
+ });
+
+ test('reports available when the probe succeeds', () => {
+ const result = resolveWeasyprint(() => true);
+ assert.equal(result.available, true);
+ assert.equal(result.hint, undefined);
+ });
+ });
+}
diff --git a/plugins/linkedin-thought-leadership/render/build-carousel.mjs b/plugins/linkedin-thought-leadership/render/build-carousel.mjs
new file mode 100644
index 0000000..623d5b6
--- /dev/null
+++ b/plugins/linkedin-thought-leadership/render/build-carousel.mjs
@@ -0,0 +1,302 @@
+#!/usr/bin/env node
+// build-carousel.mjs — render en LinkedIn-carousel (dokument-PDF) fra slide-markdown.
+// Bruk: node build-carousel.mjs linkedin/06/carousel.md [linkedin/03/carousel.md ...]
+// Hver "## SLIDE N — ..." blir én portrett-side (1080×1350, 4:5) i PDF-en.
+// Designet typografisk deck — speiler avis-identiteten (Newsreader/Inter, off-white,
+// oxblood). Cover (slide 1) + CTA (siste slide) = oxblood-bokstøtter; de øvrige lyse
+// med bolk-kicker + footer (Maskinrommet + teller). Ingen per-slide AI-foto.
+// Krever: weasyprint på PATH. Ingen npm-avhengigheter.
+
+import fs from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { execFileSync } from "node:child_process";
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+
+// ---------------------------------------------------------------------------
+// weasyprint graceful degradation (S1, correction #3)
+// Detekterer weasyprint på PATH. Returnerer et skip-signal (kaster ALDRI) når
+// verktøyet mangler, slik at PDF-steget hoppes over med en tydelig install-hint
+// i stedet for å krasje kjøringen. `probe` er injiserbar for test.
+// ---------------------------------------------------------------------------
+const WEASYPRINT_HINT =
+ "weasyprint ikke funnet på PATH — hopper over PDF-steget.\n" +
+ " Install: pipx install weasyprint (alternativt: brew install weasyprint)";
+
+export function resolveWeasyprint(probe = defaultWeasyprintProbe) {
+ if (probe()) return { available: true };
+ return { available: false, hint: WEASYPRINT_HINT };
+}
+
+function defaultWeasyprintProbe() {
+ try {
+ execFileSync("weasyprint", ["--version"], { stdio: "ignore" });
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Inline markdown (**fet**, *kursiv*) + escaping
+// ---------------------------------------------------------------------------
+function esc(s) {
+ return s.replace(/&/g, "&").replace(//g, ">");
+}
+function inline(text) {
+ let out = esc(text);
+ out = out.replace(/\*\*([^*]+)\*\*/g, (_, c) => `${c} `);
+ out = out.replace(/\*([^*]+)\*/g, (_, c) => `${c} `);
+ return out;
+}
+
+// ---------------------------------------------------------------------------
+// Valgfri YAML front matter (flate key: "value"-par) — for cover/CTA-eyebrow.
+// Felt: cover_eyebrow, cta_eyebrow. Faller tilbake til generiske default-er.
+// ---------------------------------------------------------------------------
+function parseFrontMatter(raw) {
+ const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
+ if (!m) return { meta: {}, rest: 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, rest: m[2] };
+}
+
+// ---------------------------------------------------------------------------
+// Parse slide-markdown.
+// Slides skilles av "## SLIDE N — label". Innenfor en slide:
+// - linje `…` (backticks) -> kicker (bolk-merkelapp)
+// - linje "# …" -> tittel
+// - øvrige ikke-tomme -> brødtekst-avsnitt (ett per linje)
+// ---------------------------------------------------------------------------
+function parseSlides(raw) {
+ const body = raw.replace(/\r\n/g, "\n");
+ // dropp ev. innledende H1 + forklarings-avsnitt før første "## SLIDE"
+ const startIdx = body.indexOf("## SLIDE");
+ const region = startIdx >= 0 ? body.slice(startIdx) : body;
+ const chunks = region.split(/^##\s+SLIDE\b.*$/m).map((c) => c.trim()).filter(Boolean);
+
+ return chunks.map((chunk) => {
+ const lines = chunk.split("\n");
+ let kicker = null;
+ let title = null;
+ const bodyParas = [];
+ for (const lnRaw of lines) {
+ const ln = lnRaw.trim();
+ if (!ln) continue;
+ if (ln.startsWith("---")) continue;
+ const km = ln.match(/^`([^`]+)`$/);
+ if (km && !kicker && !title) {
+ kicker = km[1].trim();
+ continue;
+ }
+ if (ln.startsWith("# ")) {
+ title = ln.slice(2).trim();
+ continue;
+ }
+ bodyParas.push(ln);
+ }
+ return { kicker, title, bodyParas };
+ });
+}
+
+// ---------------------------------------------------------------------------
+// CSS — portrett 4:5, avis-identitet. Oxblood-bokstøtter for cover/CTA.
+// ---------------------------------------------------------------------------
+const FONT_DIR = path.join(__dirname, "fonts");
+const ff = (f) => `url("file://${path.join(FONT_DIR, f).replace(/ /g, "%20")}")`;
+const FONT_FACE = `
+@font-face{font-family:"Newsreader";font-style:normal;font-weight:400;src:${ff("Newsreader-400.ttf")};}
+@font-face{font-family:"Newsreader";font-style:italic;font-weight:400;src:${ff("Newsreader-400i.ttf")};}
+@font-face{font-family:"Newsreader";font-style:normal;font-weight:600;src:${ff("Newsreader-600.ttf")};}
+@font-face{font-family:"Newsreader";font-style:italic;font-weight:600;src:${ff("Newsreader-600i.ttf")};}
+@font-face{font-family:"Newsreader";font-style:normal;font-weight:700;src:${ff("Newsreader-700.ttf")};}
+@font-face{font-family:"Inter";font-style:normal;font-weight:400;src:${ff("Inter-400.ttf")};}
+@font-face{font-family:"Inter";font-style:normal;font-weight:600;src:${ff("Inter-600.ttf")};}
+@font-face{font-family:"Inter";font-style:normal;font-weight:700;src:${ff("Inter-700.ttf")};}
+`;
+
+const CSS = `
+${FONT_FACE}
+:root{
+ --bg:#FBFAF7; --ink:#1A1A1A; --muted:#5b5750; --accent:#9A3324;
+ --rule:#d8d4cb; --cream:#F4EFE6;
+ --serif:"Newsreader",Georgia,serif;
+ --sans:"Inter","Helvetica Neue",Arial,sans-serif;
+}
+@page{ size:1080px 1350px; margin:0; }
+*{ box-sizing:border-box; margin:0; padding:0; }
+html,body{ background:var(--bg); }
+.slide{
+ position:relative;
+ width:1080px; height:1350px;
+ padding:96px 96px 92px;
+ background:var(--bg); color:var(--ink);
+ font-family:var(--serif);
+ page-break-after:always;
+ overflow:hidden;
+}
+.slide:last-child{ page-break-after:auto; }
+
+/* ---- kicker (bolk-merkelapp) ---- */
+.kicker{
+ display:inline-block;
+ font-family:var(--sans); font-weight:700;
+ text-transform:uppercase; letter-spacing:0.14em;
+ font-size:22px; color:#fff; background:var(--accent);
+ padding:9px 18px; border-radius:3px;
+ margin-bottom:46px;
+}
+
+/* ---- tittel / brødtekst ---- */
+.title{
+ font-family:var(--serif); font-weight:700;
+ font-size:74px; line-height:1.06; letter-spacing:-0.012em;
+ margin-bottom:40px;
+}
+.body p{
+ font-family:var(--serif); font-weight:400;
+ font-size:35px; line-height:1.45; color:#2b2823;
+ margin-bottom:22px;
+}
+.body p strong{ font-weight:700; color:var(--ink); }
+.body p em{ font-style:italic; }
+
+/* ---- footer ---- */
+.footer{
+ position:absolute; left:96px; right:96px; bottom:64px;
+ display:flex; justify-content:space-between; align-items:center;
+ border-top:1px solid var(--rule); padding-top:22px;
+ font-family:var(--sans); font-size:21px; letter-spacing:0.04em;
+}
+.footer .brand{ font-weight:700; text-transform:uppercase; letter-spacing:0.13em; color:var(--accent); }
+.footer .count{ color:var(--muted); font-weight:600; }
+
+/* ---- interior layout (grep): topp-justert under kicker ---- */
+.slide.interior .stage{ }
+
+/* ---- cover + CTA: oxblood-bokstøtter ---- */
+.slide.bookend{
+ background:var(--accent); color:var(--cream);
+ display:flex; flex-direction:column; justify-content:center;
+}
+.slide.bookend .eyebrow{
+ font-family:var(--sans); font-weight:700; text-transform:uppercase;
+ letter-spacing:0.18em; font-size:23px; color:#F0C9B6; margin-bottom:34px;
+}
+.slide.bookend .title{ color:#fff; font-size:86px; line-height:1.02; margin-bottom:40px; }
+.slide.bookend .body p{ color:#F4E4D8; font-size:38px; line-height:1.42; }
+.slide.bookend .body p strong{ color:#fff; }
+.slide.bookend .footer{
+ border-top:1px solid rgba(244,228,216,0.32);
+ color:#F0C9B6;
+}
+.slide.bookend .footer .brand{ color:#fff; }
+.slide.bookend .footer .count{ color:#F0C9B6; }
+.slide.bookend .arrow{ font-size:40px; }
+`;
+
+// ---------------------------------------------------------------------------
+// Render én slide til HTML
+// ---------------------------------------------------------------------------
+function slideHtml(slide, idx, total, eyebrows) {
+ const isCover = idx === 0;
+ const isCta = idx === total - 1;
+ const bookend = isCover || isCta;
+ const cls = bookend ? "slide bookend" : "slide interior";
+
+ const titleHtml = slide.title ? `
${inline(slide.title)} ` : "";
+ const bodyHtml = slide.bodyParas.length
+ ? `${slide.bodyParas.map((p) => `
${inline(p)}
`).join("")}
`
+ : "";
+
+ // kicker: interior bruker bolk-merkelapp; bookend bruker eyebrow (cover/CTA)
+ let head = "";
+ if (bookend) {
+ const eyebrow = isCover ? eyebrows.cover : eyebrows.cta;
+ head = `${esc(eyebrow)}
`;
+ } else if (slide.kicker) {
+ head = `${esc(slide.kicker)} `;
+ }
+
+ const counter = `${idx + 1} / ${total}`;
+ const footer = ``;
+
+ return `
+ ${head}
+ ${titleHtml}
+ ${bodyHtml}
+ ${footer}
+ `;
+}
+
+function buildHtml(slides, eyebrows) {
+ const total = slides.length;
+ const slidesHtml = slides.map((s, i) => slideHtml(s, i, total, eyebrows)).join("\n");
+ return `
+
+Carousel
+
+${slidesHtml}
+
+
+`;
+}
+
+// ---------------------------------------------------------------------------
+// Main
+// ---------------------------------------------------------------------------
+function main() {
+ const args = process.argv.slice(2);
+ if (!args.length) {
+ console.error("Bruk: node build-carousel.mjs [flere.md ...]");
+ process.exit(1);
+ }
+
+ const wp = resolveWeasyprint();
+ if (!wp.available) console.warn(wp.hint);
+
+ 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 } = parseFrontMatter(raw);
+ const slides = parseSlides(raw);
+ if (!slides.length) {
+ console.error(`Ingen slides funnet i ${inPath}`);
+ continue;
+ }
+ const eyebrows = {
+ cover: meta.cover_eyebrow || "Maskinrommet",
+ cta: meta.cta_eyebrow || "Kom i gang",
+ };
+ const dir = path.dirname(inPath);
+ const html = buildHtml(slides, eyebrows);
+ const htmlPath = path.join(dir, "carousel.html");
+ const pdfPath = path.join(dir, "carousel.pdf");
+ fs.writeFileSync(htmlPath, html, "utf8");
+ if (wp.available) {
+ execFileSync("weasyprint", [htmlPath, pdfPath], { stdio: ["ignore", "ignore", "inherit"] });
+ const kb = (fs.statSync(pdfPath).size / 1024).toFixed(1);
+ console.log(`Carousel: ${pdfPath} (${slides.length} slides, ${kb} KB)`);
+ } else {
+ console.warn(` Hoppet over PDF (weasyprint mangler): ${pdfPath}`);
+ }
+ }
+}
+
+if (import.meta.url === `file://${process.argv[1]}`) {
+ main();
+}
diff --git a/plugins/linkedin-thought-leadership/render/build-html.mjs b/plugins/linkedin-thought-leadership/render/build-html.mjs
new file mode 100644
index 0000000..ce72929
--- /dev/null
+++ b/plugins/linkedin-thought-leadership/render/build-html.mjs
@@ -0,0 +1,963 @@
+#!/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: **fet**, *kursiv*. «» og — beholdes uendret.
+// Tar uescapet tekst, returnerer escaped HTML med inline-tagger.
+// ---------------------------------------------------------------------------
+function inline(text) {
+ let out = esc(text);
+ // **fet** før *kursiv* for å unngå konflikt
+ out = out.replace(/\*\*([^*]+)\*\*/g, (_, c) => `${c} `);
+ out = out.replace(/\*([^*]+)\*/g, (_, c) => `${c} `);
+ return out;
+}
+
+// ---------------------------------------------------------------------------
+// Kompakt markdown -> HTML for body.
+// Håndterer: ## / ### overskrifter, - punktlister, 1. nummererte lister,
+// > blockquote, --- horisontal linje, og avsnitt (blanklinje-separert).
+// Første avsnitt får drop-cap-klasse. Avsnitt etter det første: .indent.
+// ---------------------------------------------------------------------------
+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
+ let hm = trimmed.match(/^(#{2,3})\s+(.*)$/);
+ if (hm) {
+ const level = hm[1].length; // 2 eller 3
+ blocks.push(`${inline(hm[2].trim())} `);
+ i++;
+ continue;
+ }
+
+ // Blockquote (sammenhengende > -linjer)
+ if (/^>\s?/.test(trimmed)) {
+ const qbuf = [];
+ while (i < lines.length && /^>\s?/.test(lines[i].trim())) {
+ qbuf.push(lines[i].trim().replace(/^>\s?/, ""));
+ i++;
+ }
+ blocks.push(`${inline(qbuf.join(" ").trim())}
`);
+ continue;
+ }
+
+ // Punktliste
+ if (/^[-*]\s+/.test(trimmed)) {
+ const items = [];
+ while (i < lines.length && /^[-*]\s+/.test(lines[i].trim())) {
+ items.push(lines[i].trim().replace(/^[-*]\s+/, ""));
+ i++;
+ }
+ blocks.push(
+ "" + items.map((it) => `${inline(it)} `).join("") + " "
+ );
+ continue;
+ }
+
+ // Nummerert liste
+ if (/^\d+\.\s+/.test(trimmed)) {
+ const items = [];
+ while (i < lines.length && /^\d+\.\s+/.test(lines[i].trim())) {
+ items.push(lines[i].trim().replace(/^\d+\.\s+/, ""));
+ i++;
+ }
+ blocks.push(
+ "" + items.map((it) => `${inline(it)} `).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) ||
+ /^(#{2,3})\s+/.test(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 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 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 + ' ' +
+ 'Endre ' +
+ 'Slett ' +
+ '
' +
+ '«' + 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)}
` : ""}
+
+ ${meta.byline ? `
${esc(meta.byline)}
` : ""}
+ ${metaLine ? `
${esc(metaLine)}
` : ""}
+
+
+${bodyHtml}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Slett
+ Avbryt
+ Lagre
+
+
+
+
+
+
Annoteringer — kopier og lim tilbake
+
Kopier til utklippstavle
+
+
+ Kopier
+ Lukk
+
+
+
+
+
+
+
+
+`;
+}
+
+// ---------------------------------------------------------------------------
+// Main
+// ---------------------------------------------------------------------------
+function main() {
+ const args = process.argv.slice(2);
+ if (!args.length) {
+ console.error("Bruk: node build-html.mjs [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)`);
+ }
+}
+
+main();
diff --git a/plugins/linkedin-thought-leadership/render/build-linkedin.mjs b/plugins/linkedin-thought-leadership/render/build-linkedin.mjs
new file mode 100644
index 0000000..b90758e
--- /dev/null
+++ b/plugins/linkedin-thought-leadership/render/build-linkedin.mjs
@@ -0,0 +1,364 @@
+#!/usr/bin/env node
+// build-linkedin.mjs — bygger ÉN SAMLET POST.html per artikkel (publiseringsark).
+// Bruk: node build-linkedin.mjs utkast/01-...md [flere.md ...]
+// (samle/POST.html bygges alltid til slutt, uavhengig av argumentene)
+//
+// Mål (HANDOVER §13 E): alt-på-ett-sted per artikkel slik at bruker kan legge inn én edition
+// i én operasjon. POST.html åpnes i nettleser og inneholder, i publiseringsrekkefølge:
+// 1. Planlagt dato + kl. 08:00 (+ ferskvare-flagg for 01/02)
+// 2. Tittel / SEO-tittel / SEO-beskrivelse
+// 3. Cover: filnavn + credit + caption
+// 4. «Tell your network»-delingstekst (system, klikk-gatet) + hashtags
+// 5. Første kommentar
+// 6. (Del 3/6) carousel-PDF-referanse
+// 7. Brødtekst som RIK TEKST mellom streker (merk & kopier rett inn i editoren)
+//
+// Kilder:
+// - brødtekst + felt: front matter + body i utkast/NN-...md
+// - delingstekst/hashtags/første kommentar: linkedin/edition-delingstekst.md (parses)
+// - kalender + ferskvare + cover-credit/caption: konstanter nedenfor (HANDOVER §13 + image-credit-caption.md)
+// Ingen npm-avhengigheter, ingen nett. meta.md røres IKKE (håndholdt; har ekte pulse-URL).
+
+import fs from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+// Output følger serien (kjøres fra serie-mappa), ikke scriptet i tools/.
+const OUT_ROOT = path.join(process.cwd(), "linkedin");
+const DELINGSTEKST_FILE = path.join(OUT_ROOT, "edition-delingstekst.md");
+
+// ---------------------------------------------------------------------------
+// LÅSTE KONSTANTER (HANDOVER §13 / DREIEBOK / image-credit-caption.md)
+// ---------------------------------------------------------------------------
+const CALENDAR = {
+ "01": { dag: "Tirsdag 26.05.2026", klokke: "08:00" },
+ "02": { dag: "Onsdag 27.05.2026", klokke: "08:00" },
+ "03": { dag: "Torsdag 28.05.2026", klokke: "08:00" },
+ "04": { dag: "Fredag 29.05.2026", klokke: "08:00" },
+ "05": { dag: "Lørdag 30.05.2026", klokke: "08:00" },
+ "06": { dag: "Søndag 31.05.2026", klokke: "08:00" },
+ samle: { dag: "Mandag 01.06.2026", klokke: "08:00" },
+};
+
+const FRESHNESS = {
+ "01": "OpenAI-verdsettelse (~850 mrd USD «i mars») + «Anthropic forhandler akkurat nå om en runde» — er runden lukket? Oppdater tall/tempus FØR planlegging.",
+ "02": "SWE-bench Verified (77,6 %) for Mistral Medium 3.5 — fortsatt korrekt? Vurder avrunding FØR planlegging.",
+};
+
+const COVER_CREDIT = "Illustrasjon generert med Google Gemini (Nano Banana Pro)";
+const CAPTIONS = {
+ "01": "Noen lover vekst — men hvem tjener på at ledergruppen tror på pitchen?",
+ "02": "KI på maskiner vi styrer selv — der ingen data forlater huset.",
+ "03": "Regningen kommer hver måned — og en voksende del går ut av landet.",
+ "04": "Samme talent, ulik tilgang — det er der gapet begynner.",
+ "05": "Å forvalte var jobben. Nå er den å lede omstilling — om igjen, hvert år.",
+ "06": "Tolv grep en leder kan ta selv — de to første er allerede krysset av.",
+};
+const CAROUSEL = new Set(["03", "06"]);
+
+// ---------------------------------------------------------------------------
+// YAML front matter (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+/, "") };
+}
+
+function esc(s) {
+ return s.replace(/&/g, "&").replace(//g, ">");
+}
+
+// Inline: **fet**, *kursiv*, bare URL → lenke. «», — beholdes.
+function inline(text) {
+ let out = esc(text);
+ out = out.replace(/\*\*([^*]+)\*\*/g, (_, c) => `${c} `);
+ out = out.replace(/\*([^*]+)\*/g, (_, c) => `${c} `);
+ out = out.replace(
+ /(https?:\/\/[^\s<]+)/g,
+ (u) => `${u} `
+ );
+ return out;
+}
+
+// ---------------------------------------------------------------------------
+// Markdown -> REN semantisk HTML (tagger LinkedIn-editoren kjenner igjen:
+// h2, h3, p, ul/ol/li, blockquote, strong, em, hr).
+// ---------------------------------------------------------------------------
+function markdownToBlocks(body) {
+ const lines = body.replace(/\r\n/g, "\n").split("\n");
+ const out = [];
+ let i = 0;
+ let para = [];
+
+ function flushPara() {
+ if (para.length) out.push(`${inline(para.join(" "))}
`);
+ para = [];
+ }
+
+ while (i < lines.length) {
+ const t = lines[i].trim();
+ if (t === "") { flushPara(); i++; continue; }
+ if (t === "---") { flushPara(); out.push(" "); i++; continue; }
+ if (/^###\s+/.test(t)) { flushPara(); out.push(`${inline(t.replace(/^###\s+/, ""))} `); i++; continue; }
+ if (/^##\s+/.test(t)) { flushPara(); out.push(`${inline(t.replace(/^##\s+/, ""))} `); i++; continue; }
+ if (/^>\s?/.test(t)) {
+ flushPara();
+ const q = [];
+ while (i < lines.length && /^>\s?/.test(lines[i].trim())) {
+ q.push(lines[i].trim().replace(/^>\s?/, ""));
+ i++;
+ }
+ out.push(`${inline(q.join(" "))}
`);
+ continue;
+ }
+ if (/^[-*]\s+/.test(t)) {
+ flushPara();
+ const items = [];
+ while (i < lines.length && /^[-*]\s+/.test(lines[i].trim())) {
+ items.push(lines[i].trim().replace(/^[-*]\s+/, ""));
+ i++;
+ }
+ out.push(`${items.map((x) => `${inline(x)} `).join("")} `);
+ continue;
+ }
+ if (/^\d+\.\s+/.test(t)) {
+ flushPara();
+ const items = [];
+ while (i < lines.length && /^\d+\.\s+/.test(lines[i].trim())) {
+ items.push(lines[i].trim().replace(/^\d+\.\s+/, ""));
+ i++;
+ }
+ out.push(`${items.map((x) => `${inline(x)} `).join("")} `);
+ continue;
+ }
+ para.push(t);
+ i++;
+ }
+ flushPara();
+ return out;
+}
+
+// ---------------------------------------------------------------------------
+// SEO
+// ---------------------------------------------------------------------------
+function seoDescription(subtitle) {
+ const s = (subtitle || "").replace(/\s+/g, " ").trim();
+ if (s.length <= 160) return s;
+ let cut = s.slice(0, 158);
+ cut = cut.slice(0, cut.lastIndexOf(" "));
+ return cut + "…";
+}
+function seoTitle(title) {
+ return (title || "").replace(/\s+/g, " ").trim();
+}
+
+// ---------------------------------------------------------------------------
+// Parse edition-delingstekst.md → { "01": {share, hashtags, kommentar}, ..., samle: {...} }
+// En seksjon = «## Del N — …» eller «## Samle…». «## SYSTEM …» ignoreres.
+// ---------------------------------------------------------------------------
+function parseDelingstekst() {
+ const raw = fs.readFileSync(DELINGSTEKST_FILE, "utf8").replace(/\r\n/g, "\n");
+ const lines = raw.split("\n");
+ const out = {};
+ let i = 0;
+ while (i < lines.length) {
+ const h = lines[i].match(/^##\s+(Del\s+(\d)|Samle)/i);
+ if (!h) { i++; continue; }
+ const key = h[2] ? h[2].padStart(2, "0") : "samle";
+ i++;
+ const shareLines = [];
+ let hashtags = "";
+ let kommentar = "";
+ while (i < lines.length && !/^##\s+/.test(lines[i]) && lines[i].trim() !== "---") {
+ const t = lines[i];
+ const tt = t.trim();
+ const km = tt.match(/^\*\*Første kommentar:\*\*\s*(.*)$/);
+ if (km) { kommentar = km[1].trim(); i++; continue; }
+ if (/^#\S/.test(tt)) { hashtags = tt; i++; continue; }
+ if (/^>/.test(tt)) { i++; continue; } // hopp over NB-blockquote
+ shareLines.push(t);
+ i++;
+ }
+ out[key] = {
+ share: shareLines.join("\n").trim(),
+ hashtags,
+ kommentar,
+ };
+ }
+ return out;
+}
+
+// ---------------------------------------------------------------------------
+// HTML-skall
+// ---------------------------------------------------------------------------
+const CSS = `
+ body { font: 16px/1.6 -apple-system, Segoe UI, Roboto, sans-serif; max-width: 760px;
+ margin: 24px auto; padding: 0 22px 60px; color: #1a1a1a; }
+ h1.sheet { font-size: 1.5em; margin: 0 0 2px; }
+ .when { font-size: 1.05em; font-weight: 700; color: #9a3324; margin: 0 0 18px; }
+ .fresh { background: #fff7e6; border: 1px solid #f0c97a; border-radius: 8px;
+ padding: 12px 16px; font-size: 14px; color: #5a4500; margin: 0 0 20px; }
+ .fld { background: #f6f6f4; border: 1px solid #e2e2dc; border-radius: 10px;
+ padding: 14px 18px; margin: 0 0 16px; }
+ .fld h2 { font-size: .82em; text-transform: uppercase; letter-spacing: .06em;
+ color: #777; margin: 0 0 8px; }
+ .fld .label { font-size: 12px; color: #888; margin: 10px 0 1px; }
+ .fld .val { font-size: 15px; }
+ .warn { color: #9a3324; font-weight: 600; }
+ .copybox { background: #fff; border: 1px dashed #9a3324; border-radius: 8px;
+ padding: 12px 14px; white-space: pre-wrap; font-size: 15px; margin-top: 4px; }
+ .marker { color: #9a3324; font-weight: 700; letter-spacing: .04em; font-size: 13px;
+ text-transform: uppercase; margin: 26px 0 6px; }
+ .copyzone { border-top: 2px dashed #9a3324; border-bottom: 2px dashed #9a3324; padding: 18px 0; }
+ .copyzone h2 { font-size: 1.32em; margin: 1.4em 0 .4em; }
+ .copyzone h3 { font-size: 1.1em; margin: 1.2em 0 .3em; }
+ .copyzone blockquote { border-left: 3px solid #ccc; margin: 1em 0; padding-left: 16px; color: #444; }
+ .copyzone hr { border: none; border-top: 1px solid #ddd; margin: 1.6em 0; }
+ .copyzone li { margin: .2em 0; }
+ code { background: #ececec; padding: 1px 5px; border-radius: 4px; font-size: 13px; }
+`;
+
+function shell(title, inner) {
+ return `
+
+
+
+${esc(title)}
+
+
+
+${inner}
+
+`;
+}
+
+// ---------------------------------------------------------------------------
+// Edition-POST.html (Del 1–6)
+// ---------------------------------------------------------------------------
+function editionPost(nn, meta, body, share) {
+ const cal = CALENDAR[nn] || { dag: "—", klokke: "08:00" };
+ const sTitle = seoTitle(meta.title);
+ const sDesc = seoDescription(meta.subtitle);
+ const seoWarn = sTitle.length > 60 ? ` ⚠️ ${sTitle.length} tegn (over SEO-anbefaling 60) ` : ` (${sTitle.length} tegn)`;
+ const fresh = FRESHNESS[nn];
+ const blocks = markdownToBlocks(body);
+ const subtitle = meta.subtitle ? `${inline(meta.subtitle)}
` : "";
+ const copyZone = [subtitle, ...blocks].filter(Boolean).join("\n ");
+ const shareField = share ? `${share.share}\n\n${share.hashtags}` : "—";
+
+ const carouselBlock = CAROUSEL.has(nn)
+ ? `6 · Carousel (valgfritt rekkevidde-tillegg)
+
Egen dokument-post, helst egen dag: last opp linkedin/${nn}/carousel.pdf.
+ Caption = delingstekstens premiss-linje.
`
+ : "";
+
+ const inner = `
+ Del ${nn} — ${esc(meta.title || "")}
+ 📅 ${cal.dag} · kl. ${cal.klokke} (Schedule post → CEST)
+ ${fresh ? `⚠️ Ferskvare før planlegging: ${esc(fresh)}
` : ""}
+
+
+
1 · Felter (Settings i editoren)
+
Tittel (${(meta.title || "").length} tegn)
+
${esc(meta.title || "")}
+
SEO-tittel${seoWarn}
+
${esc(sTitle)}
+
SEO-beskrivelse (${sDesc.length} tegn — mål 140–160)
+
${esc(sDesc)}
+
Lesetid / Serie
+
${esc(meta.lesetid || "—")} · ${esc(meta.serie || "—")}
+
+
+
+
2 · Cover (1920×1080)
+
Fil
linkedin/${nn}/cover.png
+
Credit (Add credit and caption)
${esc(COVER_CREDIT)}
+
Caption
${esc(CAPTIONS[nn] || "—")}
+
+
+
+
3 · «Tell your network…»-delingstekst (lim i feltet over kortet)
+
${esc(shareField)}
+
+
+
+
4 · Første kommentar (legg når posten er live)
+
${esc(share ? share.kommentar : "—")}
+
+
+ ${carouselBlock}
+
+ ⬇︎ ${CAROUSEL.has(nn) ? "7" : "6"} · BRØDTEKST — merk alt herfra, kopier (⌘C), lim i editoren ⬇︎
+
+ ${copyZone}
+
+ ⬆︎ Til hit ⬆︎ (sjekk at overskrifter/lister/fet overlevde liminga)
`;
+
+ return shell(`Del ${nn} · ${meta.title || ""}`, inner);
+}
+
+// ---------------------------------------------------------------------------
+// Samle-POST.html (frittstående native post)
+// ---------------------------------------------------------------------------
+function samlePost(share) {
+ const cal = CALENDAR.samle;
+ const shareField = share ? `${share.share}\n\n${share.hashtags}` : "—";
+ const inner = `
+ Samle-post — oversikt over hele serien
+ 📅 ${cal.dag} · kl. ${cal.klokke} (Schedule post → CEST)
+ Type: Frittstående native feed-post (ikke en edition).
+ Lenken til serien legges i FØRSTE KOMMENTAR.
+
+ ⬇︎ 1 · POST-TEKST — merk alt herfra, kopier, lim i en ny LinkedIn-post ⬇︎
+
+ ⬆︎ Til hit ⬆︎
+
+
+
2 · Første kommentar (legg når posten er live)
+
${esc(share ? share.kommentar : "—")}
+
[LENKE] = index/kanonisk hjem (fromaitochitta.com hvis live) ELLER Del 1-editionen som inngang. Velg det som faktisk er publisert.
+
`;
+ return shell("Samle-post · Maskinrommet", inner);
+}
+
+// ---------------------------------------------------------------------------
+// Main
+// ---------------------------------------------------------------------------
+const files = process.argv.slice(2);
+const shareMap = parseDelingstekst();
+
+for (const f of files) {
+ const abs = path.isAbsolute(f) ? f : path.join(process.cwd(), f);
+ const raw = fs.readFileSync(abs, "utf8");
+ const { meta, body } = parseFrontMatter(raw);
+ const base = path.basename(abs).replace(/\.md$/, "");
+ const nn = (base.match(/^(\d{2})/) || [, base])[1];
+ if (!/^\d{2}$/.test(nn)) { console.warn(`↷ hopper over ${f} (ikke NN-prefiks)`); continue; }
+ const dir = path.join(OUT_ROOT, nn);
+ fs.mkdirSync(dir, { recursive: true });
+ fs.writeFileSync(path.join(dir, "POST.html"), editionPost(nn, meta, body, shareMap[nn]));
+ console.log(`✓ linkedin/${nn}/POST.html (${meta.title || base})`);
+}
+
+// Samle bygges alltid (innhold er uavhengig av utkast-filene)
+if (shareMap.samle) {
+ const dir = path.join(OUT_ROOT, "samle");
+ fs.mkdirSync(dir, { recursive: true });
+ fs.writeFileSync(path.join(dir, "POST.html"), samlePost(shareMap.samle));
+ console.log("✓ linkedin/samle/POST.html (samle-post)");
+}
diff --git a/plugins/linkedin-thought-leadership/render/build-pdf.mjs b/plugins/linkedin-thought-leadership/render/build-pdf.mjs
new file mode 100644
index 0000000..ea8e15d
--- /dev/null
+++ b/plugins/linkedin-thought-leadership/render/build-pdf.mjs
@@ -0,0 +1,378 @@
+#!/usr/bin/env node
+// build-pdf.mjs — render kronikkene som rene avis-PDF-er (uten annoterings-UI).
+// Bruk: node build-pdf.mjs utkast/01-....md [flere.md ...]
+// Genererer ren print-HTML i pdf/_html/.html og kjører weasyprint -> pdf/.pdf.
+// Speiler avis-stilen fra build-html.mjs, men print-tunet (A4, marger, sidetall).
+// Krever: weasyprint på PATH. Ingen npm-avhengigheter.
+
+import fs from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { execFileSync } from "node:child_process";
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+
+// ---------------------------------------------------------------------------
+// weasyprint graceful degradation (S1, correction #3)
+// Detekterer weasyprint på PATH. Returnerer et skip-signal (kaster ALDRI) når
+// verktøyet mangler, slik at PDF-steget hoppes over med en tydelig install-hint
+// i stedet for å krasje kjøringen. `probe` er injiserbar for test.
+// ---------------------------------------------------------------------------
+const WEASYPRINT_HINT =
+ "weasyprint ikke funnet på PATH — hopper over PDF-steget.\n" +
+ " Install: pipx install weasyprint (alternativt: brew install weasyprint)";
+
+export function resolveWeasyprint(probe = defaultWeasyprintProbe) {
+ if (probe()) return { available: true };
+ return { available: false, hint: WEASYPRINT_HINT };
+}
+
+function defaultWeasyprintProbe() {
+ try {
+ execFileSync("weasyprint", ["--version"], { stdio: "ignore" });
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+// ---------------------------------------------------------------------------
+// YAML front matter (flate key: "value"-par mellom --- ... ---) — som build-html.mjs
+// ---------------------------------------------------------------------------
+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+/, "") };
+}
+
+function esc(s) {
+ return s.replace(/&/g, "&").replace(//g, ">");
+}
+
+function inline(text) {
+ let out = esc(text);
+ out = out.replace(/\*\*([^*]+)\*\*/g, (_, c) => `${c} `);
+ out = out.replace(/\*([^*]+)\*/g, (_, c) => `${c} `);
+ return out;
+}
+
+// ---------------------------------------------------------------------------
+// Kompakt markdown -> HTML (som build-html.mjs). Siste avsnitt-blokk som starter
+// med Om tilblivelsen: merkes .colophon for diskret metodenote-stil.
+// ---------------------------------------------------------------------------
+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++;
+ let cls = paraCount === 1 ? "lede" : "indent";
+ if (/^\*Om tilblivelsen:\*/.test(text)) cls = "colophon";
+ let inner = inline(text);
+ // Drop cap som ekte, floatet (weasyprint krasjer på ::first-letter{float}).
+ if (cls === "lede") {
+ inner = inner.replace(
+ /^(\s*)([A-Za-zÆØÅæøå0-9])/,
+ (_, ws, ch) => `${ws}${ch} `
+ );
+ }
+ blocks.push(`${inner}
`);
+ }
+
+ while (i < lines.length) {
+ const line = lines[i];
+ const trimmed = line.trim();
+
+ if (/^---+$/.test(trimmed)) {
+ blocks.push(" ");
+ i++;
+ continue;
+ }
+
+ let hm = trimmed.match(/^(#{2,3})\s+(.*)$/);
+ if (hm) {
+ const level = hm[1].length;
+ blocks.push(`${inline(hm[2].trim())} `);
+ i++;
+ continue;
+ }
+
+ 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;
+ }
+
+ if (/^[-*]\s+/.test(trimmed)) {
+ const items = [];
+ while (i < lines.length && /^[-*]\s+/.test(lines[i].trim())) {
+ items.push(lines[i].trim().replace(/^[-*]\s+/, ""));
+ i++;
+ }
+ blocks.push("" + items.map((it) => `${inline(it)} `).join("") + " ");
+ continue;
+ }
+
+ 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) => `${inline(it)} `).join("") + " ");
+ continue;
+ }
+
+ if (trimmed === "") {
+ i++;
+ continue;
+ }
+
+ const pbuf = [];
+ while (i < lines.length) {
+ const t = lines[i].trim();
+ if (
+ t === "" ||
+ /^---+$/.test(t) ||
+ /^(#{2,3})\s+/.test(t) ||
+ /^>\s?/.test(t) ||
+ /^[-*]\s+/.test(t) ||
+ /^\d+\.\s+/.test(t)
+ ) {
+ break;
+ }
+ pbuf.push(t);
+ i++;
+ }
+ flushPara(pbuf);
+ }
+
+ return blocks.join("\n");
+}
+
+// ---------------------------------------------------------------------------
+// Print-CSS — avis-identitet (off-white, oxblood, drop cap, serif), A4-tunet.
+// ---------------------------------------------------------------------------
+const FONT_DIR = path.join(__dirname, "fonts");
+const ff = (f) => `url("file://${path.join(FONT_DIR, f).replace(/ /g, "%20")}")`;
+const FONT_FACE = `
+@font-face{font-family:"Newsreader";font-style:normal;font-weight:400;src:${ff("Newsreader-400.ttf")};}
+@font-face{font-family:"Newsreader";font-style:italic;font-weight:400;src:${ff("Newsreader-400i.ttf")};}
+@font-face{font-family:"Newsreader";font-style:normal;font-weight:600;src:${ff("Newsreader-600.ttf")};}
+@font-face{font-family:"Newsreader";font-style:italic;font-weight:600;src:${ff("Newsreader-600i.ttf")};}
+@font-face{font-family:"Newsreader";font-style:normal;font-weight:700;src:${ff("Newsreader-700.ttf")};}
+@font-face{font-family:"Inter";font-style:normal;font-weight:400;src:${ff("Inter-400.ttf")};}
+@font-face{font-family:"Inter";font-style:normal;font-weight:600;src:${ff("Inter-600.ttf")};}
+@font-face{font-family:"Inter";font-style:normal;font-weight:700;src:${ff("Inter-700.ttf")};}
+`;
+const PRINT_CSS = `
+${FONT_FACE}
+:root {
+ --bg: #FBFAF7;
+ --ink: #1A1A1A;
+ --muted: #555555;
+ --accent: #9A3324;
+ --rule: #d8d4cb;
+ --serif: "Newsreader",Georgia,"Times New Roman",serif;
+ --sans: "Inter","Helvetica Neue",Helvetica,Arial,sans-serif;
+}
+@page {
+ size: A4;
+ margin: 22mm 21mm 20mm;
+ background: #FBFAF7;
+ @bottom-center {
+ content: counter(page);
+ font-family: "Inter","Helvetica Neue",Helvetica,Arial,sans-serif;
+ font-size: 8pt;
+ color: #9a9a9a;
+ }
+}
+* { box-sizing: border-box; }
+html { background: var(--bg); }
+body {
+ margin: 0;
+ background: var(--bg);
+ color: var(--ink);
+ font-family: var(--serif);
+ font-size: 12.5pt;
+ line-height: 1.5;
+}
+.kicker {
+ font-family: var(--sans);
+ text-transform: uppercase;
+ letter-spacing: 0.12em;
+ font-size: 8pt;
+ font-weight: 700;
+ color: var(--accent);
+ margin: 0 0 8pt;
+}
+h1.title {
+ font-family: var(--serif);
+ font-size: 27pt;
+ line-height: 1.08;
+ font-weight: 700;
+ margin: 0 0 9pt;
+ letter-spacing: -0.01em;
+}
+.subtitle {
+ font-family: var(--serif);
+ font-style: italic;
+ font-size: 14.5pt;
+ line-height: 1.4;
+ color: var(--muted);
+ margin: 0 0 14pt;
+}
+.byline-wrap {
+ border-top: 1px solid var(--rule);
+ padding-top: 7pt;
+ margin-bottom: 18pt;
+}
+.byline {
+ font-family: var(--sans);
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ font-size: 8pt;
+ font-weight: 600;
+ color: var(--muted);
+ margin: 0 0 2pt;
+}
+.meta {
+ font-family: var(--sans);
+ font-size: 8pt;
+ color: var(--muted);
+ letter-spacing: 0.04em;
+}
+.body p { margin: 0; text-align: justify; hyphens: auto; orphans: 3; widows: 3; }
+.body p.indent { text-indent: 1.4em; }
+.body .dropcap {
+ float: left;
+ font-size: 3.1em;
+ line-height: 0.74;
+ padding: 0.02em 0.09em 0 0;
+ color: var(--accent);
+ font-weight: 700;
+}
+.body h2 { font-size: 16.5pt; font-weight: 700; margin: 18pt 0 6pt; line-height: 1.2; }
+.body h3 { font-size: 13.5pt; font-weight: 700; margin: 15pt 0 4pt; line-height: 1.25; }
+.body ul, .body ol { margin: 7pt 0; padding-left: 1.4em; }
+.body li { margin: 2pt 0; }
+.body blockquote {
+ margin: 12pt 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: 16pt auto;
+ width: 38%;
+}
+.body strong { font-weight: 600; }
+.body p.colophon {
+ font-family: var(--sans);
+ font-size: 9.5pt;
+ line-height: 1.45;
+ color: var(--muted);
+ text-align: left;
+ text-indent: 0;
+}
+.body h2, .body h3 { break-after: avoid; }
+.body p.lede { break-after: avoid; }
+`;
+
+function buildPrintHtml(meta, body) {
+ const bodyHtml = markdownToHtml(body);
+ const title = meta.title || "Kronikk";
+ const metaLine = [meta.serie, meta.lesetid].filter(Boolean).join(" · ");
+ return `
+
+
+
+${esc(title)}
+
+
+
+
+ ${meta.kicker ? `${esc(meta.kicker)}
` : ""}
+ ${inline(title)}
+ ${meta.subtitle ? `${inline(meta.subtitle)}
` : ""}
+
+ ${meta.byline ? `
${esc(meta.byline)}
` : ""}
+ ${metaLine ? `
${esc(metaLine)}
` : ""}
+
+
+${bodyHtml}
+
+
+
+
+`;
+}
+
+// ---------------------------------------------------------------------------
+// Main
+// ---------------------------------------------------------------------------
+function main() {
+ const args = process.argv.slice(2);
+ if (!args.length) {
+ console.error("Bruk: node build-pdf.mjs [flere.md ...]");
+ process.exit(1);
+ }
+ // Output følger serien (kjøres fra serie-mappa); fonts følger scriptet via __dirname.
+ const pdfDir = path.join(process.cwd(), "pdf");
+ const htmlDir = path.join(pdfDir, "_html");
+ fs.mkdirSync(htmlDir, { recursive: true });
+
+ const wp = resolveWeasyprint();
+ if (!wp.available) console.warn(wp.hint);
+
+ 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 base = path.basename(inPath).replace(/\.md$/i, "");
+ const html = buildPrintHtml(meta, body);
+ const htmlPath = path.join(htmlDir, base + ".html");
+ const pdfPath = path.join(pdfDir, base + ".pdf");
+ fs.writeFileSync(htmlPath, html, "utf8");
+ if (wp.available) {
+ execFileSync("weasyprint", [htmlPath, pdfPath], { stdio: ["ignore", "ignore", "inherit"] });
+ const kb = (fs.statSync(pdfPath).size / 1024).toFixed(1);
+ console.log(`PDF: ${pdfPath} (${kb} KB)`);
+ } else {
+ console.warn(` Hoppet over PDF (weasyprint mangler): ${pdfPath}`);
+ }
+ }
+}
+
+if (import.meta.url === `file://${process.argv[1]}`) {
+ main();
+}
diff --git a/plugins/linkedin-thought-leadership/render/fonts/Inter-400.ttf b/plugins/linkedin-thought-leadership/render/fonts/Inter-400.ttf
new file mode 100644
index 0000000..399a6e0
Binary files /dev/null and b/plugins/linkedin-thought-leadership/render/fonts/Inter-400.ttf differ
diff --git a/plugins/linkedin-thought-leadership/render/fonts/Inter-600.ttf b/plugins/linkedin-thought-leadership/render/fonts/Inter-600.ttf
new file mode 100644
index 0000000..67fda28
Binary files /dev/null and b/plugins/linkedin-thought-leadership/render/fonts/Inter-600.ttf differ
diff --git a/plugins/linkedin-thought-leadership/render/fonts/Inter-700.ttf b/plugins/linkedin-thought-leadership/render/fonts/Inter-700.ttf
new file mode 100644
index 0000000..9c2f47d
Binary files /dev/null and b/plugins/linkedin-thought-leadership/render/fonts/Inter-700.ttf differ
diff --git a/plugins/linkedin-thought-leadership/render/fonts/Newsreader-400.ttf b/plugins/linkedin-thought-leadership/render/fonts/Newsreader-400.ttf
new file mode 100644
index 0000000..bc6943f
Binary files /dev/null and b/plugins/linkedin-thought-leadership/render/fonts/Newsreader-400.ttf differ
diff --git a/plugins/linkedin-thought-leadership/render/fonts/Newsreader-400i.ttf b/plugins/linkedin-thought-leadership/render/fonts/Newsreader-400i.ttf
new file mode 100644
index 0000000..3a1d151
Binary files /dev/null and b/plugins/linkedin-thought-leadership/render/fonts/Newsreader-400i.ttf differ
diff --git a/plugins/linkedin-thought-leadership/render/fonts/Newsreader-600.ttf b/plugins/linkedin-thought-leadership/render/fonts/Newsreader-600.ttf
new file mode 100644
index 0000000..15ea5fd
Binary files /dev/null and b/plugins/linkedin-thought-leadership/render/fonts/Newsreader-600.ttf differ
diff --git a/plugins/linkedin-thought-leadership/render/fonts/Newsreader-600i.ttf b/plugins/linkedin-thought-leadership/render/fonts/Newsreader-600i.ttf
new file mode 100644
index 0000000..bc49f4c
Binary files /dev/null and b/plugins/linkedin-thought-leadership/render/fonts/Newsreader-600i.ttf differ
diff --git a/plugins/linkedin-thought-leadership/render/fonts/Newsreader-700.ttf b/plugins/linkedin-thought-leadership/render/fonts/Newsreader-700.ttf
new file mode 100644
index 0000000..07d5148
Binary files /dev/null and b/plugins/linkedin-thought-leadership/render/fonts/Newsreader-700.ttf differ