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
91
plugins/linkedin-studio/render/OFL.txt
Normal file
91
plugins/linkedin-studio/render/OFL.txt
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
Copyright (c) The Inter Project Authors (https://github.com/rsms/inter)
|
||||
Copyright (c) The Newsreader Project Authors (https://github.com/productiontype/Newsreader)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply to any
|
||||
document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may include
|
||||
source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical writer or
|
||||
other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining a
|
||||
copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components, in
|
||||
Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or in
|
||||
the appropriate machine-readable metadata fields within text or binary
|
||||
files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name
|
||||
as presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any Modified
|
||||
Version, except to acknowledge the contribution(s) of the Copyright
|
||||
Holder(s) and the Author(s) or with their explicit written permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole, must be
|
||||
distributed entirely under this license, and must not be distributed under
|
||||
any other license. The requirement for fonts to remain under this license
|
||||
does not apply to any document created using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are not
|
||||
met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF
|
||||
COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS
|
||||
IN THE FONT SOFTWARE.
|
||||
65
plugins/linkedin-studio/render/__tests__/build-html.test.mjs
Normal file
65
plugins/linkedin-studio/render/__tests__/build-html.test.mjs
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { markdownToHtml, inline } from '../build-html.mjs';
|
||||
|
||||
describe('markdownToHtml — tables (beslutning H)', () => {
|
||||
test('converts a pipe table to <table>/<tr>/<td>', () => {
|
||||
const md = [
|
||||
'| Plattform | Pris |',
|
||||
'| --- | --- |',
|
||||
'| Azure | Høy |',
|
||||
'| Foundry | Lav |',
|
||||
].join('\n');
|
||||
const html = markdownToHtml(md);
|
||||
assert.match(html, /<table>/);
|
||||
assert.match(html, /<tr>/);
|
||||
assert.match(html, /<td>/);
|
||||
// header cells render as <th>, body values as <td>
|
||||
assert.match(html, /<th>Plattform<\/th>/);
|
||||
assert.match(html, /<td>Azure<\/td>/);
|
||||
// the separator row must NOT become a data row
|
||||
assert.doesNotMatch(html, /<td>---<\/td>/);
|
||||
});
|
||||
|
||||
test('empty input produces no table', () => {
|
||||
assert.doesNotMatch(markdownToHtml(''), /<table>/);
|
||||
});
|
||||
|
||||
test('tolerates a malformed row (uneven cell count) without throwing', () => {
|
||||
const md = [
|
||||
'| a | b |',
|
||||
'| --- | --- |',
|
||||
'| only-one-cell |',
|
||||
'| x | y |',
|
||||
].join('\n');
|
||||
let html;
|
||||
assert.doesNotThrow(() => { html = markdownToHtml(md); });
|
||||
assert.match(html, /<table>/);
|
||||
assert.match(html, /<td>x<\/td>/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('markdownToHtml — heading levels # … #### (beslutning H)', () => {
|
||||
test('# renders <h1>', () => {
|
||||
assert.match(markdownToHtml('# Tittel'), /<h1>Tittel<\/h1>/);
|
||||
});
|
||||
test('## renders <h2>', () => {
|
||||
assert.match(markdownToHtml('## Seksjon'), /<h2>Seksjon<\/h2>/);
|
||||
});
|
||||
test('### renders <h3>', () => {
|
||||
assert.match(markdownToHtml('### Under'), /<h3>Under<\/h3>/);
|
||||
});
|
||||
test('#### renders <h4>', () => {
|
||||
assert.match(markdownToHtml('#### Detalj'), /<h4>Detalj<\/h4>/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('inline — backtick code span (beslutning H)', () => {
|
||||
test('`code` renders <code>', () => {
|
||||
assert.match(inline('bruk `npm install` her'), /<code>npm install<\/code>/);
|
||||
});
|
||||
test('still handles **bold** and *italic*', () => {
|
||||
assert.match(inline('**fet** og *kursiv*'), /<strong>fet<\/strong>/);
|
||||
assert.match(inline('**fet** og *kursiv*'), /<em>kursiv<\/em>/);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
// build-linkedin.test.mjs — S2: edition-config.json generalization.
|
||||
// Verifies the fasit assumption (5): changing values in the config changes
|
||||
// POST.html output with NO code change. Regression: a config matching the old
|
||||
// hardcoded Seres values reproduces the baseline strings.
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { loadEditionConfig, editionPost, samlePost } from "../build-linkedin.mjs";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const fixturesDir = join(__dirname, "fixtures");
|
||||
|
||||
const meta = {
|
||||
title: "Testtittel",
|
||||
subtitle: "Undertittel",
|
||||
lesetid: "5 min",
|
||||
serie: "Test",
|
||||
};
|
||||
const body = "## Overskrift\n\nEn paragraf med tekst.";
|
||||
const share = { share: "Delingstekst", hashtags: "#test", kommentar: "Kommentar" };
|
||||
|
||||
describe("build-linkedin edition-config", () => {
|
||||
it("loadEditionConfig reads the Seres regression fixture", () => {
|
||||
const cfg = loadEditionConfig(fixturesDir);
|
||||
assert.equal(cfg.calendar["01"].dag, "Tirsdag 26.05.2026");
|
||||
assert.equal(
|
||||
cfg.coverCredit,
|
||||
"Illustrasjon generert med Google Gemini (Nano Banana Pro)"
|
||||
);
|
||||
assert.ok(cfg.captions["06"].startsWith("Tolv grep"));
|
||||
});
|
||||
|
||||
it("regression: Seres config reproduces baseline strings in POST.html", () => {
|
||||
const cfg = loadEditionConfig(fixturesDir);
|
||||
const html = editionPost("01", meta, body, share, cfg);
|
||||
assert.ok(html.includes("Tirsdag 26.05.2026"), "calendar date present");
|
||||
assert.ok(html.includes("Noen lover vekst"), "caption 01 present");
|
||||
assert.ok(
|
||||
html.includes("Illustrasjon generert med Google Gemini"),
|
||||
"cover credit present"
|
||||
);
|
||||
assert.ok(html.includes("OpenAI-verdsettelse"), "freshness 01 present");
|
||||
});
|
||||
|
||||
it("changing config values changes POST.html output (no code change)", () => {
|
||||
const cfg = loadEditionConfig(fixturesDir);
|
||||
const html1 = editionPost("01", meta, body, share, cfg);
|
||||
|
||||
const cfg2 = JSON.parse(JSON.stringify(cfg));
|
||||
cfg2.captions["01"] = "EN HELT ANNEN CAPTION";
|
||||
cfg2.calendar["01"].dag = "Mandag 01.01.2030";
|
||||
const html2 = editionPost("01", meta, body, share, cfg2);
|
||||
|
||||
assert.notEqual(html1, html2, "different config → different output");
|
||||
assert.ok(html2.includes("EN HELT ANNEN CAPTION"), "new caption present");
|
||||
assert.ok(html2.includes("Mandag 01.01.2030"), "new date present");
|
||||
assert.ok(!html2.includes("Noen lover vekst"), "old caption gone");
|
||||
});
|
||||
|
||||
it("missing config degrades gracefully to empty defaults", () => {
|
||||
const cfg = loadEditionConfig(join(fixturesDir, "does-not-exist"));
|
||||
assert.deepEqual(cfg, {
|
||||
calendar: {},
|
||||
freshness: {},
|
||||
coverCredit: "",
|
||||
captions: {},
|
||||
carousel: [],
|
||||
});
|
||||
// editionPost still renders without throwing (uses "—" fallbacks)
|
||||
const html = editionPost("01", meta, body, share, cfg);
|
||||
assert.ok(html.includes("Del 01"), "edition heading still rendered");
|
||||
});
|
||||
|
||||
it("samlePost renders with config calendar and degrades gracefully", () => {
|
||||
const cfg = loadEditionConfig(fixturesDir);
|
||||
const html = samlePost(share, cfg);
|
||||
assert.ok(html.includes("Mandag 01.06.2026"), "samle calendar from config");
|
||||
|
||||
const empty = loadEditionConfig(join(fixturesDir, "nope"));
|
||||
const htmlEmpty = samlePost(share, empty);
|
||||
assert.ok(htmlEmpty.includes("Samle-post"), "renders without throwing");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"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" }
|
||||
},
|
||||
"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."
|
||||
},
|
||||
"coverCredit": "Illustrasjon generert med Google Gemini (Nano Banana Pro)",
|
||||
"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."
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { resolveWeasyprint as resolvePdf } from '../build-pdf.mjs';
|
||||
import { resolveWeasyprint as resolveCarousel } from '../build-carousel.mjs';
|
||||
|
||||
// S1 (correction #3): when weasyprint is not resolvable on PATH, the degradation
|
||||
// helper must return a skip-signal (NOT throw) and emit an install hint, so the
|
||||
// render scripts can skip the PDF step gracefully instead of crashing.
|
||||
|
||||
for (const [name, resolveWeasyprint] of [
|
||||
['build-pdf', resolvePdf],
|
||||
['build-carousel', resolveCarousel],
|
||||
]) {
|
||||
describe(`resolveWeasyprint — ${name}`, () => {
|
||||
test('returns a skip-signal (not a throw) when weasyprint is absent', () => {
|
||||
let result;
|
||||
assert.doesNotThrow(() => {
|
||||
result = resolveWeasyprint(() => false);
|
||||
});
|
||||
assert.equal(result.available, false);
|
||||
});
|
||||
|
||||
test('emits an install hint when absent', () => {
|
||||
const result = resolveWeasyprint(() => false);
|
||||
assert.ok(typeof result.hint === 'string' && result.hint.length > 0);
|
||||
assert.match(result.hint, /weasyprint/i);
|
||||
assert.match(result.hint, /install/i);
|
||||
});
|
||||
|
||||
test('reports available when the probe succeeds', () => {
|
||||
const result = resolveWeasyprint(() => true);
|
||||
assert.equal(result.available, true);
|
||||
assert.equal(result.hint, undefined);
|
||||
});
|
||||
});
|
||||
}
|
||||
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();
|
||||
}
|
||||
1077
plugins/linkedin-studio/render/build-html.mjs
Normal file
1077
plugins/linkedin-studio/render/build-html.mjs
Normal file
File diff suppressed because it is too large
Load diff
388
plugins/linkedin-studio/render/build-linkedin.mjs
Normal file
388
plugins/linkedin-studio/render/build-linkedin.mjs
Normal file
|
|
@ -0,0 +1,388 @@
|
|||
#!/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: linkedin/edition-config.json i serie-mappa
|
||||
// (faller tilbake til tomme standarder hvis fila mangler — 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");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EDITION-KONFIG (HANDOVER §13 / DREIEBOK / image-credit-caption.md)
|
||||
// Per-serie verdier (kalender, ferskvare, cover-credit, captions) leses fra
|
||||
// linkedin/edition-config.json i serie-mappa — ikke lenger hardkodet. Q1: JSON
|
||||
// (deterministisk parsing). Mangler fila, faller vi tilbake til tomme
|
||||
// standarder slik at byggingen degraderer pent i stedet for å kaste.
|
||||
// ---------------------------------------------------------------------------
|
||||
const CONFIG_FILE = path.join(OUT_ROOT, "edition-config.json");
|
||||
|
||||
const EMPTY_CONFIG = { calendar: {}, freshness: {}, coverCredit: "", captions: {}, carousel: [] };
|
||||
|
||||
// Les edition-config.json fra rootDir (serie-mappas linkedin/). Normaliser alle
|
||||
// felt til kjente former; manglende/ugyldig fil → tomme standarder (graceful).
|
||||
export function loadEditionConfig(rootDir = OUT_ROOT) {
|
||||
let cfg;
|
||||
try {
|
||||
cfg = JSON.parse(fs.readFileSync(path.join(rootDir, "edition-config.json"), "utf8"));
|
||||
} catch {
|
||||
return { ...EMPTY_CONFIG };
|
||||
}
|
||||
if (!cfg || typeof cfg !== "object") return { ...EMPTY_CONFIG };
|
||||
return {
|
||||
calendar: cfg.calendar && typeof cfg.calendar === "object" ? cfg.calendar : {},
|
||||
freshness: cfg.freshness && typeof cfg.freshness === "object" ? cfg.freshness : {},
|
||||
coverCredit: typeof cfg.coverCredit === "string" ? cfg.coverCredit : "",
|
||||
captions: cfg.captions && typeof cfg.captions === "object" ? cfg.captions : {},
|
||||
// S14/F6: carousel editions are config-derived, not Seres-hardcoded. A list of
|
||||
// zero-padded NN strings ("03","06"); empty/absent → no carousel block for any
|
||||
// edition. Generalizes away the old `new Set(["03","06"])` Seres assumption.
|
||||
carousel: Array.isArray(cfg.carousel) ? cfg.carousel.map(String) : [],
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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() {
|
||||
// Graceful (S14/F8): missing or unreadable delingstekst → no distribution copy.
|
||||
// Matches loadEditionConfig's fail-soft contract — the article POST.html still
|
||||
// builds; only the share text is absent. Previously this threw ENOENT before any
|
||||
// POST.html was written, killing the whole build incl. article posts.
|
||||
let raw;
|
||||
try {
|
||||
raw = fs.readFileSync(DELINGSTEKST_FILE, "utf8").replace(/\r\n/g, "\n");
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
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)
|
||||
// ---------------------------------------------------------------------------
|
||||
export function editionPost(nn, meta, body, share, config = EMPTY_CONFIG) {
|
||||
const cal = config.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 = config.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 = (config.carousel || []).includes(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(config.coverCredit || "")}</div>
|
||||
<div class="label">Caption</div><div class="val">${esc(config.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">⬇︎ ${(config.carousel || []).includes(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)
|
||||
// ---------------------------------------------------------------------------
|
||||
export function samlePost(share, config = EMPTY_CONFIG) {
|
||||
const cal = config.calendar.samle || { dag: "—", klokke: "08:00" };
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
export function main(files = process.argv.slice(2)) {
|
||||
const config = loadEditionConfig(OUT_ROOT);
|
||||
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], config));
|
||||
console.log(`✓ linkedin/${nn}/POST.html (${meta.title || base})`);
|
||||
}
|
||||
|
||||
// Samle bygges KUN når delingsteksten deklarerer en «## Samle»-seksjon (S14/F6:
|
||||
// tidligere kommentar sa «alltid», men bygget har alltid vært betinget av
|
||||
// shareMap.samle — innholdet er uavhengig av utkast-filene, men ikke av delingstekst).
|
||||
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, config));
|
||||
console.log("✓ linkedin/samle/POST.html (samle-post)");
|
||||
}
|
||||
}
|
||||
|
||||
// CLI-guard (S2 korreksjon #4): kjør main kun når scriptet kjøres direkte,
|
||||
// slik at modulen kan importeres i tester uten side-effekter.
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
main();
|
||||
}
|
||||
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();
|
||||
}
|
||||
BIN
plugins/linkedin-studio/render/fonts/Inter-400.ttf
Normal file
BIN
plugins/linkedin-studio/render/fonts/Inter-400.ttf
Normal file
Binary file not shown.
BIN
plugins/linkedin-studio/render/fonts/Inter-600.ttf
Normal file
BIN
plugins/linkedin-studio/render/fonts/Inter-600.ttf
Normal file
Binary file not shown.
BIN
plugins/linkedin-studio/render/fonts/Inter-700.ttf
Normal file
BIN
plugins/linkedin-studio/render/fonts/Inter-700.ttf
Normal file
Binary file not shown.
BIN
plugins/linkedin-studio/render/fonts/Newsreader-400.ttf
Normal file
BIN
plugins/linkedin-studio/render/fonts/Newsreader-400.ttf
Normal file
Binary file not shown.
BIN
plugins/linkedin-studio/render/fonts/Newsreader-400i.ttf
Normal file
BIN
plugins/linkedin-studio/render/fonts/Newsreader-400i.ttf
Normal file
Binary file not shown.
BIN
plugins/linkedin-studio/render/fonts/Newsreader-600.ttf
Normal file
BIN
plugins/linkedin-studio/render/fonts/Newsreader-600.ttf
Normal file
Binary file not shown.
BIN
plugins/linkedin-studio/render/fonts/Newsreader-600i.ttf
Normal file
BIN
plugins/linkedin-studio/render/fonts/Newsreader-600i.ttf
Normal file
Binary file not shown.
BIN
plugins/linkedin-studio/render/fonts/Newsreader-700.ttf
Normal file
BIN
plugins/linkedin-studio/render/fonts/Newsreader-700.ttf
Normal file
Binary file not shown.
Loading…
Add table
Add a link
Reference in a new issue