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:
Kjell Tore Guttormsen 2026-05-29 11:32:02 +02:00
commit b6bb61246b
196 changed files with 164 additions and 138 deletions

View file

@ -1,302 +0,0 @@
#!/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();
}