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
365 lines
14 KiB
JavaScript
365 lines
14 KiB
JavaScript
#!/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();
|
||
}
|