feat(linkedin): migrate render scripts + fonts into plugin (S1)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-05-26 22:11:38 +02:00
commit 6eff5e8e21
14 changed files with 2134 additions and 0 deletions

View file

@ -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.

View file

@ -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);
});
});
}

View file

@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function inline(text) {
let out = esc(text);
out = out.replace(/\*\*([^*]+)\*\*/g, (_, c) => `<strong>${c}</strong>`);
out = out.replace(/\*([^*]+)\*/g, (_, c) => `<em>${c}</em>`);
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 ? `<h1 class="title">${inline(slide.title)}</h1>` : "";
const bodyHtml = slide.bodyParas.length
? `<div class="body">${slide.bodyParas.map((p) => `<p>${inline(p)}</p>`).join("")}</div>`
: "";
// kicker: interior bruker bolk-merkelapp; bookend bruker eyebrow (cover/CTA)
let head = "";
if (bookend) {
const eyebrow = isCover ? eyebrows.cover : eyebrows.cta;
head = `<p class="eyebrow">${esc(eyebrow)}</p>`;
} else if (slide.kicker) {
head = `<span class="kicker">${esc(slide.kicker)}</span>`;
}
const counter = `${idx + 1} / ${total}`;
const footer = `<div class="footer"><span class="brand">Maskinrommet</span><span class="count">${counter}</span></div>`;
return `<section class="${cls}">
${head}
${titleHtml}
${bodyHtml}
${footer}
</section>`;
}
function buildHtml(slides, eyebrows) {
const total = slides.length;
const slidesHtml = slides.map((s, i) => slideHtml(s, i, total, eyebrows)).join("\n");
return `<!DOCTYPE html>
<html lang="nb">
<head><meta charset="utf-8"><title>Carousel</title><style>${CSS}</style></head>
<body>
${slidesHtml}
</body>
</html>
`;
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
function main() {
const args = process.argv.slice(2);
if (!args.length) {
console.error("Bruk: node build-carousel.mjs <carousel.md> [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();
}

View file

@ -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/<samme-navn>.html. Ingen npm-avhengigheter, ingen nett.
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// ---------------------------------------------------------------------------
// YAML front matter (minimal: flate key: "value"-par mellom --- ... ---)
// ---------------------------------------------------------------------------
function parseFrontMatter(raw) {
const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
if (!m) return { meta: {}, body: raw };
const meta = {};
for (const line of m[1].split(/\r?\n/)) {
const mm = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
if (!mm) continue;
let val = mm[2].trim();
if (
(val.startsWith('"') && val.endsWith('"')) ||
(val.startsWith("'") && val.endsWith("'"))
) {
val = val.slice(1, -1);
}
meta[mm[1]] = val;
}
return { meta, body: m[2].replace(/^\r?\n+/, "") };
}
// ---------------------------------------------------------------------------
// HTML-escape for tekstinnhold
// ---------------------------------------------------------------------------
function esc(s) {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
// ---------------------------------------------------------------------------
// Inline markdown: **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) => `<strong>${c}</strong>`);
out = out.replace(/\*([^*]+)\*/g, (_, c) => `<em>${c}</em>`);
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(`<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
let hm = trimmed.match(/^(#{2,3})\s+(.*)$/);
if (hm) {
const level = hm[1].length; // 2 eller 3
blocks.push(`<h${level}>${inline(hm[2].trim())}</h${level}>`);
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(`<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) ||
/^(#{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 <article> er markerbart (tittel + ingress + brødtekst), ikke bare .body.
var article = document.querySelector("article");
var toolbar = document.getElementById("annoToolbar");
var popover = document.getElementById("annoPopover");
var sidebar = document.getElementById("annoSidebar");
var listEl = document.getElementById("annoList");
var annotations = load();
var pendingRange = null; // {text} for ny annotering
var editingId = null; // id når en eksisterende annotering redigeres
function load() {
try {
var raw = localStorage.getItem(STORE_KEY);
return raw ? JSON.parse(raw) : [];
} catch (e) { return []; }
}
function save() {
try { localStorage.setItem(STORE_KEY, JSON.stringify(annotations)); } catch (e) {}
}
// --- Markering -> verktøylinje -------------------------------------------
document.addEventListener("mouseup", function (e) {
if (toolbar.contains(e.target) || popover.contains(e.target)) return;
setTimeout(handleSelection, 0);
});
// Hent kontekst rundt markeringen (ordene foran/bak i samme avsnitt),
// slik at korte markeringer (ett ord) kan plasseres entydig i eksporten.
function getContext(range, selText) {
var BLOCK = /^(P|LI|H1|H2|H3|H4|BLOCKQUOTE|TD)$/;
var block = range.commonAncestorContainer;
if (block.nodeType === 3) block = block.parentElement;
while (block && block !== article && !BLOCK.test(block.tagName)) block = block.parentElement;
if (!block || block === article) return "";
try {
var beforeR = document.createRange();
beforeR.selectNodeContents(block);
beforeR.setEnd(range.startContainer, range.startOffset);
var afterR = document.createRange();
afterR.selectNodeContents(block);
afterR.setStart(range.endContainer, range.endOffset);
var before = beforeR.toString().replace(/\s+/g, " ");
var after = afterR.toString().replace(/\s+/g, " ");
var W = 55;
if (before.length > W) before = "…" + before.slice(-W);
if (after.length > W) after = after.slice(0, W) + "…";
return (before + "〈" + selText + "〉" + after).trim();
} catch (e) { return ""; }
}
function handleSelection() {
var sel = window.getSelection();
if (!sel || sel.isCollapsed) { hideToolbar(); return; }
var text = sel.toString().replace(/\s+/g, " ").trim();
if (text.length < 2) { hideToolbar(); return; }
var range = sel.getRangeAt(0);
if (!article.contains(range.commonAncestorContainer)) { hideToolbar(); return; }
var rect = range.getBoundingClientRect();
pendingRange = { text: text, context: getContext(range, text) };
showToolbar(rect);
}
function showToolbar(rect) {
toolbar.classList.add("show");
var tw = toolbar.offsetWidth;
var x = window.scrollX + rect.left + rect.width / 2 - tw / 2;
var y = window.scrollY + rect.top - toolbar.offsetHeight - 8;
x = Math.max(8, x);
if (y < window.scrollY + 4) y = window.scrollY + rect.bottom + 8;
toolbar.style.left = x + "px";
toolbar.style.top = y + "px";
popover.classList.remove("show");
}
function hideToolbar() { toolbar.classList.remove("show"); }
// Bygg verktøylinje-knapper
Object.keys(INTENTS).forEach(function (key) {
var b = document.createElement("button");
b.innerHTML = '<span class="swatch" style="background:' + SWATCH[key] + '"></span>' + INTENTS[key].label;
b.addEventListener("mousedown", function (ev) { ev.preventDefault(); });
b.addEventListener("click", function () { openPopover(key); });
toolbar.appendChild(b);
});
// Intent-velger i popoveren (brukes både ved ny og ved redigering)
var pick = popover.querySelector(".intent-pick");
Object.keys(INTENTS).forEach(function (key) {
var b = document.createElement("button");
b.type = "button";
b.dataset.intent = key;
b.innerHTML = '<span class="swatch" style="background:' + SWATCH[key] + '"></span>' + INTENTS[key].label;
b.addEventListener("mousedown", function (ev) { ev.preventDefault(); });
b.addEventListener("click", function () {
popover.dataset.intent = key;
updateIntentPick(key);
});
pick.appendChild(b);
});
function updateIntentPick(sel) {
Array.prototype.forEach.call(pick.children, function (b) {
b.classList.toggle("active", b.dataset.intent === sel);
});
}
function positionPopover(rect) {
popover.style.left = Math.max(8, window.scrollX + rect.left) + "px";
popover.style.top = (window.scrollY + rect.top) + "px";
}
function openPopover(intentKey) {
if (!pendingRange) return;
editingId = null;
popover.querySelector(".ph").textContent =
INTENTS[intentKey].label + " — «" + truncate(pendingRange.text, 60) + "»";
var ta = popover.querySelector("textarea");
ta.value = "";
popover.dataset.intent = intentKey;
updateIntentPick(intentKey);
popover.querySelector(".del-edit").style.display = "none";
popover.classList.add("show");
positionPopover(toolbar.getBoundingClientRect());
toolbar.classList.remove("show");
setTimeout(function () { ta.focus(); }, 10);
}
// Åpne popover for å REDIGERE en eksisterende annotering
function openEdit(id, rect) {
var a = annotations.filter(function (x) { return x.id === id; })[0];
if (!a) return;
editingId = id;
pendingRange = null;
popover.querySelector(".ph").textContent = "Rediger — «" + truncate(a.text, 60) + "»";
var ta = popover.querySelector("textarea");
ta.value = a.comment || "";
popover.dataset.intent = a.intent;
updateIntentPick(a.intent);
popover.querySelector(".del-edit").style.display = "";
popover.classList.add("show");
positionPopover(rect);
toolbar.classList.remove("show");
window.getSelection().removeAllRanges();
setTimeout(function () { ta.focus(); }, 10);
}
// Klikk på en markering i artikkelen -> rediger den
article.addEventListener("click", function (e) {
var span = e.target.closest ? e.target.closest("span.anno") : null;
if (!span || !span.dataset.id) return;
e.preventDefault();
openEdit(span.dataset.id, span.getBoundingClientRect());
});
popover.querySelector(".cancel").addEventListener("click", function () {
popover.classList.remove("show"); pendingRange = null; editingId = null;
});
popover.querySelector(".del-edit").addEventListener("click", function () {
if (!editingId) return;
annotations = annotations.filter(function (x) { return x.id !== editingId; });
editingId = null;
save();
popover.classList.remove("show");
render();
});
popover.querySelector(".primary").addEventListener("click", function () {
var intent = popover.dataset.intent;
var cmt = popover.querySelector("textarea").value.trim();
if (editingId) {
annotations.forEach(function (a) {
if (a.id === editingId) { a.intent = intent; a.comment = cmt; }
});
editingId = null;
save();
popover.classList.remove("show");
render();
return;
}
if (!pendingRange) return;
annotations.push({
id: Date.now() + "-" + Math.floor(Math.random() * 1e6),
intent: intent,
text: pendingRange.text,
context: pendingRange.context || "",
comment: cmt
});
save();
popover.classList.remove("show");
pendingRange = null;
window.getSelection().removeAllRanges();
render();
});
// --- Rendering: marker tekst i artikkelen og bygg sidepanel --------------
function render() {
clearMarks();
annotations.forEach(function (a, idx) { markInArticle(a, idx + 1); });
renderList();
}
function clearMarks() {
var marks = article.querySelectorAll("span.anno");
marks.forEach(function (m) {
var parent = m.parentNode;
while (m.firstChild) {
if (m.firstChild.classList && m.firstChild.classList.contains("anno-num")) {
m.removeChild(m.firstChild);
} else {
parent.insertBefore(m.firstChild, m);
}
}
parent.removeChild(m);
parent.normalize();
});
}
// Finn første tekst-treff i artikkelen og pakk det inn. Teller per-tekst
// forekomster slik at like sitater markeres i rekkefølge.
var occCounters = {};
function markInArticle(a, num) {
var needle = a.text;
if (!occCounters[needle]) occCounters[needle] = 0;
var skip = occCounters[needle];
occCounters[needle]++;
var walker = document.createTreeWalker(article, NodeFilter.SHOW_TEXT, null);
var node, found = 0;
while ((node = walker.nextNode())) {
var nv = node.nodeValue;
var pos = nv.indexOf(needle);
while (pos !== -1) {
if (found === skip) {
wrapTextNode(node, pos, needle.length, a, num);
return;
}
found++;
pos = nv.indexOf(needle, pos + 1);
}
}
}
function wrapTextNode(node, start, len, a, num) {
var range = document.createRange();
range.setStart(node, start);
range.setEnd(node, start + len);
var span = document.createElement("span");
span.className = "anno anno-" + INTENTS[a.intent].cls;
span.dataset.id = a.id;
span.title = INTENTS[a.intent].label + (a.comment ? ": " + a.comment : "");
try {
range.surroundContents(span);
var sup = document.createElement("sup");
sup.className = "anno-num";
sup.textContent = num;
span.appendChild(sup);
} catch (e) { /* range spente over elementgrense — hopp over markering */ }
}
function renderList() {
occCounters = {}; // nullstill for neste render
listEl.innerHTML = "";
if (!annotations.length) {
var em = document.createElement("div");
em.className = "empty";
em.textContent = "Ingen annoteringer ennå. Marker tekst i artikkelen for å begynne.";
listEl.appendChild(em);
return;
}
annotations.forEach(function (a, idx) {
var item = document.createElement("div");
item.className = "anno-item";
var cls = INTENTS[a.intent].cls;
item.innerHTML =
'<div class="top">' +
'<span class="num">' + (idx + 1) + '</span>' +
'<span class="badge badge-' + cls + '">' + INTENTS[a.intent].label + '</span>' +
'<button class="edit" title="Endre">Endre</button>' +
'<button class="del" title="Slett">Slett</button>' +
'</div>' +
'<div class="quote">«' + escHtml(truncate(a.text, 90)) + '»</div>' +
(a.comment ? '<div class="cmt">' + escHtml(a.comment) + '</div>' : '');
item.querySelector(".edit").addEventListener("click", function () {
openEdit(a.id, item.getBoundingClientRect());
});
item.querySelector(".del").addEventListener("click", function () {
annotations = annotations.filter(function (x) { return x.id !== a.id; });
save(); render();
});
listEl.appendChild(item);
});
}
// --- Eksport: kompakt annoteringsliste (kun annoteringer, ikke brødtekst) -
function buildAnnotatedMarkdown() {
var header = "# Annoteringer — " + SOURCE_FILE + " · «" + TITLE + "»";
if (!annotations.length) {
return header + "\n\n(Ingen annoteringer.)\n";
}
function occurrences(s) {
if (!s) return 0;
var hay = article.textContent.replace(/\s+/g, " ");
var n = 0, i = 0;
while ((i = hay.indexOf(s, i)) !== -1) { n++; i += s.length; }
return n;
}
var blocks = annotations.map(function (a, idx) {
var lines = [(idx + 1) + ". [" + INTENTS[a.intent].upper + "] «" + a.text + "»"];
// Ta med kontekst kun når markeringen er kort eller forekommer flere ganger
// (ellers holder vi eksporten kompakt).
if (a.context && (a.text.length < 30 || occurrences(a.text) > 1)) {
lines.push(" ↳ i: «" + a.context + "»");
}
lines.push(" → " + (a.comment || ""));
return lines.join("\n");
});
return header + "\n\n" + blocks.join("\n\n") + "\n";
}
function showExport() {
var overlay = document.getElementById("exportOverlay");
var ta = document.getElementById("exportText");
ta.value = buildAnnotatedMarkdown();
overlay.classList.add("show");
ta.focus(); ta.select();
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(ta.value).catch(function () {});
}
}
// --- Helpers --------------------------------------------------------------
function truncate(s, n) { return s.length > n ? s.slice(0, n - 1) + "…" : s; }
function escHtml(s) {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
// --- Bind topp-/panel-knapper --------------------------------------------
document.getElementById("btnExport").addEventListener("click", showExport);
document.getElementById("btnExportTop").addEventListener("click", showExport);
document.getElementById("btnClear").addEventListener("click", function () {
if (!annotations.length) return;
if (confirm("Tøm alle annoteringer for denne artikkelen?")) {
annotations = []; save(); render();
}
});
document.getElementById("exportClose").addEventListener("click", function () {
document.getElementById("exportOverlay").classList.remove("show");
});
document.getElementById("exportCopy").addEventListener("click", function () {
var ta = document.getElementById("exportText");
ta.select();
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(ta.value);
} else { document.execCommand("copy"); }
});
document.getElementById("sidebarToggle").addEventListener("click", function () {
sidebar.classList.toggle("mobile-show");
});
// Skjul verktøylinje ved klikk utenfor
document.addEventListener("mousedown", function (e) {
if (!toolbar.contains(e.target) && !popover.contains(e.target)) {
if (!window.getSelection().toString().trim()) hideToolbar();
}
});
render();
})();
`;
// ---------------------------------------------------------------------------
// HTML-shell
// ---------------------------------------------------------------------------
function buildPage(meta, body, articleKey, sourceFile) {
const bodyHtml = markdownToHtml(body);
const title = meta.title || "Kronikk";
const metaLine = [meta.serie, meta.lesetid].filter(Boolean).join(" · ");
return `<!DOCTYPE html>
<html lang="no">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${esc(title)}</title>
<style>${CSS}</style>
</head>
<body>
<button class="sidebar-toggle" id="sidebarToggle">Annoteringer</button>
<div class="page">
<article>
${meta.kicker ? `<p class="kicker">${esc(meta.kicker)}</p>` : ""}
<h1 class="title">${inline(title)}</h1>
${meta.subtitle ? `<p class="subtitle">${inline(meta.subtitle)}</p>` : ""}
<div class="byline-wrap">
${meta.byline ? `<p class="byline">${esc(meta.byline)}</p>` : ""}
${metaLine ? `<p class="meta">${esc(metaLine)}</p>` : ""}
</div>
<div class="body">
${bodyHtml}
</div>
</article>
<aside class="sidebar" id="annoSidebar">
<h2>Annoteringer</h2>
<div class="actions">
<button class="primary" id="btnExport">Kopier annoteringer</button>
<button id="btnClear">Tøm alle</button>
</div>
<div id="annoList"></div>
</aside>
</div>
<div class="anno-toolbar" id="annoToolbar"></div>
<div class="anno-popover" id="annoPopover">
<div class="ph"></div>
<div class="intent-pick"></div>
<textarea placeholder="Skriv kommentar (valgfritt)…"></textarea>
<div class="row">
<button class="del-edit" style="display:none;">Slett</button>
<button class="cancel">Avbryt</button>
<button class="primary">Lagre</button>
</div>
</div>
<div class="export-overlay" id="exportOverlay">
<div class="export-box">
<h3>Annoteringer kopier og lim tilbake</h3>
<button class="primary" id="btnExportTop" style="margin-bottom:8px;">Kopier til utklippstavle</button>
<textarea id="exportText" readonly></textarea>
<div class="row">
<button id="exportCopy">Kopier</button>
<button class="primary" id="exportClose">Lukk</button>
</div>
</div>
</div>
<script>
window.__ARTICLE_KEY__ = ${toJsString(articleKey)};
window.__BODY_MD__ = ${toJsString(body)};
window.__SOURCE_FILE__ = ${toJsString(sourceFile)};
window.__TITLE__ = ${toJsString(title)};
</script>
<script>
${CLIENT_JS}
</script>
</body>
</html>
`;
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
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)`);
}
}
main();

View file

@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
// Inline: **fet**, *kursiv*, bare URL → lenke. «», — beholdes.
function inline(text) {
let out = esc(text);
out = out.replace(/\*\*([^*]+)\*\*/g, (_, c) => `<strong>${c}</strong>`);
out = out.replace(/\*([^*]+)\*/g, (_, c) => `<em>${c}</em>`);
out = out.replace(
/(https?:\/\/[^\s<]+)/g,
(u) => `<a href="${u}">${u}</a>`
);
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(`<p>${inline(para.join(" "))}</p>`);
para = [];
}
while (i < lines.length) {
const t = lines[i].trim();
if (t === "") { flushPara(); i++; continue; }
if (t === "---") { flushPara(); out.push("<hr>"); i++; continue; }
if (/^###\s+/.test(t)) { flushPara(); out.push(`<h3>${inline(t.replace(/^###\s+/, ""))}</h3>`); i++; continue; }
if (/^##\s+/.test(t)) { flushPara(); out.push(`<h2>${inline(t.replace(/^##\s+/, ""))}</h2>`); 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(`<blockquote><p>${inline(q.join(" "))}</p></blockquote>`);
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(`<ul>${items.map((x) => `<li>${inline(x)}</li>`).join("")}</ul>`);
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(`<ol>${items.map((x) => `<li>${inline(x)}</li>`).join("")}</ol>`);
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 `<!doctype html>
<html lang="nb">
<head>
<meta charset="utf-8">
<title>${esc(title)}</title>
<style>${CSS}</style>
</head>
<body>
${inner}
</body>
</html>`;
}
// ---------------------------------------------------------------------------
// Edition-POST.html (Del 16)
// ---------------------------------------------------------------------------
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 ? ` <span class="warn">⚠️ ${sTitle.length} tegn (over SEO-anbefaling 60)</span>` : ` (${sTitle.length} tegn)`;
const fresh = FRESHNESS[nn];
const blocks = markdownToBlocks(body);
const subtitle = meta.subtitle ? `<p><strong>${inline(meta.subtitle)}</strong></p>` : "";
const copyZone = [subtitle, ...blocks].filter(Boolean).join("\n ");
const shareField = share ? `${share.share}\n\n${share.hashtags}` : "—";
const carouselBlock = CAROUSEL.has(nn)
? `<div class="fld"><h2>6 · Carousel (valgfritt rekkevidde-tillegg)</h2>
<div class="val">Egen dokument-post, helst egen dag: last opp <code>linkedin/${nn}/carousel.pdf</code>.
Caption = delingstekstens premiss-linje.</div></div>`
: "";
const inner = `
<h1 class="sheet">Del ${nn} ${esc(meta.title || "")}</h1>
<p class="when">📅 ${cal.dag} · kl. ${cal.klokke} (Schedule post CEST)</p>
${fresh ? `<div class="fresh"><strong>⚠️ Ferskvare før planlegging:</strong> ${esc(fresh)}</div>` : ""}
<div class="fld">
<h2>1 · Felter (Settings i editoren)</h2>
<div class="label">Tittel (${(meta.title || "").length} tegn)</div>
<div class="val">${esc(meta.title || "")}</div>
<div class="label">SEO-tittel${seoWarn}</div>
<div class="val">${esc(sTitle)}</div>
<div class="label">SEO-beskrivelse (${sDesc.length} tegn mål 140160)</div>
<div class="val">${esc(sDesc)}</div>
<div class="label">Lesetid / Serie</div>
<div class="val">${esc(meta.lesetid || "—")} · ${esc(meta.serie || "—")}</div>
</div>
<div class="fld">
<h2>2 · Cover (1920×1080)</h2>
<div class="label">Fil</div><div class="val"><code>linkedin/${nn}/cover.png</code></div>
<div class="label">Credit (Add credit and caption)</div><div class="val">${esc(COVER_CREDIT)}</div>
<div class="label">Caption</div><div class="val">${esc(CAPTIONS[nn] || "")}</div>
</div>
<div class="fld">
<h2>3 · «Tell your network»-delingstekst (lim i feltet over kortet)</h2>
<div class="copybox">${esc(shareField)}</div>
</div>
<div class="fld">
<h2>4 · Første kommentar (legg når posten er live)</h2>
<div class="copybox">${esc(share ? share.kommentar : "—")}</div>
</div>
${carouselBlock}
<div class="marker"> ${CAROUSEL.has(nn) ? "7" : "6"} · BRØDTEKST merk alt herfra, kopier (C), lim i editoren </div>
<div class="copyzone">
${copyZone}
</div>
<div class="marker"> Til hit &nbsp;(sjekk at overskrifter/lister/fet overlevde liminga)</div>`;
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 = `
<h1 class="sheet">Samle-post oversikt over hele serien</h1>
<p class="when">📅 ${cal.dag} · kl. ${cal.klokke} (Schedule post CEST)</p>
<div class="fresh"><strong>Type:</strong> Frittstående native feed-post (ikke en edition).
Lenken til serien legges i FØRSTE KOMMENTAR.</div>
<div class="marker"> 1 · POST-TEKST merk alt herfra, kopier, lim i en ny LinkedIn-post </div>
<div class="copyzone"><div class="copybox" style="border:none;padding:0">${esc(shareField)}</div></div>
<div class="marker"> Til hit </div>
<div class="fld" style="margin-top:24px">
<h2>2 · Første kommentar (legg når posten er live)</h2>
<div class="copybox">${esc(share ? share.kommentar : "—")}</div>
<div class="label" style="margin-top:10px">[LENKE] = index/kanonisk hjem (fromaitochitta.com hvis live) ELLER Del 1-editionen som inngang. Velg det som faktisk er publisert.</div>
</div>`;
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)");
}

View file

@ -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/<navn>.html og kjører weasyprint -> pdf/<navn>.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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function inline(text) {
let out = esc(text);
out = out.replace(/\*\*([^*]+)\*\*/g, (_, c) => `<strong>${c}</strong>`);
out = out.replace(/\*([^*]+)\*/g, (_, c) => `<em>${c}</em>`);
return out;
}
// ---------------------------------------------------------------------------
// Kompakt markdown -> HTML (som build-html.mjs). Siste avsnitt-blokk som starter
// med <em>Om tilblivelsen:</em> 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 <span> (weasyprint krasjer på ::first-letter{float}).
if (cls === "lede") {
inner = inner.replace(
/^(\s*)([A-Za-zÆØÅæøå0-9])/,
(_, ws, ch) => `${ws}<span class="dropcap">${ch}</span>`
);
}
blocks.push(`<p class="${cls}">${inner}</p>`);
}
while (i < lines.length) {
const line = lines[i];
const trimmed = line.trim();
if (/^---+$/.test(trimmed)) {
blocks.push("<hr>");
i++;
continue;
}
let hm = trimmed.match(/^(#{2,3})\s+(.*)$/);
if (hm) {
const level = hm[1].length;
blocks.push(`<h${level}>${inline(hm[2].trim())}</h${level}>`);
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(`<blockquote><p>${inline(qbuf.join(" ").trim())}</p></blockquote>`);
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("<ul>" + items.map((it) => `<li>${inline(it)}</li>`).join("") + "</ul>");
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("<ol>" + items.map((it) => `<li>${inline(it)}</li>`).join("") + "</ol>");
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 `<!DOCTYPE html>
<html lang="nb">
<head>
<meta charset="utf-8">
<title>${esc(title)}</title>
<style>${PRINT_CSS}</style>
</head>
<body>
<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>
</body>
</html>
`;
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
function main() {
const args = process.argv.slice(2);
if (!args.length) {
console.error("Bruk: node build-pdf.mjs <fil.md> [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();
}