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>
This commit is contained in:
parent
9df3de795c
commit
b6bb61246b
196 changed files with 164 additions and 138 deletions
302
plugins/linkedin-studio/render/build-carousel.mjs
Normal file
302
plugins/linkedin-studio/render/build-carousel.mjs
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
#!/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, "&").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"><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();
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue