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
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue