Wave 3 / Step 8 of the remediation plan (Phase 1 — usable by a non-author). The flagship long-form engine shipped bespoke-as-general: a maintainer-private absolute series path and a hardcoded 'Maskinrommet' brand in the renderers. Both are now generalized so a non-author can run the pipeline without inheriting the author's filesystem or publication identity. - commands/newsletter.md + config/edition-state.template.json: series-root default /Users/ktg/repos/maskinrommet/serier -> $HOME/linkedin-series; reconciled the prose that called the maskinrommet folder 'the default' so it no longer contradicts the new neutral default. LTL_SERIES_ROOT override contract preserved. - render/build-linkedin.mjs + render/build-carousel.mjs: brand is now an LTL_BRAND env-var (empty default -> generic). Samle-post title, carousel footer brand-span, and cover-eyebrow fallback are de-branded; empty brand renders clean chrome. The operator sets LTL_BRAND=Maskinrommet in their own env to re-brand (same pattern as LTL_SERIES_ROOT). - config/image-credit-caption.template.md: 'Maskinrommet-/serie-badge' -> generic 'serie-badge'. Out of scope (Step 9): the residual 'Maskinrommet skrivekontrakt §C2/§A' references in newsletter.md are the writing-contract generalization, handled next. [skip-docs]: three-doc + version reconciliation is Step 21 (pre-review-gate, per plan: 'LAST so it captures everything'). These intermediate Wave commits are NOT pushed before the /trekreview gate, so the three-doc obligation (which governs pushed changes) is satisfied at Step 21, not per local checkpoint commit. Verify: grep -rIn '/Users/ktg' config/ commands/ render/ (excl .local) -> no matches; grep -rn 'Maskinrommet' render/ -> no matches (de-branded); node --check on both render scripts -> OK; LTL_SERIES_ROOT still present in newsletter.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
307 lines
12 KiB
JavaScript
307 lines
12 KiB
JavaScript
#!/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, "<").replace(/>/g, ">");
|
||
}
|
||
function inline(text) {
|
||
let out = esc(text);
|
||
out = out.replace(/\*\*([^*]+)\*\*/g, (_, c) => `<strong>${c}</strong>`);
|
||
out = out.replace(/\*([^*]+)\*/g, (_, c) => `<em>${c}</em>`);
|
||
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 ? `<h1 class="title">${inline(slide.title)}</h1>` : "";
|
||
const bodyHtml = slide.bodyParas.length
|
||
? `<div class="body">${slide.bodyParas.map((p) => `<p>${inline(p)}</p>`).join("")}</div>`
|
||
: "";
|
||
|
||
// kicker: interior bruker bolk-merkelapp; bookend bruker eyebrow (cover/CTA)
|
||
let head = "";
|
||
if (bookend) {
|
||
const eyebrow = isCover ? eyebrows.cover : eyebrows.cta;
|
||
head = `<p class="eyebrow">${esc(eyebrow)}</p>`;
|
||
} else if (slide.kicker) {
|
||
head = `<span class="kicker">${esc(slide.kicker)}</span>`;
|
||
}
|
||
|
||
const counter = `${idx + 1} / ${total}`;
|
||
const footer = `<div class="footer">${BRAND ? `<span class="brand">${esc(BRAND)}</span>` : ""}<span class="count">${counter}</span></div>`;
|
||
|
||
return `<section class="${cls}">
|
||
${head}
|
||
${titleHtml}
|
||
${bodyHtml}
|
||
${footer}
|
||
</section>`;
|
||
}
|
||
|
||
function buildHtml(slides, eyebrows) {
|
||
const total = slides.length;
|
||
const slidesHtml = slides.map((s, i) => slideHtml(s, i, total, eyebrows)).join("\n");
|
||
return `<!DOCTYPE html>
|
||
<html lang="nb">
|
||
<head><meta charset="utf-8"><title>Carousel</title><style>${CSS}</style></head>
|
||
<body>
|
||
${slidesHtml}
|
||
</body>
|
||
</html>
|
||
`;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Main
|
||
// ---------------------------------------------------------------------------
|
||
function main() {
|
||
const args = process.argv.slice(2);
|
||
if (!args.length) {
|
||
console.error("Bruk: node build-carousel.mjs <carousel.md> [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();
|
||
}
|