linkedin-studio/render/build-figur.mjs
Kjell Tore Guttormsen c4434ed789 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
2026-07-16 20:26:19 +02:00

365 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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();
}