ktg-plugin-marketplace/plugins/linkedin-studio/render/build-pdf.mjs
Kjell Tore Guttormsen b6bb61246b refactor(linkedin)!: rename plugin linkedin-thought-leadership → linkedin-studio (v3.0.0)
BREAKING CHANGE: the marketplace slug, the agent namespace
(linkedin-studio:<agent>), and the runtime state-file path
(~/.claude/linkedin-studio.local.md) all change. Reinstall required;
existing state migrated in place (post metrics, streak, history preserved).
The /linkedin:* commands are unchanged — the command namespace is set
per-command in frontmatter and was always independent of the plugin slug.
Functionality is byte-identical to v2.4.0; this release is pure identity.

- dir + manifests: plugins/linkedin-studio + plugin.json + root marketplace.json
- agent namespace updated in commands/newsletter.md (only functional invoker)
- state path updated in 4 hook scripts + topic-rotation prompt + state template
- catch-all skill dir renamed skills/linkedin-studio (5 functional skills unchanged)
- docs + version bump to 3.0.0 across README badge, CHANGELOG, root README/CLAUDE.md
- historical records (CHANGELOG past entries, docs/ build artifacts,
  config-audit v5.0.0 snapshots) intentionally retain the old slug

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 11:32:02 +02:00

378 lines
11 KiB
JavaScript

#!/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();
}