feat(linkedin): migrate render scripts + fonts into plugin (S1)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
098726d397
commit
6eff5e8e21
14 changed files with 2134 additions and 0 deletions
364
plugins/linkedin-thought-leadership/render/build-linkedin.mjs
Normal file
364
plugins/linkedin-thought-leadership/render/build-linkedin.mjs
Normal file
|
|
@ -0,0 +1,364 @@
|
|||
#!/usr/bin/env node
|
||||
// build-linkedin.mjs — bygger ÉN SAMLET POST.html per artikkel (publiseringsark).
|
||||
// Bruk: node build-linkedin.mjs utkast/01-...md [flere.md ...]
|
||||
// (samle/POST.html bygges alltid til slutt, uavhengig av argumentene)
|
||||
//
|
||||
// Mål (HANDOVER §13 E): alt-på-ett-sted per artikkel slik at bruker kan legge inn én edition
|
||||
// i én operasjon. POST.html åpnes i nettleser og inneholder, i publiseringsrekkefølge:
|
||||
// 1. Planlagt dato + kl. 08:00 (+ ferskvare-flagg for 01/02)
|
||||
// 2. Tittel / SEO-tittel / SEO-beskrivelse
|
||||
// 3. Cover: filnavn + credit + caption
|
||||
// 4. «Tell your network»-delingstekst (system, klikk-gatet) + hashtags
|
||||
// 5. Første kommentar
|
||||
// 6. (Del 3/6) carousel-PDF-referanse
|
||||
// 7. Brødtekst som RIK TEKST mellom streker (merk & kopier rett inn i editoren)
|
||||
//
|
||||
// Kilder:
|
||||
// - brødtekst + felt: front matter + body i utkast/NN-...md
|
||||
// - delingstekst/hashtags/første kommentar: linkedin/edition-delingstekst.md (parses)
|
||||
// - kalender + ferskvare + cover-credit/caption: konstanter nedenfor (HANDOVER §13 + image-credit-caption.md)
|
||||
// Ingen npm-avhengigheter, ingen nett. meta.md røres IKKE (håndholdt; har ekte pulse-URL).
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
// Output følger serien (kjøres fra serie-mappa), ikke scriptet i tools/.
|
||||
const OUT_ROOT = path.join(process.cwd(), "linkedin");
|
||||
const DELINGSTEKST_FILE = path.join(OUT_ROOT, "edition-delingstekst.md");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LÅSTE KONSTANTER (HANDOVER §13 / DREIEBOK / image-credit-caption.md)
|
||||
// ---------------------------------------------------------------------------
|
||||
const CALENDAR = {
|
||||
"01": { dag: "Tirsdag 26.05.2026", klokke: "08:00" },
|
||||
"02": { dag: "Onsdag 27.05.2026", klokke: "08:00" },
|
||||
"03": { dag: "Torsdag 28.05.2026", klokke: "08:00" },
|
||||
"04": { dag: "Fredag 29.05.2026", klokke: "08:00" },
|
||||
"05": { dag: "Lørdag 30.05.2026", klokke: "08:00" },
|
||||
"06": { dag: "Søndag 31.05.2026", klokke: "08:00" },
|
||||
samle: { dag: "Mandag 01.06.2026", klokke: "08:00" },
|
||||
};
|
||||
|
||||
const FRESHNESS = {
|
||||
"01": "OpenAI-verdsettelse (~850 mrd USD «i mars») + «Anthropic forhandler akkurat nå om en runde» — er runden lukket? Oppdater tall/tempus FØR planlegging.",
|
||||
"02": "SWE-bench Verified (77,6 %) for Mistral Medium 3.5 — fortsatt korrekt? Vurder avrunding FØR planlegging.",
|
||||
};
|
||||
|
||||
const COVER_CREDIT = "Illustrasjon generert med Google Gemini (Nano Banana Pro)";
|
||||
const CAPTIONS = {
|
||||
"01": "Noen lover vekst — men hvem tjener på at ledergruppen tror på pitchen?",
|
||||
"02": "KI på maskiner vi styrer selv — der ingen data forlater huset.",
|
||||
"03": "Regningen kommer hver måned — og en voksende del går ut av landet.",
|
||||
"04": "Samme talent, ulik tilgang — det er der gapet begynner.",
|
||||
"05": "Å forvalte var jobben. Nå er den å lede omstilling — om igjen, hvert år.",
|
||||
"06": "Tolv grep en leder kan ta selv — de to første er allerede krysset av.",
|
||||
};
|
||||
const CAROUSEL = new Set(["03", "06"]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// YAML front matter (flate key: "value"-par mellom --- ... ---)
|
||||
// ---------------------------------------------------------------------------
|
||||
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, ">");
|
||||
}
|
||||
|
||||
// Inline: **fet**, *kursiv*, bare URL → lenke. «», — beholdes.
|
||||
function inline(text) {
|
||||
let out = esc(text);
|
||||
out = out.replace(/\*\*([^*]+)\*\*/g, (_, c) => `<strong>${c}</strong>`);
|
||||
out = out.replace(/\*([^*]+)\*/g, (_, c) => `<em>${c}</em>`);
|
||||
out = out.replace(
|
||||
/(https?:\/\/[^\s<]+)/g,
|
||||
(u) => `<a href="${u}">${u}</a>`
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Markdown -> REN semantisk HTML (tagger LinkedIn-editoren kjenner igjen:
|
||||
// h2, h3, p, ul/ol/li, blockquote, strong, em, hr).
|
||||
// ---------------------------------------------------------------------------
|
||||
function markdownToBlocks(body) {
|
||||
const lines = body.replace(/\r\n/g, "\n").split("\n");
|
||||
const out = [];
|
||||
let i = 0;
|
||||
let para = [];
|
||||
|
||||
function flushPara() {
|
||||
if (para.length) out.push(`<p>${inline(para.join(" "))}</p>`);
|
||||
para = [];
|
||||
}
|
||||
|
||||
while (i < lines.length) {
|
||||
const t = lines[i].trim();
|
||||
if (t === "") { flushPara(); i++; continue; }
|
||||
if (t === "---") { flushPara(); out.push("<hr>"); i++; continue; }
|
||||
if (/^###\s+/.test(t)) { flushPara(); out.push(`<h3>${inline(t.replace(/^###\s+/, ""))}</h3>`); i++; continue; }
|
||||
if (/^##\s+/.test(t)) { flushPara(); out.push(`<h2>${inline(t.replace(/^##\s+/, ""))}</h2>`); i++; continue; }
|
||||
if (/^>\s?/.test(t)) {
|
||||
flushPara();
|
||||
const q = [];
|
||||
while (i < lines.length && /^>\s?/.test(lines[i].trim())) {
|
||||
q.push(lines[i].trim().replace(/^>\s?/, ""));
|
||||
i++;
|
||||
}
|
||||
out.push(`<blockquote><p>${inline(q.join(" "))}</p></blockquote>`);
|
||||
continue;
|
||||
}
|
||||
if (/^[-*]\s+/.test(t)) {
|
||||
flushPara();
|
||||
const items = [];
|
||||
while (i < lines.length && /^[-*]\s+/.test(lines[i].trim())) {
|
||||
items.push(lines[i].trim().replace(/^[-*]\s+/, ""));
|
||||
i++;
|
||||
}
|
||||
out.push(`<ul>${items.map((x) => `<li>${inline(x)}</li>`).join("")}</ul>`);
|
||||
continue;
|
||||
}
|
||||
if (/^\d+\.\s+/.test(t)) {
|
||||
flushPara();
|
||||
const items = [];
|
||||
while (i < lines.length && /^\d+\.\s+/.test(lines[i].trim())) {
|
||||
items.push(lines[i].trim().replace(/^\d+\.\s+/, ""));
|
||||
i++;
|
||||
}
|
||||
out.push(`<ol>${items.map((x) => `<li>${inline(x)}</li>`).join("")}</ol>`);
|
||||
continue;
|
||||
}
|
||||
para.push(t);
|
||||
i++;
|
||||
}
|
||||
flushPara();
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SEO
|
||||
// ---------------------------------------------------------------------------
|
||||
function seoDescription(subtitle) {
|
||||
const s = (subtitle || "").replace(/\s+/g, " ").trim();
|
||||
if (s.length <= 160) return s;
|
||||
let cut = s.slice(0, 158);
|
||||
cut = cut.slice(0, cut.lastIndexOf(" "));
|
||||
return cut + "…";
|
||||
}
|
||||
function seoTitle(title) {
|
||||
return (title || "").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parse edition-delingstekst.md → { "01": {share, hashtags, kommentar}, ..., samle: {...} }
|
||||
// En seksjon = «## Del N — …» eller «## Samle…». «## SYSTEM …» ignoreres.
|
||||
// ---------------------------------------------------------------------------
|
||||
function parseDelingstekst() {
|
||||
const raw = fs.readFileSync(DELINGSTEKST_FILE, "utf8").replace(/\r\n/g, "\n");
|
||||
const lines = raw.split("\n");
|
||||
const out = {};
|
||||
let i = 0;
|
||||
while (i < lines.length) {
|
||||
const h = lines[i].match(/^##\s+(Del\s+(\d)|Samle)/i);
|
||||
if (!h) { i++; continue; }
|
||||
const key = h[2] ? h[2].padStart(2, "0") : "samle";
|
||||
i++;
|
||||
const shareLines = [];
|
||||
let hashtags = "";
|
||||
let kommentar = "";
|
||||
while (i < lines.length && !/^##\s+/.test(lines[i]) && lines[i].trim() !== "---") {
|
||||
const t = lines[i];
|
||||
const tt = t.trim();
|
||||
const km = tt.match(/^\*\*Første kommentar:\*\*\s*(.*)$/);
|
||||
if (km) { kommentar = km[1].trim(); i++; continue; }
|
||||
if (/^#\S/.test(tt)) { hashtags = tt; i++; continue; }
|
||||
if (/^>/.test(tt)) { i++; continue; } // hopp over NB-blockquote
|
||||
shareLines.push(t);
|
||||
i++;
|
||||
}
|
||||
out[key] = {
|
||||
share: shareLines.join("\n").trim(),
|
||||
hashtags,
|
||||
kommentar,
|
||||
};
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTML-skall
|
||||
// ---------------------------------------------------------------------------
|
||||
const CSS = `
|
||||
body { font: 16px/1.6 -apple-system, Segoe UI, Roboto, sans-serif; max-width: 760px;
|
||||
margin: 24px auto; padding: 0 22px 60px; color: #1a1a1a; }
|
||||
h1.sheet { font-size: 1.5em; margin: 0 0 2px; }
|
||||
.when { font-size: 1.05em; font-weight: 700; color: #9a3324; margin: 0 0 18px; }
|
||||
.fresh { background: #fff7e6; border: 1px solid #f0c97a; border-radius: 8px;
|
||||
padding: 12px 16px; font-size: 14px; color: #5a4500; margin: 0 0 20px; }
|
||||
.fld { background: #f6f6f4; border: 1px solid #e2e2dc; border-radius: 10px;
|
||||
padding: 14px 18px; margin: 0 0 16px; }
|
||||
.fld h2 { font-size: .82em; text-transform: uppercase; letter-spacing: .06em;
|
||||
color: #777; margin: 0 0 8px; }
|
||||
.fld .label { font-size: 12px; color: #888; margin: 10px 0 1px; }
|
||||
.fld .val { font-size: 15px; }
|
||||
.warn { color: #9a3324; font-weight: 600; }
|
||||
.copybox { background: #fff; border: 1px dashed #9a3324; border-radius: 8px;
|
||||
padding: 12px 14px; white-space: pre-wrap; font-size: 15px; margin-top: 4px; }
|
||||
.marker { color: #9a3324; font-weight: 700; letter-spacing: .04em; font-size: 13px;
|
||||
text-transform: uppercase; margin: 26px 0 6px; }
|
||||
.copyzone { border-top: 2px dashed #9a3324; border-bottom: 2px dashed #9a3324; padding: 18px 0; }
|
||||
.copyzone h2 { font-size: 1.32em; margin: 1.4em 0 .4em; }
|
||||
.copyzone h3 { font-size: 1.1em; margin: 1.2em 0 .3em; }
|
||||
.copyzone blockquote { border-left: 3px solid #ccc; margin: 1em 0; padding-left: 16px; color: #444; }
|
||||
.copyzone hr { border: none; border-top: 1px solid #ddd; margin: 1.6em 0; }
|
||||
.copyzone li { margin: .2em 0; }
|
||||
code { background: #ececec; padding: 1px 5px; border-radius: 4px; font-size: 13px; }
|
||||
`;
|
||||
|
||||
function shell(title, inner) {
|
||||
return `<!doctype html>
|
||||
<html lang="nb">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>${esc(title)}</title>
|
||||
<style>${CSS}</style>
|
||||
</head>
|
||||
<body>
|
||||
${inner}
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edition-POST.html (Del 1–6)
|
||||
// ---------------------------------------------------------------------------
|
||||
function editionPost(nn, meta, body, share) {
|
||||
const cal = CALENDAR[nn] || { dag: "—", klokke: "08:00" };
|
||||
const sTitle = seoTitle(meta.title);
|
||||
const sDesc = seoDescription(meta.subtitle);
|
||||
const seoWarn = sTitle.length > 60 ? ` <span class="warn">⚠️ ${sTitle.length} tegn (over SEO-anbefaling 60)</span>` : ` (${sTitle.length} tegn)`;
|
||||
const fresh = FRESHNESS[nn];
|
||||
const blocks = markdownToBlocks(body);
|
||||
const subtitle = meta.subtitle ? `<p><strong>${inline(meta.subtitle)}</strong></p>` : "";
|
||||
const copyZone = [subtitle, ...blocks].filter(Boolean).join("\n ");
|
||||
const shareField = share ? `${share.share}\n\n${share.hashtags}` : "—";
|
||||
|
||||
const carouselBlock = CAROUSEL.has(nn)
|
||||
? `<div class="fld"><h2>6 · Carousel (valgfritt rekkevidde-tillegg)</h2>
|
||||
<div class="val">Egen dokument-post, helst egen dag: last opp <code>linkedin/${nn}/carousel.pdf</code>.
|
||||
Caption = delingstekstens premiss-linje.</div></div>`
|
||||
: "";
|
||||
|
||||
const inner = `
|
||||
<h1 class="sheet">Del ${nn} — ${esc(meta.title || "")}</h1>
|
||||
<p class="when">📅 ${cal.dag} · kl. ${cal.klokke} (Schedule post → CEST)</p>
|
||||
${fresh ? `<div class="fresh"><strong>⚠️ Ferskvare før planlegging:</strong> ${esc(fresh)}</div>` : ""}
|
||||
|
||||
<div class="fld">
|
||||
<h2>1 · Felter (Settings i editoren)</h2>
|
||||
<div class="label">Tittel (${(meta.title || "").length} tegn)</div>
|
||||
<div class="val">${esc(meta.title || "")}</div>
|
||||
<div class="label">SEO-tittel${seoWarn}</div>
|
||||
<div class="val">${esc(sTitle)}</div>
|
||||
<div class="label">SEO-beskrivelse (${sDesc.length} tegn — mål 140–160)</div>
|
||||
<div class="val">${esc(sDesc)}</div>
|
||||
<div class="label">Lesetid / Serie</div>
|
||||
<div class="val">${esc(meta.lesetid || "—")} · ${esc(meta.serie || "—")}</div>
|
||||
</div>
|
||||
|
||||
<div class="fld">
|
||||
<h2>2 · Cover (1920×1080)</h2>
|
||||
<div class="label">Fil</div><div class="val"><code>linkedin/${nn}/cover.png</code></div>
|
||||
<div class="label">Credit (Add credit and caption)</div><div class="val">${esc(COVER_CREDIT)}</div>
|
||||
<div class="label">Caption</div><div class="val">${esc(CAPTIONS[nn] || "—")}</div>
|
||||
</div>
|
||||
|
||||
<div class="fld">
|
||||
<h2>3 · «Tell your network…»-delingstekst (lim i feltet over kortet)</h2>
|
||||
<div class="copybox">${esc(shareField)}</div>
|
||||
</div>
|
||||
|
||||
<div class="fld">
|
||||
<h2>4 · Første kommentar (legg når posten er live)</h2>
|
||||
<div class="copybox">${esc(share ? share.kommentar : "—")}</div>
|
||||
</div>
|
||||
|
||||
${carouselBlock}
|
||||
|
||||
<div class="marker">⬇︎ ${CAROUSEL.has(nn) ? "7" : "6"} · BRØDTEKST — merk alt herfra, kopier (⌘C), lim i editoren ⬇︎</div>
|
||||
<div class="copyzone">
|
||||
${copyZone}
|
||||
</div>
|
||||
<div class="marker">⬆︎ Til hit ⬆︎ (sjekk at overskrifter/lister/fet overlevde liminga)</div>`;
|
||||
|
||||
return shell(`Del ${nn} · ${meta.title || ""}`, inner);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Samle-POST.html (frittstående native post)
|
||||
// ---------------------------------------------------------------------------
|
||||
function samlePost(share) {
|
||||
const cal = CALENDAR.samle;
|
||||
const shareField = share ? `${share.share}\n\n${share.hashtags}` : "—";
|
||||
const inner = `
|
||||
<h1 class="sheet">Samle-post — oversikt over hele serien</h1>
|
||||
<p class="when">📅 ${cal.dag} · kl. ${cal.klokke} (Schedule post → CEST)</p>
|
||||
<div class="fresh"><strong>Type:</strong> Frittstående native feed-post (ikke en edition).
|
||||
Lenken til serien legges i FØRSTE KOMMENTAR.</div>
|
||||
|
||||
<div class="marker">⬇︎ 1 · POST-TEKST — merk alt herfra, kopier, lim i en ny LinkedIn-post ⬇︎</div>
|
||||
<div class="copyzone"><div class="copybox" style="border:none;padding:0">${esc(shareField)}</div></div>
|
||||
<div class="marker">⬆︎ Til hit ⬆︎</div>
|
||||
|
||||
<div class="fld" style="margin-top:24px">
|
||||
<h2>2 · Første kommentar (legg når posten er live)</h2>
|
||||
<div class="copybox">${esc(share ? share.kommentar : "—")}</div>
|
||||
<div class="label" style="margin-top:10px">[LENKE] = index/kanonisk hjem (fromaitochitta.com hvis live) ELLER Del 1-editionen som inngang. Velg det som faktisk er publisert.</div>
|
||||
</div>`;
|
||||
return shell("Samle-post · Maskinrommet", inner);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
const files = process.argv.slice(2);
|
||||
const shareMap = parseDelingstekst();
|
||||
|
||||
for (const f of files) {
|
||||
const abs = path.isAbsolute(f) ? f : path.join(process.cwd(), f);
|
||||
const raw = fs.readFileSync(abs, "utf8");
|
||||
const { meta, body } = parseFrontMatter(raw);
|
||||
const base = path.basename(abs).replace(/\.md$/, "");
|
||||
const nn = (base.match(/^(\d{2})/) || [, base])[1];
|
||||
if (!/^\d{2}$/.test(nn)) { console.warn(`↷ hopper over ${f} (ikke NN-prefiks)`); continue; }
|
||||
const dir = path.join(OUT_ROOT, nn);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, "POST.html"), editionPost(nn, meta, body, shareMap[nn]));
|
||||
console.log(`✓ linkedin/${nn}/POST.html (${meta.title || base})`);
|
||||
}
|
||||
|
||||
// Samle bygges alltid (innhold er uavhengig av utkast-filene)
|
||||
if (shareMap.samle) {
|
||||
const dir = path.join(OUT_ROOT, "samle");
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, "POST.html"), samlePost(shareMap.samle));
|
||||
console.log("✓ linkedin/samle/POST.html (samle-post)");
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue