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) {linkedin/${nn}/cover.png