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

View file

@ -0,0 +1,284 @@
import { describe, test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { execFileSync } from 'node:child_process';
import {
TARGETS,
DEFAULT_TOKENS,
deriveDimensions,
loadBrandTokens,
tokensToCss,
wrapSvg,
injectTokens,
parseSvgMeta,
validateFigure,
resolveChrome,
parseArgs,
renderFigure,
} from '../build-figur.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const FIXTURE_SVG = path.join(__dirname, 'fixtures', 'demo-figur.svg');
function tmpdir() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'build-figur-test-'));
}
// Leser bredde/høyde fra PNG-IHDR (byte 1623, big-endian).
function pngDimensions(file) {
const buf = fs.readFileSync(file);
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
}
describe('TARGETS + deriveDimensions', () => {
test('carousel er 1080×1350 (fast)', () => {
assert.equal(TARGETS.carousel.width, 1080);
assert.equal(TARGETS.carousel.height, 1350);
});
test('article er 1200 bred med innholdsstyrt høyde', () => {
assert.equal(TARGETS.article.width, 1200);
assert.equal(TARGETS.article.height, null);
});
test('single er kvadratisk 1200×1200', () => {
assert.equal(TARGETS.single.width, 1200);
assert.equal(TARGETS.single.height, 1200);
});
test('article + aspekt 16:9 gir avledet høyde 675', () => {
const d = deriveDimensions('article', 1200 / 675, {});
assert.equal(d.width, 1200);
assert.equal(d.height, 675);
assert.equal(d.warnings.length, 0);
});
test('article uten aspekt gir fallback-høyde med warning', () => {
const d = deriveDimensions('article', null, {});
assert.equal(d.width, 1200);
assert.ok(d.height > 0);
assert.ok(d.warnings.some((w) => /høyde/i.test(w)));
});
test('--width/--height-overstyringer vinner over målet', () => {
const d = deriveDimensions('carousel', null, { width: 800, height: 600 });
assert.equal(d.width, 800);
assert.equal(d.height, 600);
});
test('ukjent mål kaster med gyldige mål i meldingen', () => {
assert.throws(() => deriveDimensions('poster', null, {}), /article/);
});
});
describe('loadBrandTokens — token-seam (brukerdata, aldri hardkodet)', () => {
test('uten brand-tokens.json: nøytrale defaults, source=default', () => {
const dir = tmpdir();
const r = loadBrandTokens(dir);
assert.equal(r.source, 'default');
assert.deepEqual(r.tokens, DEFAULT_TOKENS);
assert.equal(r.warnings.length, 0);
});
test('gyldig fil merges over defaults, source=user', () => {
const dir = tmpdir();
fs.mkdirSync(path.join(dir, 'profile'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'profile', 'brand-tokens.json'),
JSON.stringify({ accent: '#123456' }),
'utf8'
);
const r = loadBrandTokens(dir);
assert.equal(r.source, 'user');
assert.equal(r.tokens.accent, '#123456');
// umerkede felter beholder defaults
assert.equal(r.tokens.background, DEFAULT_TOKENS.background);
});
test('ugyldig JSON: defaults + warning, kaster aldri', () => {
const dir = tmpdir();
fs.mkdirSync(path.join(dir, 'profile'), { recursive: true });
fs.writeFileSync(path.join(dir, 'profile', 'brand-tokens.json'), '{not json', 'utf8');
let r;
assert.doesNotThrow(() => { r = loadBrandTokens(dir); });
assert.equal(r.source, 'default');
assert.ok(r.warnings.some((w) => /brand-tokens\.json/.test(w)));
});
});
describe('tokensToCss + wrapSvg + injectTokens — CSS-injeksjon', () => {
test('tokensToCss produserer --figur-variabler for alle tokens', () => {
const css = tokensToCss(DEFAULT_TOKENS);
assert.match(css, /:root\{/);
for (const key of Object.keys(DEFAULT_TOKENS)) {
assert.ok(css.includes(`--figur-${key}`), `mangler --figur-${key}`);
}
});
test('egendefinerte tokenverdier reflekteres i CSS-en', () => {
const css = tokensToCss({ ...DEFAULT_TOKENS, accent: '#ABCDEF' });
assert.match(css, /--figur-accent:\s*#ABCDEF/);
});
test('wrapSvg bygger komplett HTML med svg + tokens + mål-dimensjoner', () => {
const html = wrapSvg('<svg viewBox="0 0 10 10"></svg>', { width: 1080, height: 1350 }, DEFAULT_TOKENS);
assert.match(html, /<!DOCTYPE html>/i);
assert.match(html, /<svg viewBox="0 0 10 10">/);
assert.match(html, /--figur-accent/);
assert.match(html, /width:\s*1080px/);
assert.match(html, /height:\s*1350px/);
});
test('injectTokens legger style rett etter <head> når den finnes', () => {
const out = injectTokens('<html><head><title>x</title></head><body></body></html>', DEFAULT_TOKENS);
assert.match(out, /<head>\s*<style>[^<]*--figur-ink/);
});
test('injectTokens prepender style når <head> mangler', () => {
const out = injectTokens('<p>bare et fragment</p>', DEFAULT_TOKENS);
assert.ok(out.trimStart().startsWith('<style>'));
assert.match(out, /<p>bare et fragment<\/p>/);
});
});
describe('parseSvgMeta — aspekt fra SVG-kilden', () => {
test('viewBox gir aspekt', () => {
const aspect = parseSvgMeta('<svg viewBox="0 0 1200 675"></svg>');
assert.ok(Math.abs(aspect - 1200 / 675) < 1e-9);
});
test('width/height-attributter gir aspekt', () => {
const aspect = parseSvgMeta('<svg width="800px" height="400"></svg>');
assert.equal(aspect, 2);
});
test('uten dimensjonsinfo: null', () => {
assert.equal(parseSvgMeta('<svg><rect/></svg>'), null);
});
});
describe('validateFigure — designregler som warn, aldri hard fail', () => {
test('font-size under 24 gir warning', () => {
const w = validateFigure('<svg><text font-size="14">liten</text></svg>');
assert.ok(w.some((x) => /24/.test(x)));
});
test('font-size ≥ 24 gir ingen tekst-warning', () => {
const w = validateFigure('<svg><text font-size="28">ok</text><text style="font-size: 32px">ok</text></svg>');
assert.ok(!w.some((x) => /24/.test(x)));
});
test('mer enn 2 ikke-nøytrale farger gir fargebudsjett-warning', () => {
const w = validateFigure('<svg><rect fill="#9A3324"/><rect fill="#2F6F9F"/><rect fill="#22AA55"/></svg>');
assert.ok(w.some((x) => /farge/i.test(x)));
});
test('nøytraler (gråtoner/hvit/sort) telles ikke mot fargebudsjettet', () => {
const w = validateFigure(
'<svg><rect fill="#FFFFFF"/><rect fill="#1A1A1A"/><rect fill="#888888"/><rect fill="#9A3324" font-size="30"/></svg>'
);
assert.ok(!w.some((x) => /farge/i.test(x)));
});
});
describe('resolveChrome — robust binær-oppdagelse (mockbar)', () => {
test('finner app-bundle-sti via exists-probe', () => {
const r = resolveChrome({
exists: (p) => p.includes('Google Chrome.app'),
which: () => null,
});
assert.equal(r.available, true);
assert.match(r.path, /Google Chrome/);
});
test('faller tilbake til PATH via which-probe', () => {
const r = resolveChrome({
exists: () => false,
which: (cmd) => (cmd === 'chromium' ? '/usr/local/bin/chromium' : null),
});
assert.equal(r.available, true);
assert.equal(r.path, '/usr/local/bin/chromium');
});
test('ingen kandidater: available=false med install-hint', () => {
const r = resolveChrome({ exists: () => false, which: () => null });
assert.equal(r.available, false);
assert.match(r.hint, /Chrome/);
});
});
describe('parseArgs — CLI-kontrakt', () => {
test('defaults: target=article, out avledes senere (null)', () => {
const r = parseArgs(['fig.svg']);
assert.equal(r.error, undefined);
assert.equal(r.input, 'fig.svg');
assert.equal(r.target, 'article');
assert.equal(r.out, null);
});
test('--target/--out/--width/--height parses (tall som tall)', () => {
const r = parseArgs(['fig.svg', '--target', 'carousel', '--out', '/tmp/x.png', '--width', '900', '--height', '1200']);
assert.equal(r.target, 'carousel');
assert.equal(r.out, '/tmp/x.png');
assert.equal(r.width, 900);
assert.equal(r.height, 1200);
});
test('ugyldig target gir error med gyldige mål', () => {
const r = parseArgs(['fig.svg', '--target', 'poster']);
assert.match(r.error, /article/);
});
test('manglende input gir usage-error', () => {
const r = parseArgs([]);
assert.match(r.error, /[Bb]ruk/);
});
});
// ---------------------------------------------------------------------------
// Ende-til-ende (krever ekte Chrome — skippes CI-vennlig når binær mangler)
// ---------------------------------------------------------------------------
const chrome = resolveChrome();
describe('renderFigure — ende-til-ende via headless Chrome', { skip: !chrome.available && 'Chrome ikke funnet' }, () => {
test('article: fixture-SVG → PNG med 1200px bredde og aspekt-avledet høyde', async () => {
const out = path.join(tmpdir(), 'fig-article.png');
const r = await renderFigure({ input: FIXTURE_SVG, target: 'article', out, dataDir: tmpdir() });
assert.equal(r.out, out);
const dim = pngDimensions(out);
assert.equal(dim.width, 1200);
assert.equal(dim.height, 675); // fixture er 16:9 (viewBox 1200×675)
});
test('carousel: fixture-SVG → PNG 1080×1350', async () => {
const out = path.join(tmpdir(), 'fig-carousel.png');
await renderFigure({ input: FIXTURE_SVG, target: 'carousel', out, dataDir: tmpdir() });
const dim = pngDimensions(out);
assert.equal(dim.width, 1080);
assert.equal(dim.height, 1350);
});
test('render uten brand-tokens.json lykkes med nøytrale defaults', async () => {
const out = path.join(tmpdir(), 'fig-default-tokens.png');
const r = await renderFigure({ input: FIXTURE_SVG, target: 'single', out, dataDir: tmpdir() });
assert.equal(r.tokensSource, 'default');
assert.ok(fs.existsSync(out));
// PNG-magic
const buf = fs.readFileSync(out);
assert.equal(buf.readUInt32BE(0), 0x89504e47);
});
test('CLI: node build-figur.mjs <fixture> --target carousel --out <png>', () => {
const out = path.join(tmpdir(), 'fig-cli.png');
const script = path.join(__dirname, '..', 'build-figur.mjs');
execFileSync(process.execPath, [script, FIXTURE_SVG, '--target', 'carousel', '--out', out], {
stdio: ['ignore', 'pipe', 'pipe'],
});
const dim = pngDimensions(out);
assert.equal(dim.width, 1080);
assert.equal(dim.height, 1350);
});
});

View file

@ -0,0 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 675" role="img" aria-label="Demo: soylediagram med placeholder-data">
<!-- Noytral demo-figur (placeholder-data) for build-figur-testene.
Bruker token-CSS-variabler med noytrale fallbacks. -->
<rect width="1200" height="675" fill="var(--figur-background, #FFFFFF)"/>
<text x="80" y="90" font-family="var(--figur-fontFamily, sans-serif)" font-size="40" font-weight="700" fill="var(--figur-ink, #1A1A1A)">Kvartalsvis utvikling (demo)</text>
<text x="80" y="135" font-family="var(--figur-fontFamily, sans-serif)" font-size="26" fill="var(--figur-muted, #5B5B5B)">Placeholder-data — ikke reelle tall</text>
<g>
<rect x="120" y="380" width="140" height="180" fill="var(--figur-accent, #2F6F9F)"/>
<rect x="360" y="320" width="140" height="240" fill="var(--figur-accent, #2F6F9F)"/>
<rect x="600" y="250" width="140" height="310" fill="var(--figur-accent, #2F6F9F)"/>
<rect x="840" y="180" width="140" height="380" fill="var(--figur-accent, #2F6F9F)"/>
</g>
<line x1="80" y1="560" x2="1120" y2="560" stroke="var(--figur-rule, #D9D9D9)" stroke-width="2"/>
<g font-family="var(--figur-fontFamily, sans-serif)" font-size="26" fill="var(--figur-muted, #5B5B5B)">
<text x="160" y="605">Q1</text>
<text x="400" y="605">Q2</text>
<text x="640" y="605">Q3</text>
<text x="880" y="605">Q4</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

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