#!/usr/bin/env node // build-figur.mjs — render en kodet figur (SVG/HTML) til PNG via headless Chrome. // Bruk: node build-figur.mjs [--target article|carousel|single] // [--out ] [--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 , ellers prependet). // --------------------------------------------------------------------------- export function wrapSvg(svgContent, { width, height }, tokens) { return ` ${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(); }