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>
This commit is contained in:
Kjell Tore Guttormsen 2026-05-29 11:32:02 +02:00
commit b6bb61246b
196 changed files with 164 additions and 138 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,65 @@
import { describe, test } from 'node:test';
import assert from 'node:assert/strict';
import { markdownToHtml, inline } from '../build-html.mjs';
describe('markdownToHtml — tables (beslutning H)', () => {
test('converts a pipe table to <table>/<tr>/<td>', () => {
const md = [
'| Plattform | Pris |',
'| --- | --- |',
'| Azure | Høy |',
'| Foundry | Lav |',
].join('\n');
const html = markdownToHtml(md);
assert.match(html, /<table>/);
assert.match(html, /<tr>/);
assert.match(html, /<td>/);
// header cells render as <th>, body values as <td>
assert.match(html, /<th>Plattform<\/th>/);
assert.match(html, /<td>Azure<\/td>/);
// the separator row must NOT become a data row
assert.doesNotMatch(html, /<td>---<\/td>/);
});
test('empty input produces no table', () => {
assert.doesNotMatch(markdownToHtml(''), /<table>/);
});
test('tolerates a malformed row (uneven cell count) without throwing', () => {
const md = [
'| a | b |',
'| --- | --- |',
'| only-one-cell |',
'| x | y |',
].join('\n');
let html;
assert.doesNotThrow(() => { html = markdownToHtml(md); });
assert.match(html, /<table>/);
assert.match(html, /<td>x<\/td>/);
});
});
describe('markdownToHtml — heading levels # … #### (beslutning H)', () => {
test('# renders <h1>', () => {
assert.match(markdownToHtml('# Tittel'), /<h1>Tittel<\/h1>/);
});
test('## renders <h2>', () => {
assert.match(markdownToHtml('## Seksjon'), /<h2>Seksjon<\/h2>/);
});
test('### renders <h3>', () => {
assert.match(markdownToHtml('### Under'), /<h3>Under<\/h3>/);
});
test('#### renders <h4>', () => {
assert.match(markdownToHtml('#### Detalj'), /<h4>Detalj<\/h4>/);
});
});
describe('inline — backtick code span (beslutning H)', () => {
test('`code` renders <code>', () => {
assert.match(inline('bruk `npm install` her'), /<code>npm install<\/code>/);
});
test('still handles **bold** and *italic*', () => {
assert.match(inline('**fet** og *kursiv*'), /<strong>fet<\/strong>/);
assert.match(inline('**fet** og *kursiv*'), /<em>kursiv<\/em>/);
});
});

View file

@ -0,0 +1,84 @@
// build-linkedin.test.mjs — S2: edition-config.json generalization.
// Verifies the fasit assumption (5): changing values in the config changes
// POST.html output with NO code change. Regression: a config matching the old
// hardcoded Seres values reproduces the baseline strings.
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { loadEditionConfig, editionPost, samlePost } from "../build-linkedin.mjs";
const __dirname = dirname(fileURLToPath(import.meta.url));
const fixturesDir = join(__dirname, "fixtures");
const meta = {
title: "Testtittel",
subtitle: "Undertittel",
lesetid: "5 min",
serie: "Test",
};
const body = "## Overskrift\n\nEn paragraf med tekst.";
const share = { share: "Delingstekst", hashtags: "#test", kommentar: "Kommentar" };
describe("build-linkedin edition-config", () => {
it("loadEditionConfig reads the Seres regression fixture", () => {
const cfg = loadEditionConfig(fixturesDir);
assert.equal(cfg.calendar["01"].dag, "Tirsdag 26.05.2026");
assert.equal(
cfg.coverCredit,
"Illustrasjon generert med Google Gemini (Nano Banana Pro)"
);
assert.ok(cfg.captions["06"].startsWith("Tolv grep"));
});
it("regression: Seres config reproduces baseline strings in POST.html", () => {
const cfg = loadEditionConfig(fixturesDir);
const html = editionPost("01", meta, body, share, cfg);
assert.ok(html.includes("Tirsdag 26.05.2026"), "calendar date present");
assert.ok(html.includes("Noen lover vekst"), "caption 01 present");
assert.ok(
html.includes("Illustrasjon generert med Google Gemini"),
"cover credit present"
);
assert.ok(html.includes("OpenAI-verdsettelse"), "freshness 01 present");
});
it("changing config values changes POST.html output (no code change)", () => {
const cfg = loadEditionConfig(fixturesDir);
const html1 = editionPost("01", meta, body, share, cfg);
const cfg2 = JSON.parse(JSON.stringify(cfg));
cfg2.captions["01"] = "EN HELT ANNEN CAPTION";
cfg2.calendar["01"].dag = "Mandag 01.01.2030";
const html2 = editionPost("01", meta, body, share, cfg2);
assert.notEqual(html1, html2, "different config → different output");
assert.ok(html2.includes("EN HELT ANNEN CAPTION"), "new caption present");
assert.ok(html2.includes("Mandag 01.01.2030"), "new date present");
assert.ok(!html2.includes("Noen lover vekst"), "old caption gone");
});
it("missing config degrades gracefully to empty defaults", () => {
const cfg = loadEditionConfig(join(fixturesDir, "does-not-exist"));
assert.deepEqual(cfg, {
calendar: {},
freshness: {},
coverCredit: "",
captions: {},
carousel: [],
});
// editionPost still renders without throwing (uses "—" fallbacks)
const html = editionPost("01", meta, body, share, cfg);
assert.ok(html.includes("Del 01"), "edition heading still rendered");
});
it("samlePost renders with config calendar and degrades gracefully", () => {
const cfg = loadEditionConfig(fixturesDir);
const html = samlePost(share, cfg);
assert.ok(html.includes("Mandag 01.06.2026"), "samle calendar from config");
const empty = loadEditionConfig(join(fixturesDir, "nope"));
const htmlEmpty = samlePost(share, empty);
assert.ok(htmlEmpty.includes("Samle-post"), "renders without throwing");
});
});

View file

@ -0,0 +1,24 @@
{
"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" }
},
"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."
},
"coverCredit": "Illustrasjon generert med Google Gemini (Nano Banana Pro)",
"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."
}
}

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

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,388 @@
#!/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: linkedin/edition-config.json i serie-mappa
// (faller tilbake til tomme standarder hvis fila mangler — 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");
// ---------------------------------------------------------------------------
// EDITION-KONFIG (HANDOVER §13 / DREIEBOK / image-credit-caption.md)
// Per-serie verdier (kalender, ferskvare, cover-credit, captions) leses fra
// linkedin/edition-config.json i serie-mappa — ikke lenger hardkodet. Q1: JSON
// (deterministisk parsing). Mangler fila, faller vi tilbake til tomme
// standarder slik at byggingen degraderer pent i stedet for å kaste.
// ---------------------------------------------------------------------------
const CONFIG_FILE = path.join(OUT_ROOT, "edition-config.json");
const EMPTY_CONFIG = { calendar: {}, freshness: {}, coverCredit: "", captions: {}, carousel: [] };
// Les edition-config.json fra rootDir (serie-mappas linkedin/). Normaliser alle
// felt til kjente former; manglende/ugyldig fil → tomme standarder (graceful).
export function loadEditionConfig(rootDir = OUT_ROOT) {
let cfg;
try {
cfg = JSON.parse(fs.readFileSync(path.join(rootDir, "edition-config.json"), "utf8"));
} catch {
return { ...EMPTY_CONFIG };
}
if (!cfg || typeof cfg !== "object") return { ...EMPTY_CONFIG };
return {
calendar: cfg.calendar && typeof cfg.calendar === "object" ? cfg.calendar : {},
freshness: cfg.freshness && typeof cfg.freshness === "object" ? cfg.freshness : {},
coverCredit: typeof cfg.coverCredit === "string" ? cfg.coverCredit : "",
captions: cfg.captions && typeof cfg.captions === "object" ? cfg.captions : {},
// S14/F6: carousel editions are config-derived, not Seres-hardcoded. A list of
// zero-padded NN strings ("03","06"); empty/absent → no carousel block for any
// edition. Generalizes away the old `new Set(["03","06"])` Seres assumption.
carousel: Array.isArray(cfg.carousel) ? cfg.carousel.map(String) : [],
};
}
// ---------------------------------------------------------------------------
// 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() {
// Graceful (S14/F8): missing or unreadable delingstekst → no distribution copy.
// Matches loadEditionConfig's fail-soft contract — the article POST.html still
// builds; only the share text is absent. Previously this threw ENOENT before any
// POST.html was written, killing the whole build incl. article posts.
let raw;
try {
raw = fs.readFileSync(DELINGSTEKST_FILE, "utf8").replace(/\r\n/g, "\n");
} catch {
return {};
}
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)
// ---------------------------------------------------------------------------
export function editionPost(nn, meta, body, share, config = EMPTY_CONFIG) {
const cal = config.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 = config.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 = (config.carousel || []).includes(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(config.coverCredit || "")}</div>
<div class="label">Caption</div><div class="val">${esc(config.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"> ${(config.carousel || []).includes(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)
// ---------------------------------------------------------------------------
export function samlePost(share, config = EMPTY_CONFIG) {
const cal = config.calendar.samle || { dag: "—", klokke: "08:00" };
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
// ---------------------------------------------------------------------------
export function main(files = process.argv.slice(2)) {
const config = loadEditionConfig(OUT_ROOT);
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], config));
console.log(`✓ linkedin/${nn}/POST.html (${meta.title || base})`);
}
// Samle bygges KUN når delingsteksten deklarerer en «## Samle»-seksjon (S14/F6:
// tidligere kommentar sa «alltid», men bygget har alltid vært betinget av
// shareMap.samle — innholdet er uavhengig av utkast-filene, men ikke av delingstekst).
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, config));
console.log("✓ linkedin/samle/POST.html (samle-post)");
}
}
// CLI-guard (S2 korreksjon #4): kjør main kun når scriptet kjøres direkte,
// slik at modulen kan importeres i tester uten side-effekter.
if (import.meta.url === `file://${process.argv[1]}`) {
main();
}

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

Binary file not shown.

Binary file not shown.

Binary file not shown.