ktg-plugin-marketplace/plugins/linkedin-thought-leadership/render/build-carousel.mjs
2026-05-26 22:11:38 +02:00

302 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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 (Maskinrommet + 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));
// ---------------------------------------------------------------------------
// 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
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"><span class="brand">Maskinrommet</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 || "Maskinrommet",
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();
}