feat(linkedin): generalize build-linkedin to read edition-config.json (S2)

This commit is contained in:
Kjell Tore Guttormsen 2026-05-26 23:15:26 +02:00
commit 6e6ed7dd4e
3 changed files with 173 additions and 53 deletions

View file

@ -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");
});
});

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

@ -16,7 +16,8 @@
// Kilder: // Kilder:
// - brødtekst + felt: front matter + body i utkast/NN-...md // - brødtekst + felt: front matter + body i utkast/NN-...md
// - delingstekst/hashtags/første kommentar: linkedin/edition-delingstekst.md (parses) // - 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). // Ingen npm-avhengigheter, ingen nett. meta.md røres IKKE (håndholdt; har ekte pulse-URL).
import fs from "node:fs"; 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"); 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 = { const CONFIG_FILE = path.join(OUT_ROOT, "edition-config.json");
"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 = { const EMPTY_CONFIG = { calendar: {}, freshness: {}, coverCredit: "", captions: {} };
"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)"; // Les edition-config.json fra rootDir (serie-mappas linkedin/). Normaliser alle
const CAPTIONS = { // felt til kjente former; manglende/ugyldig fil → tomme standarder (graceful).
"01": "Noen lover vekst — men hvem tjener på at ledergruppen tror på pitchen?", export function loadEditionConfig(rootDir = OUT_ROOT) {
"02": "KI på maskiner vi styrer selv — der ingen data forlater huset.", let cfg;
"03": "Regningen kommer hver måned — og en voksende del går ut av landet.", try {
"04": "Samme talent, ulik tilgang — det er der gapet begynner.", cfg = JSON.parse(fs.readFileSync(path.join(rootDir, "edition-config.json"), "utf8"));
"05": "Å forvalte var jobben. Nå er den å lede omstilling — om igjen, hvert år.", } catch {
"06": "Tolv grep en leder kan ta selv — de to første er allerede krysset av.", 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"]); const CAROUSEL = new Set(["03", "06"]);
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -250,12 +255,12 @@ ${inner}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Edition-POST.html (Del 16) // Edition-POST.html (Del 16)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function editionPost(nn, meta, body, share) { export function editionPost(nn, meta, body, share, config = EMPTY_CONFIG) {
const cal = CALENDAR[nn] || { dag: "—", klokke: "08:00" }; const cal = config.calendar[nn] || { dag: "—", klokke: "08:00" };
const sTitle = seoTitle(meta.title); const sTitle = seoTitle(meta.title);
const sDesc = seoDescription(meta.subtitle); 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 seoWarn = sTitle.length > 60 ? ` <span class="warn">⚠️ ${sTitle.length} tegn (over SEO-anbefaling 60)</span>` : ` (${sTitle.length} tegn)`;
const fresh = FRESHNESS[nn]; const fresh = config.freshness[nn];
const blocks = markdownToBlocks(body); const blocks = markdownToBlocks(body);
const subtitle = meta.subtitle ? `<p><strong>${inline(meta.subtitle)}</strong></p>` : ""; const subtitle = meta.subtitle ? `<p><strong>${inline(meta.subtitle)}</strong></p>` : "";
const copyZone = [subtitle, ...blocks].filter(Boolean).join("\n "); const copyZone = [subtitle, ...blocks].filter(Boolean).join("\n ");
@ -287,8 +292,8 @@ function editionPost(nn, meta, body, share) {
<div class="fld"> <div class="fld">
<h2>2 · Cover (1920×1080)</h2> <h2>2 · Cover (1920×1080)</h2>
<div class="label">Fil</div><div class="val"><code>linkedin/${nn}/cover.png</code></div> <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(COVER_CREDIT)}</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(CAPTIONS[nn] || "")}</div> <div class="label">Caption</div><div class="val">${esc(config.captions[nn] || "")}</div>
</div> </div>
<div class="fld"> <div class="fld">
@ -315,8 +320,8 @@ function editionPost(nn, meta, body, share) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Samle-POST.html (frittstående native post) // Samle-POST.html (frittstående native post)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function samlePost(share) { export function samlePost(share, config = EMPTY_CONFIG) {
const cal = CALENDAR.samle; const cal = config.calendar.samle || { dag: "—", klokke: "08:00" };
const shareField = share ? `${share.share}\n\n${share.hashtags}` : "—"; const shareField = share ? `${share.share}\n\n${share.hashtags}` : "—";
const inner = ` const inner = `
<h1 class="sheet">Samle-post oversikt over hele serien</h1> <h1 class="sheet">Samle-post oversikt over hele serien</h1>
@ -339,26 +344,34 @@ function samlePost(share) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Main // Main
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const files = process.argv.slice(2); export function main(files = process.argv.slice(2)) {
const shareMap = parseDelingstekst(); const config = loadEditionConfig(OUT_ROOT);
const shareMap = parseDelingstekst();
for (const f of files) { for (const f of files) {
const abs = path.isAbsolute(f) ? f : path.join(process.cwd(), f); const abs = path.isAbsolute(f) ? f : path.join(process.cwd(), f);
const raw = fs.readFileSync(abs, "utf8"); const raw = fs.readFileSync(abs, "utf8");
const { meta, body } = parseFrontMatter(raw); const { meta, body } = parseFrontMatter(raw);
const base = path.basename(abs).replace(/\.md$/, ""); const base = path.basename(abs).replace(/\.md$/, "");
const nn = (base.match(/^(\d{2})/) || [, base])[1]; const nn = (base.match(/^(\d{2})/) || [, base])[1];
if (!/^\d{2}$/.test(nn)) { console.warn(`↷ hopper over ${f} (ikke NN-prefiks)`); continue; } if (!/^\d{2}$/.test(nn)) { console.warn(`↷ hopper over ${f} (ikke NN-prefiks)`); continue; }
const dir = path.join(OUT_ROOT, nn); const dir = path.join(OUT_ROOT, nn);
fs.mkdirSync(dir, { recursive: true }); fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, "POST.html"), editionPost(nn, meta, body, shareMap[nn])); fs.writeFileSync(path.join(dir, "POST.html"), editionPost(nn, meta, body, shareMap[nn], config));
console.log(`✓ linkedin/${nn}/POST.html (${meta.title || base})`); 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) // CLI-guard (S2 korreksjon #4): kjør main kun når scriptet kjøres direkte,
if (shareMap.samle) { // slik at modulen kan importeres i tester uten side-effekter.
const dir = path.join(OUT_ROOT, "samle"); if (import.meta.url === `file://${process.argv[1]}`) {
fs.mkdirSync(dir, { recursive: true }); main();
fs.writeFileSync(path.join(dir, "POST.html"), samlePost(shareMap.samle));
console.log("✓ linkedin/samle/POST.html (samle-post)");
} }