From c4434ed789ac0a2df15084301cb5faa3491bc11a Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Thu, 16 Jul 2026 20:26:19 +0200 Subject: [PATCH] =?UTF-8?q?feat(linkedin-studio):=20N2=20=E2=80=94=20build?= =?UTF-8?q?-figur=20renderer=20(SVG/HTML->PNG,=203=20m=C3=A5l,=20token-sea?= =?UTF-8?q?m)=20[skip-docs]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: 26f26c3d-be91-47c4-bbdf-4694ba063b67 --- render/__tests__/build-figur.test.mjs | 284 ++++++++++++++++++ render/__tests__/fixtures/demo-figur.svg | 20 ++ render/build-figur.mjs | 365 +++++++++++++++++++++++ 3 files changed, 669 insertions(+) create mode 100644 render/__tests__/build-figur.test.mjs create mode 100644 render/__tests__/fixtures/demo-figur.svg create mode 100644 render/build-figur.mjs diff --git a/render/__tests__/build-figur.test.mjs b/render/__tests__/build-figur.test.mjs new file mode 100644 index 0000000..e814943 --- /dev/null +++ b/render/__tests__/build-figur.test.mjs @@ -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 16–23, 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('', { width: 1080, height: 1350 }, DEFAULT_TOKENS); + assert.match(html, //i); + assert.match(html, //); + assert.match(html, /--figur-accent/); + assert.match(html, /width:\s*1080px/); + assert.match(html, /height:\s*1350px/); + }); + + test('injectTokens legger style rett etter når den finnes', () => { + const out = injectTokens('x', DEFAULT_TOKENS); + assert.match(out, /\s* + +${svgContent} + + +`; +} + +export function injectTokens(html, tokens) { + const style = ``; + if (/]*>/i.test(html)) { + return html.replace(/]*>/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(/]*\bwidth\s*=\s*["']([\d.]+)(?:px)?["']/); + const hm = svgContent.match(/]*\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 [--target article|carousel|single] [--out ] [--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(); +}