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
378
plugins/linkedin-studio/render/build-pdf.mjs
Normal file
378
plugins/linkedin-studio/render/build-pdf.mjs
Normal file
|
|
@ -0,0 +1,378 @@
|
|||
#!/usr/bin/env node
|
||||
// build-pdf.mjs — render kronikkene som rene avis-PDF-er (uten annoterings-UI).
|
||||
// Bruk: node build-pdf.mjs utkast/01-....md [flere.md ...]
|
||||
// Genererer ren print-HTML i pdf/_html/<navn>.html og kjører weasyprint -> pdf/<navn>.pdf.
|
||||
// Speiler avis-stilen fra build-html.mjs, men print-tunet (A4, marger, sidetall).
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// YAML front matter (flate key: "value"-par mellom --- ... ---) — som build-html.mjs
|
||||
// ---------------------------------------------------------------------------
|
||||
function parseFrontMatter(raw) {
|
||||
const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
|
||||
if (!m) return { meta: {}, body: 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, body: m[2].replace(/^\r?\n+/, "") };
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Kompakt markdown -> HTML (som build-html.mjs). Siste avsnitt-blokk som starter
|
||||
// med <em>Om tilblivelsen:</em> merkes .colophon for diskret metodenote-stil.
|
||||
// ---------------------------------------------------------------------------
|
||||
function markdownToHtml(body) {
|
||||
const lines = body.replace(/\r\n/g, "\n").split("\n");
|
||||
const blocks = [];
|
||||
let i = 0;
|
||||
let paraCount = 0;
|
||||
|
||||
function flushPara(buf) {
|
||||
if (!buf.length) return;
|
||||
const text = buf.join(" ").trim();
|
||||
if (!text) return;
|
||||
paraCount++;
|
||||
let cls = paraCount === 1 ? "lede" : "indent";
|
||||
if (/^\*Om tilblivelsen:\*/.test(text)) cls = "colophon";
|
||||
let inner = inline(text);
|
||||
// Drop cap som ekte, floatet <span> (weasyprint krasjer på ::first-letter{float}).
|
||||
if (cls === "lede") {
|
||||
inner = inner.replace(
|
||||
/^(\s*)([A-Za-zÆØÅæøå0-9])/,
|
||||
(_, ws, ch) => `${ws}<span class="dropcap">${ch}</span>`
|
||||
);
|
||||
}
|
||||
blocks.push(`<p class="${cls}">${inner}</p>`);
|
||||
}
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
const trimmed = line.trim();
|
||||
|
||||
if (/^---+$/.test(trimmed)) {
|
||||
blocks.push("<hr>");
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
let hm = trimmed.match(/^(#{2,3})\s+(.*)$/);
|
||||
if (hm) {
|
||||
const level = hm[1].length;
|
||||
blocks.push(`<h${level}>${inline(hm[2].trim())}</h${level}>`);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^>\s?/.test(trimmed)) {
|
||||
const qbuf = [];
|
||||
while (i < lines.length && /^>\s?/.test(lines[i].trim())) {
|
||||
qbuf.push(lines[i].trim().replace(/^>\s?/, ""));
|
||||
i++;
|
||||
}
|
||||
blocks.push(`<blockquote><p>${inline(qbuf.join(" ").trim())}</p></blockquote>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^[-*]\s+/.test(trimmed)) {
|
||||
const items = [];
|
||||
while (i < lines.length && /^[-*]\s+/.test(lines[i].trim())) {
|
||||
items.push(lines[i].trim().replace(/^[-*]\s+/, ""));
|
||||
i++;
|
||||
}
|
||||
blocks.push("<ul>" + items.map((it) => `<li>${inline(it)}</li>`).join("") + "</ul>");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^\d+\.\s+/.test(trimmed)) {
|
||||
const items = [];
|
||||
while (i < lines.length && /^\d+\.\s+/.test(lines[i].trim())) {
|
||||
items.push(lines[i].trim().replace(/^\d+\.\s+/, ""));
|
||||
i++;
|
||||
}
|
||||
blocks.push("<ol>" + items.map((it) => `<li>${inline(it)}</li>`).join("") + "</ol>");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmed === "") {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const pbuf = [];
|
||||
while (i < lines.length) {
|
||||
const t = lines[i].trim();
|
||||
if (
|
||||
t === "" ||
|
||||
/^---+$/.test(t) ||
|
||||
/^(#{2,3})\s+/.test(t) ||
|
||||
/^>\s?/.test(t) ||
|
||||
/^[-*]\s+/.test(t) ||
|
||||
/^\d+\.\s+/.test(t)
|
||||
) {
|
||||
break;
|
||||
}
|
||||
pbuf.push(t);
|
||||
i++;
|
||||
}
|
||||
flushPara(pbuf);
|
||||
}
|
||||
|
||||
return blocks.join("\n");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Print-CSS — avis-identitet (off-white, oxblood, drop cap, serif), A4-tunet.
|
||||
// ---------------------------------------------------------------------------
|
||||
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 PRINT_CSS = `
|
||||
${FONT_FACE}
|
||||
:root {
|
||||
--bg: #FBFAF7;
|
||||
--ink: #1A1A1A;
|
||||
--muted: #555555;
|
||||
--accent: #9A3324;
|
||||
--rule: #d8d4cb;
|
||||
--serif: "Newsreader",Georgia,"Times New Roman",serif;
|
||||
--sans: "Inter","Helvetica Neue",Helvetica,Arial,sans-serif;
|
||||
}
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 22mm 21mm 20mm;
|
||||
background: #FBFAF7;
|
||||
@bottom-center {
|
||||
content: counter(page);
|
||||
font-family: "Inter","Helvetica Neue",Helvetica,Arial,sans-serif;
|
||||
font-size: 8pt;
|
||||
color: #9a9a9a;
|
||||
}
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html { background: var(--bg); }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: var(--serif);
|
||||
font-size: 12.5pt;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.kicker {
|
||||
font-family: var(--sans);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
font-size: 8pt;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
margin: 0 0 8pt;
|
||||
}
|
||||
h1.title {
|
||||
font-family: var(--serif);
|
||||
font-size: 27pt;
|
||||
line-height: 1.08;
|
||||
font-weight: 700;
|
||||
margin: 0 0 9pt;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.subtitle {
|
||||
font-family: var(--serif);
|
||||
font-style: italic;
|
||||
font-size: 14.5pt;
|
||||
line-height: 1.4;
|
||||
color: var(--muted);
|
||||
margin: 0 0 14pt;
|
||||
}
|
||||
.byline-wrap {
|
||||
border-top: 1px solid var(--rule);
|
||||
padding-top: 7pt;
|
||||
margin-bottom: 18pt;
|
||||
}
|
||||
.byline {
|
||||
font-family: var(--sans);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
font-size: 8pt;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
margin: 0 0 2pt;
|
||||
}
|
||||
.meta {
|
||||
font-family: var(--sans);
|
||||
font-size: 8pt;
|
||||
color: var(--muted);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.body p { margin: 0; text-align: justify; hyphens: auto; orphans: 3; widows: 3; }
|
||||
.body p.indent { text-indent: 1.4em; }
|
||||
.body .dropcap {
|
||||
float: left;
|
||||
font-size: 3.1em;
|
||||
line-height: 0.74;
|
||||
padding: 0.02em 0.09em 0 0;
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
.body h2 { font-size: 16.5pt; font-weight: 700; margin: 18pt 0 6pt; line-height: 1.2; }
|
||||
.body h3 { font-size: 13.5pt; font-weight: 700; margin: 15pt 0 4pt; line-height: 1.25; }
|
||||
.body ul, .body ol { margin: 7pt 0; padding-left: 1.4em; }
|
||||
.body li { margin: 2pt 0; }
|
||||
.body blockquote {
|
||||
margin: 12pt 0;
|
||||
padding-left: 1.1em;
|
||||
border-left: 3px solid var(--accent);
|
||||
font-style: italic;
|
||||
color: #333;
|
||||
}
|
||||
.body hr {
|
||||
border: 0;
|
||||
border-top: 1px solid var(--rule);
|
||||
margin: 16pt auto;
|
||||
width: 38%;
|
||||
}
|
||||
.body strong { font-weight: 600; }
|
||||
.body p.colophon {
|
||||
font-family: var(--sans);
|
||||
font-size: 9.5pt;
|
||||
line-height: 1.45;
|
||||
color: var(--muted);
|
||||
text-align: left;
|
||||
text-indent: 0;
|
||||
}
|
||||
.body h2, .body h3 { break-after: avoid; }
|
||||
.body p.lede { break-after: avoid; }
|
||||
`;
|
||||
|
||||
function buildPrintHtml(meta, body) {
|
||||
const bodyHtml = markdownToHtml(body);
|
||||
const title = meta.title || "Kronikk";
|
||||
const metaLine = [meta.serie, meta.lesetid].filter(Boolean).join(" · ");
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="nb">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>${esc(title)}</title>
|
||||
<style>${PRINT_CSS}</style>
|
||||
</head>
|
||||
<body>
|
||||
<article>
|
||||
${meta.kicker ? `<p class="kicker">${esc(meta.kicker)}</p>` : ""}
|
||||
<h1 class="title">${inline(title)}</h1>
|
||||
${meta.subtitle ? `<p class="subtitle">${inline(meta.subtitle)}</p>` : ""}
|
||||
<div class="byline-wrap">
|
||||
${meta.byline ? `<p class="byline">${esc(meta.byline)}</p>` : ""}
|
||||
${metaLine ? `<p class="meta">${esc(metaLine)}</p>` : ""}
|
||||
</div>
|
||||
<div class="body">
|
||||
${bodyHtml}
|
||||
</div>
|
||||
</article>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
if (!args.length) {
|
||||
console.error("Bruk: node build-pdf.mjs <fil.md> [flere.md ...]");
|
||||
process.exit(1);
|
||||
}
|
||||
// Output følger serien (kjøres fra serie-mappa); fonts følger scriptet via __dirname.
|
||||
const pdfDir = path.join(process.cwd(), "pdf");
|
||||
const htmlDir = path.join(pdfDir, "_html");
|
||||
fs.mkdirSync(htmlDir, { recursive: true });
|
||||
|
||||
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, body } = parseFrontMatter(raw);
|
||||
const base = path.basename(inPath).replace(/\.md$/i, "");
|
||||
const html = buildPrintHtml(meta, body);
|
||||
const htmlPath = path.join(htmlDir, base + ".html");
|
||||
const pdfPath = path.join(pdfDir, base + ".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(`PDF: ${pdfPath} (${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