#!/usr/bin/env node // build-carousel.mjs — render en LinkedIn-carousel (dokument-PDF) fra slide-markdown. // Bruk: node build-carousel.mjs linkedin/06/carousel.md [linkedin/03/carousel.md ...] // Hver "## SLIDE N — ..." blir én portrett-side (1080×1350, 4:5) i PDF-en. // Designet typografisk deck — speiler avis-identiteten (Newsreader/Inter, off-white, // oxblood). Cover (slide 1) + CTA (siste slide) = oxblood-bokstøtter; de øvrige lyse // med bolk-kicker + footer (valgfri brand + teller). Ingen per-slide AI-foto. // Krever: weasyprint på PATH. Ingen npm-avhengigheter. import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { execFileSync } from "node:child_process"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // Valgfri brand i footer + cover-eyebrow-fallback. Tom som standard (generisk); // sett LTL_BRAND til serie-/publikasjonsnavnet for å re-brande (samme mønster // som LTL_SERIES_ROOT). Tom brand → footer uten brand-span, tom eyebrow-fallback. const BRAND = process.env.LTL_BRAND || ""; // --------------------------------------------------------------------------- // weasyprint graceful degradation (S1, correction #3) // Detekterer weasyprint på PATH. Returnerer et skip-signal (kaster ALDRI) når // verktøyet mangler, slik at PDF-steget hoppes over med en tydelig install-hint // i stedet for å krasje kjøringen. `probe` er injiserbar for test. // --------------------------------------------------------------------------- const WEASYPRINT_HINT = "weasyprint ikke funnet på PATH — hopper over PDF-steget.\n" + " Install: pipx install weasyprint (alternativt: brew install weasyprint)"; export function resolveWeasyprint(probe = defaultWeasyprintProbe) { if (probe()) return { available: true }; return { available: false, hint: WEASYPRINT_HINT }; } function defaultWeasyprintProbe() { try { execFileSync("weasyprint", ["--version"], { stdio: "ignore" }); return true; } catch { return false; } } // --------------------------------------------------------------------------- // Inline markdown (**fet**, *kursiv*) + escaping // --------------------------------------------------------------------------- function esc(s) { return s.replace(/&/g, "&").replace(//g, ">"); } function inline(text) { let out = esc(text); out = out.replace(/\*\*([^*]+)\*\*/g, (_, c) => `${c}`); out = out.replace(/\*([^*]+)\*/g, (_, c) => `${c}`); return out; } // --------------------------------------------------------------------------- // Valgfri YAML front matter (flate key: "value"-par) — for cover/CTA-eyebrow. // Felt: cover_eyebrow, cta_eyebrow. Faller tilbake til generiske default-er. // --------------------------------------------------------------------------- function parseFrontMatter(raw) { const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/); if (!m) return { meta: {}, rest: 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, rest: m[2] }; } // --------------------------------------------------------------------------- // Parse slide-markdown. // Slides skilles av "## SLIDE N — label". Innenfor en slide: // - linje `…` (backticks) -> kicker (bolk-merkelapp) // - linje "# …" -> tittel // - øvrige ikke-tomme -> brødtekst-avsnitt (ett per linje) // --------------------------------------------------------------------------- function parseSlides(raw) { const body = raw.replace(/\r\n/g, "\n"); // dropp ev. innledende H1 + forklarings-avsnitt før første "## SLIDE" const startIdx = body.indexOf("## SLIDE"); const region = startIdx >= 0 ? body.slice(startIdx) : body; const chunks = region.split(/^##\s+SLIDE\b.*$/m).map((c) => c.trim()).filter(Boolean); return chunks.map((chunk) => { const lines = chunk.split("\n"); let kicker = null; let title = null; const bodyParas = []; for (const lnRaw of lines) { const ln = lnRaw.trim(); if (!ln) continue; if (ln.startsWith("---")) continue; const km = ln.match(/^`([^`]+)`$/); if (km && !kicker && !title) { kicker = km[1].trim(); continue; } if (ln.startsWith("# ")) { title = ln.slice(2).trim(); continue; } bodyParas.push(ln); } return { kicker, title, bodyParas }; }); } // --------------------------------------------------------------------------- // CSS — portrett 4:5, avis-identitet. Oxblood-bokstøtter for cover/CTA. // --------------------------------------------------------------------------- const FONT_DIR = path.join(__dirname, "fonts"); const ff = (f) => `url("file://${path.join(FONT_DIR, f).replace(/ /g, "%20")}")`; const FONT_FACE = ` @font-face{font-family:"Newsreader";font-style:normal;font-weight:400;src:${ff("Newsreader-400.ttf")};} @font-face{font-family:"Newsreader";font-style:italic;font-weight:400;src:${ff("Newsreader-400i.ttf")};} @font-face{font-family:"Newsreader";font-style:normal;font-weight:600;src:${ff("Newsreader-600.ttf")};} @font-face{font-family:"Newsreader";font-style:italic;font-weight:600;src:${ff("Newsreader-600i.ttf")};} @font-face{font-family:"Newsreader";font-style:normal;font-weight:700;src:${ff("Newsreader-700.ttf")};} @font-face{font-family:"Inter";font-style:normal;font-weight:400;src:${ff("Inter-400.ttf")};} @font-face{font-family:"Inter";font-style:normal;font-weight:600;src:${ff("Inter-600.ttf")};} @font-face{font-family:"Inter";font-style:normal;font-weight:700;src:${ff("Inter-700.ttf")};} `; const CSS = ` ${FONT_FACE} :root{ --bg:#FBFAF7; --ink:#1A1A1A; --muted:#5b5750; --accent:#9A3324; --rule:#d8d4cb; --cream:#F4EFE6; --serif:"Newsreader",Georgia,serif; --sans:"Inter","Helvetica Neue",Arial,sans-serif; } @page{ size:1080px 1350px; margin:0; } *{ box-sizing:border-box; margin:0; padding:0; } html,body{ background:var(--bg); } .slide{ position:relative; width:1080px; height:1350px; padding:96px 96px 92px; background:var(--bg); color:var(--ink); font-family:var(--serif); page-break-after:always; overflow:hidden; } .slide:last-child{ page-break-after:auto; } /* ---- kicker (bolk-merkelapp) ---- */ .kicker{ display:inline-block; font-family:var(--sans); font-weight:700; text-transform:uppercase; letter-spacing:0.14em; font-size:22px; color:#fff; background:var(--accent); padding:9px 18px; border-radius:3px; margin-bottom:46px; } /* ---- tittel / brødtekst ---- */ .title{ font-family:var(--serif); font-weight:700; font-size:74px; line-height:1.06; letter-spacing:-0.012em; margin-bottom:40px; } .body p{ font-family:var(--serif); font-weight:400; font-size:35px; line-height:1.45; color:#2b2823; margin-bottom:22px; } .body p strong{ font-weight:700; color:var(--ink); } .body p em{ font-style:italic; } /* ---- footer ---- */ .footer{ position:absolute; left:96px; right:96px; bottom:64px; display:flex; justify-content:space-between; align-items:center; border-top:1px solid var(--rule); padding-top:22px; font-family:var(--sans); font-size:21px; letter-spacing:0.04em; } .footer .brand{ font-weight:700; text-transform:uppercase; letter-spacing:0.13em; color:var(--accent); } .footer .count{ color:var(--muted); font-weight:600; } /* ---- interior layout (grep): topp-justert under kicker ---- */ .slide.interior .stage{ } /* ---- cover + CTA: oxblood-bokstøtter ---- */ .slide.bookend{ background:var(--accent); color:var(--cream); display:flex; flex-direction:column; justify-content:center; } .slide.bookend .eyebrow{ font-family:var(--sans); font-weight:700; text-transform:uppercase; letter-spacing:0.18em; font-size:23px; color:#F0C9B6; margin-bottom:34px; } .slide.bookend .title{ color:#fff; font-size:86px; line-height:1.02; margin-bottom:40px; } .slide.bookend .body p{ color:#F4E4D8; font-size:38px; line-height:1.42; } .slide.bookend .body p strong{ color:#fff; } .slide.bookend .footer{ border-top:1px solid rgba(244,228,216,0.32); color:#F0C9B6; } .slide.bookend .footer .brand{ color:#fff; } .slide.bookend .footer .count{ color:#F0C9B6; } .slide.bookend .arrow{ font-size:40px; } `; // --------------------------------------------------------------------------- // Render én slide til HTML // --------------------------------------------------------------------------- function slideHtml(slide, idx, total, eyebrows) { const isCover = idx === 0; const isCta = idx === total - 1; const bookend = isCover || isCta; const cls = bookend ? "slide bookend" : "slide interior"; const titleHtml = slide.title ? `

${inline(slide.title)}

` : ""; const bodyHtml = slide.bodyParas.length ? `
${slide.bodyParas.map((p) => `

${inline(p)}

`).join("")}
` : ""; // kicker: interior bruker bolk-merkelapp; bookend bruker eyebrow (cover/CTA) let head = ""; if (bookend) { const eyebrow = isCover ? eyebrows.cover : eyebrows.cta; head = `

${esc(eyebrow)}

`; } else if (slide.kicker) { head = `${esc(slide.kicker)}`; } const counter = `${idx + 1} / ${total}`; const footer = ``; return `
${head} ${titleHtml} ${bodyHtml} ${footer}
`; } function buildHtml(slides, eyebrows) { const total = slides.length; const slidesHtml = slides.map((s, i) => slideHtml(s, i, total, eyebrows)).join("\n"); return ` Carousel ${slidesHtml} `; } // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- function main() { const args = process.argv.slice(2); if (!args.length) { console.error("Bruk: node build-carousel.mjs [flere.md ...]"); process.exit(1); } const wp = resolveWeasyprint(); if (!wp.available) console.warn(wp.hint); for (const arg of args) { const inPath = path.isAbsolute(arg) ? arg : path.join(process.cwd(), arg); if (!fs.existsSync(inPath)) { console.error(`Fant ikke: ${inPath}`); continue; } const raw = fs.readFileSync(inPath, "utf8"); const { meta } = parseFrontMatter(raw); const slides = parseSlides(raw); if (!slides.length) { console.error(`Ingen slides funnet i ${inPath}`); continue; } const eyebrows = { cover: meta.cover_eyebrow || BRAND, cta: meta.cta_eyebrow || "Kom i gang", }; const dir = path.dirname(inPath); const html = buildHtml(slides, eyebrows); const htmlPath = path.join(dir, "carousel.html"); const pdfPath = path.join(dir, "carousel.pdf"); fs.writeFileSync(htmlPath, html, "utf8"); if (wp.available) { execFileSync("weasyprint", [htmlPath, pdfPath], { stdio: ["ignore", "ignore", "inherit"] }); const kb = (fs.statSync(pdfPath).size / 1024).toFixed(1); console.log(`Carousel: ${pdfPath} (${slides.length} slides, ${kb} KB)`); } else { console.warn(` Hoppet over PDF (weasyprint mangler): ${pdfPath}`); } } } if (import.meta.url === `file://${process.argv[1]}`) { main(); }