#!/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");
// Valgfri brand på samle-postens tittel. Tom som standard (generisk plugin);
// sett LTL_BRAND til serie-/publikasjonsnavnet for å re-brande (samme mønster
// som LTL_SERIES_ROOT). Tom brand → ren «Samle-post»-tittel.
const BRAND = process.env.LTL_BRAND || "";
// ---------------------------------------------------------------------------
// 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, "&").replace(//g, ">");
}
// Inline: **fet**, *kursiv*, bare URL → lenke. «», — beholdes.
function inline(text) {
let out = esc(text);
out = out.replace(/\*\*([^*]+)\*\*/g, (_, c) => `${c}`);
out = out.replace(/\*([^*]+)\*/g, (_, c) => `${c}`);
out = out.replace(
/(https?:\/\/[^\s<]+)/g,
(u) => `${u}`
);
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(`
${inline(para.join(" "))}
`);
para = [];
}
while (i < lines.length) {
const t = lines[i].trim();
if (t === "") { flushPara(); i++; continue; }
if (t === "---") { flushPara(); out.push("
"); i++; continue; }
if (/^###\s+/.test(t)) { flushPara(); out.push(`${inline(t.replace(/^###\s+/, ""))}
`); i++; continue; }
if (/^##\s+/.test(t)) { flushPara(); out.push(`${inline(t.replace(/^##\s+/, ""))}
`); 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(`${inline(q.join(" "))}
`);
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(`${items.map((x) => `- ${inline(x)}
`).join("")}
`);
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(`${items.map((x) => `- ${inline(x)}
`).join("")}
`);
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 `
${esc(title)}
${inner}
`;
}
// ---------------------------------------------------------------------------
// Edition-POST.html (Del 1–6)
// ---------------------------------------------------------------------------
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 = config.freshness[nn];
const blocks = markdownToBlocks(body);
const subtitle = meta.subtitle ? `${inline(meta.subtitle)}
` : "";
const copyZone = [subtitle, ...blocks].filter(Boolean).join("\n ");
const shareField = share ? `${share.share}\n\n${share.hashtags}` : "—";
const carouselBlock = (config.carousel || []).includes(nn)
? `6 · Carousel (valgfritt rekkevidde-tillegg)
Egen dokument-post, helst egen dag: last opp linkedin/${nn}/carousel.pdf.
Caption = delingstekstens premiss-linje.
`
: "";
const inner = `
Del ${nn} — ${esc(meta.title || "")}
📅 ${cal.dag} · kl. ${cal.klokke} (Schedule post → CEST)
${fresh ? `⚠️ Ferskvare før planlegging: ${esc(fresh)}
` : ""}
1 · Felter (Settings i editoren)
Tittel (${(meta.title || "").length} tegn)
${esc(meta.title || "")}
SEO-tittel${seoWarn}
${esc(sTitle)}
SEO-beskrivelse (${sDesc.length} tegn — mål 140–160)
${esc(sDesc)}
Lesetid / Serie
${esc(meta.lesetid || "—")} · ${esc(meta.serie || "—")}
2 · Cover (1920×1080)
Fil
linkedin/${nn}/cover.png
Credit (Add credit and caption)
${esc(config.coverCredit || "")}
Caption
${esc(config.captions[nn] || "—")}
3 · «Tell your network…»-delingstekst (lim i feltet over kortet)
${esc(shareField)}
4 · Første kommentar (legg når posten er live)
${esc(share ? share.kommentar : "—")}
${carouselBlock}
⬇︎ ${(config.carousel || []).includes(nn) ? "7" : "6"} · BRØDTEKST — merk alt herfra, kopier (⌘C), lim i editoren ⬇︎
${copyZone}
⬆︎ Til hit ⬆︎ (sjekk at overskrifter/lister/fet overlevde liminga)
`;
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 = `
Samle-post — oversikt over hele serien
📅 ${cal.dag} · kl. ${cal.klokke} (Schedule post → CEST)
Type: Frittstående native feed-post (ikke en edition).
Lenken til serien legges i FØRSTE KOMMENTAR.
⬇︎ 1 · POST-TEKST — merk alt herfra, kopier, lim i en ny LinkedIn-post ⬇︎
⬆︎ Til hit ⬆︎
2 · Første kommentar (legg når posten er live)
${esc(share ? share.kommentar : "—")}
[LENKE] = index/kanonisk hjem (fromaitochitta.com hvis live) ELLER Del 1-editionen som inngang. Velg det som faktisk er publisert.
`;
return shell(BRAND ? `Samle-post · ${BRAND}` : "Samle-post", 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();
}