refactor(linkedin)!: rename plugin linkedin-thought-leadership → linkedin-studio (v3.0.0)

BREAKING CHANGE: the marketplace slug, the agent namespace
(linkedin-studio:<agent>), and the runtime state-file path
(~/.claude/linkedin-studio.local.md) all change. Reinstall required;
existing state migrated in place (post metrics, streak, history preserved).
The /linkedin:* commands are unchanged — the command namespace is set
per-command in frontmatter and was always independent of the plugin slug.
Functionality is byte-identical to v2.4.0; this release is pure identity.

- dir + manifests: plugins/linkedin-studio + plugin.json + root marketplace.json
- agent namespace updated in commands/newsletter.md (only functional invoker)
- state path updated in 4 hook scripts + topic-rotation prompt + state template
- catch-all skill dir renamed skills/linkedin-studio (5 functional skills unchanged)
- docs + version bump to 3.0.0 across README badge, CHANGELOG, root README/CLAUDE.md
- historical records (CHANGELOG past entries, docs/ build artifacts,
  config-audit v5.0.0 snapshots) intentionally retain the old slug

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-05-29 11:32:02 +02:00
commit b6bb61246b
196 changed files with 164 additions and 138 deletions

View file

@ -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>/);
});
});

View file

@ -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");
});
});

View file

@ -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."
}
}

View file

@ -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);
});
});
}