ktg-plugin-marketplace/plugins/linkedin-studio/render/build-carousel.mjs
Kjell Tore Guttormsen b6bb61246b refactor(linkedin)!: rename plugin linkedin-thought-leadership → linkedin-studio (v3.0.0)
BREAKING CHANGE: the marketplace slug, the agent namespace
(linkedin-studio:<agent>), and the runtime state-file path
(~/.claude/linkedin-studio.local.md) all change. Reinstall required;
existing state migrated in place (post metrics, streak, history preserved).
The /linkedin:* commands are unchanged — the command namespace is set
per-command in frontmatter and was always independent of the plugin slug.
Functionality is byte-identical to v2.4.0; this release is pure identity.

- dir + manifests: plugins/linkedin-studio + plugin.json + root marketplace.json
- agent namespace updated in commands/newsletter.md (only functional invoker)
- state path updated in 4 hook scripts + topic-rotation prompt + state template
- catch-all skill dir renamed skills/linkedin-studio (5 functional skills unchanged)
- docs + version bump to 3.0.0 across README badge, CHANGELOG, root README/CLAUDE.md
- historical records (CHANGELOG past entries, docs/ build artifacts,
  config-audit v5.0.0 snapshots) intentionally retain the old slug

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 11:32:02 +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();
}