#!/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: konstanter nedenfor (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"); // --------------------------------------------------------------------------- // LÅSTE KONSTANTER (HANDOVER §13 / DREIEBOK / image-credit-caption.md) // --------------------------------------------------------------------------- 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 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 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.", }; const CAROUSEL = new Set(["03", "06"]); // --------------------------------------------------------------------------- // 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(``); 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) => `
  1. ${inline(x)}
  2. `).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() { const raw = fs.readFileSync(DELINGSTEKST_FILE, "utf8").replace(/\r\n/g, "\n"); 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) // --------------------------------------------------------------------------- function editionPost(nn, meta, body, share) { const cal = 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 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 = CAROUSEL.has(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(COVER_CREDIT)}
Caption
${esc(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}
⬇︎ ${CAROUSEL.has(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) // --------------------------------------------------------------------------- function samlePost(share) { const cal = CALENDAR.samle; 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 ⬇︎
${esc(shareField)}
⬆︎ 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("Samle-post · Maskinrommet", inner); } // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- const files = process.argv.slice(2); 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})`); } // 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)"); }