From 6e6ed7dd4e2523c03d769682d5657f3197eb1f78 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Tue, 26 May 2026 23:15:26 +0200 Subject: [PATCH] feat(linkedin): generalize build-linkedin to read edition-config.json (S2) --- .../render/__tests__/build-linkedin.test.mjs | 83 +++++++++++++ .../__tests__/fixtures/edition-config.json | 24 ++++ .../render/build-linkedin.mjs | 113 ++++++++++-------- 3 files changed, 170 insertions(+), 50 deletions(-) create mode 100644 plugins/linkedin-thought-leadership/render/__tests__/build-linkedin.test.mjs create mode 100644 plugins/linkedin-thought-leadership/render/__tests__/fixtures/edition-config.json diff --git a/plugins/linkedin-thought-leadership/render/__tests__/build-linkedin.test.mjs b/plugins/linkedin-thought-leadership/render/__tests__/build-linkedin.test.mjs new file mode 100644 index 0000000..b07dd71 --- /dev/null +++ b/plugins/linkedin-thought-leadership/render/__tests__/build-linkedin.test.mjs @@ -0,0 +1,83 @@ +// 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: {}, + }); + // 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"); + }); +}); diff --git a/plugins/linkedin-thought-leadership/render/__tests__/fixtures/edition-config.json b/plugins/linkedin-thought-leadership/render/__tests__/fixtures/edition-config.json new file mode 100644 index 0000000..f755f82 --- /dev/null +++ b/plugins/linkedin-thought-leadership/render/__tests__/fixtures/edition-config.json @@ -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." + } +} diff --git a/plugins/linkedin-thought-leadership/render/build-linkedin.mjs b/plugins/linkedin-thought-leadership/render/build-linkedin.mjs index b90758e..369aa10 100644 --- a/plugins/linkedin-thought-leadership/render/build-linkedin.mjs +++ b/plugins/linkedin-thought-leadership/render/build-linkedin.mjs @@ -16,7 +16,8 @@ // 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) +// - 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"; @@ -29,32 +30,36 @@ 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) +// 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 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 CONFIG_FILE = path.join(OUT_ROOT, "edition-config.json"); -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 EMPTY_CONFIG = { calendar: {}, freshness: {}, coverCredit: "", captions: {} }; -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.", -}; +// 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 : {}, + }; +} + +// CAROUSEL-settet er ikke en del av S2-konfig-scope (kun CALENDAR/FRESHNESS/ +// CAPTIONS/COVER_CREDIT generaliseres) — beholdes hardkodet inntil videre. const CAROUSEL = new Set(["03", "06"]); // --------------------------------------------------------------------------- @@ -250,12 +255,12 @@ ${inner} // --------------------------------------------------------------------------- // Edition-POST.html (Del 1–6) // --------------------------------------------------------------------------- -function editionPost(nn, meta, body, share) { - const cal = CALENDAR[nn] || { dag: "—", klokke: "08:00" }; +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 ? ` ⚠️ ${sTitle.length} tegn (over SEO-anbefaling 60)` : ` (${sTitle.length} tegn)`; - const fresh = FRESHNESS[nn]; + const fresh = config.freshness[nn]; const blocks = markdownToBlocks(body); const subtitle = meta.subtitle ? `

${inline(meta.subtitle)}

` : ""; const copyZone = [subtitle, ...blocks].filter(Boolean).join("\n "); @@ -287,8 +292,8 @@ function editionPost(nn, meta, body, share) {

2 · Cover (1920×1080)

Fil
linkedin/${nn}/cover.png
-
Credit (Add credit and caption)
${esc(COVER_CREDIT)}
-
Caption
${esc(CAPTIONS[nn] || "—")}
+
Credit (Add credit and caption)
${esc(config.coverCredit || "")}
+
Caption
${esc(config.captions[nn] || "—")}
@@ -315,8 +320,8 @@ function editionPost(nn, meta, body, share) { // --------------------------------------------------------------------------- // Samle-POST.html (frittstående native post) // --------------------------------------------------------------------------- -function samlePost(share) { - const cal = CALENDAR.samle; +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 = `

Samle-post — oversikt over hele serien

@@ -339,26 +344,34 @@ function samlePost(share) { // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- -const files = process.argv.slice(2); -const shareMap = parseDelingstekst(); +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])); - console.log(`✓ linkedin/${nn}/POST.html (${meta.title || base})`); + 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 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, config)); + console.log("✓ linkedin/samle/POST.html (samle-post)"); + } } -// 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)"); +// 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(); }