feat(linkedin-studio): N2 — build-figur renderer (SVG/HTML->PNG, 3 mål, token-seam) [skip-docs]

TDD (33 nye tester, render-suite 20->53/0 = ny floor, 4 ekte e2e-renders):
- render/build-figur.mjs: SVG/HTML -> PNG via headless Chrome; tre mål
  (article 1200xauto, carousel 1080x1350, single 1200x1200), overstyrbare
  via --width/--height; CLI + importerbar modul.
- Token-seam: design-tokens leses fra
  LINKEDIN_STUDIO_DATA/profile/brand-tokens.json (brukerdata), nøytrale
  defaults i modulen — merkevare aldri hardkodet i repoet. Injiseres som
  CSS-variabler (--figur-*).
- Warn-validering (aldri hard fail): min 24pt tekst, fargebudsjett
  (maks 2 ikke-nøytrale farger).
- Robust Chrome-oppdagelse: app-bundle-stier -> PATH -> install-hint
  (prober injiserbare for test).
- macOS-quirk (verifisert lokalt, Chrome 150): headless skriver PNG-en men
  avslutter aldri -> stderr-watchdog («written to file») + kill + verifisering
  av output. Søk-først utført (kjent headless-ustabilitet på macOS).
- Fixture: nøytral demo-SVG (placeholder-søylediagram) under
  render/__tests__/fixtures/.

Alle seks suiter grønne: test-runner 138/0, trends 245/0, brain 134/0,
hooks 140/0, tests 35/0, render 53/0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: 26f26c3d-be91-47c4-bbdf-4694ba063b67
This commit is contained in:
Kjell Tore Guttormsen 2026-07-16 20:26:19 +02:00
commit c4434ed789
3 changed files with 669 additions and 0 deletions

365
render/build-figur.mjs Normal file
View file

@ -0,0 +1,365 @@
#!/usr/bin/env node
// build-figur.mjs — render en kodet figur (SVG/HTML) til PNG via headless Chrome.
// Bruk: node build-figur.mjs <figur.svg|figur.html> [--target article|carousel|single]
// [--out <fil.png>] [--width N] [--height N]
// Tre render-mål: article (~1200px bred, innholdsstyrt høyde) · carousel (1080×1350)
// · single (1200×1200). Design-tokens leses fra brukerens data-dir
// (${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/profile/brand-tokens.json)
// og injiseres som CSS-variabler (--figur-*); uten fil brukes nøytrale defaults —
// brukerens merkevare er DATA, aldri hardkodet her. Designregler håndheves som
// warn-validering (aldri hard fail). Ingen nettverkstilgang under render (lokal
// fil-input; Chrome kjøres med nettverksdempende flagg). CLI + importerbar modul
// (samme dobbeltrolle som hooks/scripts/ical-generator.mjs). Ingen npm-avhengigheter.
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { execFileSync, spawn } from "node:child_process";
// ---------------------------------------------------------------------------
// Render-mål. height:null = innholdsstyrt (avledes av kildens aspekt, ellers
// fallback). Overstyrbare via --width/--height.
// ---------------------------------------------------------------------------
export const TARGETS = {
article: { width: 1200, height: null },
carousel: { width: 1080, height: 1350 },
single: { width: 1200, height: 1200 },
};
const ARTICLE_FALLBACK_ASPECT = 16 / 9;
export function deriveDimensions(target, aspect, overrides = {}) {
const t = TARGETS[target];
if (!t) {
throw new Error(`Ukjent mål «${target}». Gyldige mål: ${Object.keys(TARGETS).join(", ")}`);
}
const warnings = [];
const width = overrides.width ?? t.width;
let height = overrides.height ?? t.height;
if (height == null) {
if (aspect) {
height = Math.round(width / aspect);
} else {
height = Math.round(width / ARTICLE_FALLBACK_ASPECT);
warnings.push(
`Kilden oppgir ingen dimensjoner — bruker fallback-høyde ${height}px (overstyr med --height).`
);
}
}
return { width, height, warnings };
}
// ---------------------------------------------------------------------------
// Design-tokens: BRUKERDATA fra data-dir, nøytrale defaults ellers.
// ---------------------------------------------------------------------------
export const DEFAULT_TOKENS = {
background: "#FFFFFF",
ink: "#1A1A1A",
muted: "#5B5B5B",
accent: "#2F6F9F",
rule: "#D9D9D9",
fontFamily: "system-ui, -apple-system, 'Helvetica Neue', Arial, sans-serif",
};
function defaultDataDir() {
return process.env.LINKEDIN_STUDIO_DATA || path.join(os.homedir(), ".claude", "linkedin-studio");
}
export function loadBrandTokens(dataDir = defaultDataDir()) {
const file = path.join(dataDir, "profile", "brand-tokens.json");
const warnings = [];
if (!fs.existsSync(file)) {
return { tokens: { ...DEFAULT_TOKENS }, source: "default", warnings };
}
try {
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("forventet et JSON-objekt");
}
return { tokens: { ...DEFAULT_TOKENS, ...parsed }, source: "user", warnings };
} catch (err) {
warnings.push(`Kunne ikke lese brand-tokens.json (${err.message}) — bruker nøytrale defaults.`);
return { tokens: { ...DEFAULT_TOKENS }, source: "default", warnings };
}
}
export function tokensToCss(tokens) {
const vars = Object.entries(tokens)
.map(([key, value]) => `--figur-${key}:${value};`)
.join("");
return `:root{${vars}}`;
}
// ---------------------------------------------------------------------------
// Kilde → render-klar HTML. SVG pakkes i en side med eksakt mål-flate;
// HTML får token-variablene injisert (etter <head>, ellers prependet).
// ---------------------------------------------------------------------------
export function wrapSvg(svgContent, { width, height }, tokens) {
return `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><style>
${tokensToCss(tokens)}
*{margin:0;padding:0;box-sizing:border-box;}
html,body{width: ${width}px;height: ${height}px;background:var(--figur-background);overflow:hidden;}
svg{display:block;width:100%;height:100%;}
</style></head>
<body>
${svgContent}
</body>
</html>
`;
}
export function injectTokens(html, tokens) {
const style = `<style>${tokensToCss(tokens)}</style>`;
if (/<head[^>]*>/i.test(html)) {
return html.replace(/<head[^>]*>/i, (m) => `${m}${style}`);
}
return `${style}\n${html}`;
}
// Aspekt (bredde/høyde) fra SVG-kilden: viewBox først, ellers width/height-attributter.
export function parseSvgMeta(svgContent) {
const vb = svgContent.match(/viewBox\s*=\s*["']\s*[\d.+-]+[\s,]+[\d.+-]+[\s,]+([\d.]+)[\s,]+([\d.]+)\s*["']/);
if (vb) {
const w = parseFloat(vb[1]);
const h = parseFloat(vb[2]);
if (w > 0 && h > 0) return w / h;
}
const wm = svgContent.match(/<svg[^>]*\bwidth\s*=\s*["']([\d.]+)(?:px)?["']/);
const hm = svgContent.match(/<svg[^>]*\bheight\s*=\s*["']([\d.]+)(?:px)?["']/);
if (wm && hm) {
const w = parseFloat(wm[1]);
const h = parseFloat(hm[1]);
if (w > 0 && h > 0) return w / h;
}
return null;
}
// ---------------------------------------------------------------------------
// Warn-validering av designregler (aldri hard fail):
// - minst 24pt/px effektiv tekststørrelse
// - maks 1 primær + 1 sekundærfarge utover nøytraler (gråtoner/hvit/sort)
// (≥40 % whitespace er redaksjonell regel — ikke maskinelt målbar her.)
// ---------------------------------------------------------------------------
const MIN_FONT_SIZE = 24;
const MAX_NON_NEUTRAL_COLORS = 2;
function isNeutralHex(hex6) {
const r = parseInt(hex6.slice(0, 2), 16);
const g = parseInt(hex6.slice(2, 4), 16);
const b = parseInt(hex6.slice(4, 6), 16);
const spread = Math.max(r, g, b) - Math.min(r, g, b);
return spread <= 18;
}
export function validateFigure(source) {
const warnings = [];
const smallSizes = [];
const sizeRe = /font-size\s*[:=]\s*["']?\s*([\d.]+)\s*(px|pt)?/gi;
let m;
while ((m = sizeRe.exec(source)) !== null) {
const size = parseFloat(m[1]);
if (size < MIN_FONT_SIZE) smallSizes.push(size);
}
if (smallSizes.length) {
warnings.push(
`Tekststørrelse under ${MIN_FONT_SIZE}pt funnet (${smallSizes.join(", ")}) — hev for lesbarhet på mobil.`
);
}
const nonNeutral = new Set();
const colorRe = /#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})\b/g;
while ((m = colorRe.exec(source)) !== null) {
let hex = m[1].toLowerCase();
if (hex.length === 3) hex = hex.split("").map((c) => c + c).join("");
if (!isNeutralHex(hex)) nonNeutral.add(hex);
}
if (nonNeutral.size > MAX_NON_NEUTRAL_COLORS) {
warnings.push(
`Fargebudsjett: ${nonNeutral.size} ikke-nøytrale farger (maks 1 primær + 1 sekundær utover nøytraler).`
);
}
return warnings;
}
// ---------------------------------------------------------------------------
// Robust Chrome-oppdagelse: app-bundle-stier → PATH → hint. Prober er
// injiserbare for test.
// ---------------------------------------------------------------------------
const CHROME_APP_CANDIDATES = [
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
path.join(os.homedir(), "Applications", "Google Chrome.app", "Contents", "MacOS", "Google Chrome"),
"/Applications/Chromium.app/Contents/MacOS/Chromium",
];
const CHROME_PATH_NAMES = ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "chrome"];
const CHROME_HINT =
"Fant ingen Chrome/Chromium-binær — headless-render krever en.\n" +
` Lette etter: ${CHROME_APP_CANDIDATES.join(" · ")} + PATH (${CHROME_PATH_NAMES.join(", ")})\n` +
" Installer Google Chrome (https://www.google.com/chrome/) eller `brew install chromium`.";
function defaultWhich(cmd) {
try {
const out = execFileSync("/usr/bin/which", [cmd], { encoding: "utf8" }).trim();
return out || null;
} catch {
return null;
}
}
export function resolveChrome(probes = {}) {
const exists = probes.exists || fs.existsSync;
const which = probes.which || defaultWhich;
for (const candidate of CHROME_APP_CANDIDATES) {
if (exists(candidate)) return { available: true, path: candidate };
}
for (const name of CHROME_PATH_NAMES) {
const found = which(name);
if (found) return { available: true, path: found };
}
return { available: false, hint: CHROME_HINT };
}
// ---------------------------------------------------------------------------
// Render: kilde → wrapper-HTML i tmp → headless Chrome-screenshot → PNG.
// Kjent macOS-quirk (verifisert lokalt mot Chrome 150): headless skriver PNG-en
// og melder «N bytes written to file …» på stderr, men prosessen avslutter
// aldri. Watchdog: vi lytter på stderr-kvitteringen (evt. ren exit på andre
// plattformer), dreper prosessen og verifiserer at PNG-filen faktisk finnes.
// ---------------------------------------------------------------------------
const RENDER_TIMEOUT_MS = 30000;
function runChromeScreenshot(chromePath, args, timeoutMs = RENDER_TIMEOUT_MS) {
return new Promise((resolve) => {
const child = spawn(chromePath, args, { stdio: ["ignore", "ignore", "pipe"] });
let stderr = "";
let settled = false;
const finish = (reason) => {
if (settled) return;
settled = true;
clearTimeout(timer);
child.kill("SIGKILL");
resolve({ reason, stderr });
};
const timer = setTimeout(() => finish("timeout"), timeoutMs);
child.stderr.on("data", (chunk) => {
stderr += chunk;
if (stderr.includes("written to file")) finish("written");
});
child.on("exit", () => finish("exit"));
child.on("error", () => finish("spawn-error"));
});
}
export async function renderFigure(opts) {
const { input, target = "article", out = null, dataDir, chrome = null } = opts;
const inPath = path.isAbsolute(input) ? input : path.join(process.cwd(), input);
if (!fs.existsSync(inPath)) throw new Error(`Fant ikke input-fil: ${inPath}`);
const ext = path.extname(inPath).toLowerCase();
if (![".svg", ".html", ".htm"].includes(ext)) {
throw new Error(`Ustøttet input-type «${ext}» — bruk .svg eller .html.`);
}
const source = fs.readFileSync(inPath, "utf8");
const { tokens, source: tokensSource, warnings } = loadBrandTokens(dataDir);
warnings.push(...validateFigure(source));
const aspect = ext === ".svg" ? parseSvgMeta(source) : null;
const dims = deriveDimensions(target, aspect, { width: opts.width ?? null, height: opts.height ?? null });
warnings.push(...dims.warnings);
const pageHtml = ext === ".svg" ? wrapSvg(source, dims, tokens) : injectTokens(source, tokens);
const resolved = chrome || resolveChrome();
if (!resolved.available) throw new Error(resolved.hint);
const outPath = out
? (path.isAbsolute(out) ? out : path.join(process.cwd(), out))
: path.join(path.dirname(inPath), `${path.basename(inPath, ext)}-${target}.png`);
const workDir = fs.mkdtempSync(path.join(os.tmpdir(), "build-figur-"));
try {
const htmlPath = path.join(workDir, "figur.html");
fs.writeFileSync(htmlPath, pageHtml, "utf8");
const run = await runChromeScreenshot(resolved.path, [
"--headless",
"--disable-gpu",
"--hide-scrollbars",
"--force-device-scale-factor=1",
"--no-first-run",
"--no-default-browser-check",
"--disable-extensions",
"--disable-sync",
"--disable-background-networking",
"--disable-component-update",
"--use-mock-keychain",
"--password-store=basic",
`--user-data-dir=${path.join(workDir, "profile")}`,
`--window-size=${dims.width},${dims.height}`,
`--screenshot=${outPath}`,
`file://${htmlPath}`,
]);
if (!fs.existsSync(outPath) || fs.statSync(outPath).size === 0) {
const tail = run.stderr.split("\n").filter(Boolean).slice(-5).join("\n");
throw new Error(`Chrome skrev ikke ${outPath} (${run.reason}).\n${tail}`);
}
} finally {
fs.rmSync(workDir, { recursive: true, force: true });
}
return { out: outPath, width: dims.width, height: dims.height, warnings, tokensSource };
}
// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
const USAGE =
"Bruk: node build-figur.mjs <figur.svg|figur.html> [--target article|carousel|single] [--out <fil.png>] [--width N] [--height N]";
export function parseArgs(argv) {
const cfg = { input: null, target: "article", out: null, width: null, height: null };
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === "--target") cfg.target = argv[++i];
else if (arg === "--out") cfg.out = argv[++i];
else if (arg === "--width") cfg.width = parseInt(argv[++i], 10);
else if (arg === "--height") cfg.height = parseInt(argv[++i], 10);
else if (arg.startsWith("--")) return { error: `Ukjent flagg «${arg}». ${USAGE}` };
else if (!cfg.input) cfg.input = arg;
else return { error: `Uventet argument «${arg}». ${USAGE}` };
}
if (!cfg.input) return { error: USAGE };
if (!TARGETS[cfg.target]) {
return { error: `Ukjent mål «${cfg.target}». Gyldige mål: ${Object.keys(TARGETS).join(", ")}` };
}
if ((cfg.width !== null && !Number.isFinite(cfg.width)) || (cfg.height !== null && !Number.isFinite(cfg.height))) {
return { error: `--width/--height må være heltall. ${USAGE}` };
}
return cfg;
}
async function main() {
const cfg = parseArgs(process.argv.slice(2));
if (cfg.error) {
console.error(cfg.error);
process.exit(1);
}
try {
const result = await renderFigure(cfg);
for (const w of result.warnings) console.warn(`${w}`);
console.log(
`Figur: ${result.out} (${result.width}×${result.height}, mål: ${cfg.target}, tokens: ${result.tokensSource})`
);
} catch (err) {
console.error(err.message);
process.exit(1);
}
}
if (import.meta.url === `file://${process.argv[1]}`) {
main();
}