feat(linkedin): generalize build-linkedin to read edition-config.json (S2)
This commit is contained in:
parent
c225ea1dba
commit
6e6ed7dd4e
3 changed files with 173 additions and 53 deletions
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 1–6)
|
// Edition-POST.html (Del 1–6)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
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)");
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue