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( + "" + ); + 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) => `
  1. ${inline(it)}
  2. `).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 + '' + + '' + + '' + + '
' + + '
«' + 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)}

` : ""} + +
+${bodyHtml} +
+
+ + +
+ +
+ +
+
+
+ +
+ + + +
+
+ +
+
+

Annoteringer — kopier og lim tilbake

+ + +
+ + +
+
+
+ + + + + +`; +} + +// --------------------------------------------------------------------------- +// 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) => `
  1. ${inline(x)}
  2. `).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 ⬇︎
+
${esc(shareField)}
+
⬆︎ 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) => `
  1. ${inline(it)}
  2. `).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)}

` : ""} + +
+${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