#!/usr/bin/env node /** * Checks theme folders or .zip files the way the app and the theme store do: * the manifest, the CSS sanitizer, public variables, Latin font stacks and * WCAG contrast against the app's own palette (packages/ui/styles/tokens.css), * at the default options and at each other `select` and `toggle` value. * The checks live in packages/core/appearance/theme-check.ts. * * Run: pnpm theme:check ... [--json] * Exits 1 when any theme has an error. */ import { readFileSync } from "node:fs"; import { basename, join, resolve } from "node:path"; import { appearance, readTheme, repoRoot } from "./theme-files.mjs"; const { checkThemeFiles } = await appearance("theme-check.ts"); const { basePaletteFromTokens } = await appearance("theme-contrast.ts"); const args = process.argv.slice(2); const json = args.includes("--json"); const targets = args.filter((arg) => arg !== "--json"); if (targets.length === 0) { console.error("Usage: pnpm theme:check ... [--json]"); process.exit(2); } const base = basePaletteFromTokens(readFileSync(join(repoRoot, "packages/ui/styles/tokens.css"), "utf8")); const reports = []; for (const target of targets) { const path = resolve(process.env.INIT_CWD ?? process.cwd(), target); let read; try { read = await readTheme(path); } catch (error) { read = { error: error instanceof Error ? error.message : String(error) }; } const report = read.error ? { ok: false, issues: [{ level: "error", message: read.error }], contrast: [] } : checkThemeFiles(read.files, base); reports.push({ target, path, ...report }); } if (json) { const out = reports.map(({ path, ok, theme, issues, contrast }) => ({ path, ok, theme: theme ? { id: theme.id, name: theme.name, version: theme.version ?? null } : null, issues, contrast, })); console.log(JSON.stringify(out, null, 2)); } else { for (const { target, path, ok, theme, issues, contrast } of reports) { const title = theme ? `${theme.name} (${theme.id}${theme.version ? ` ${theme.version}` : ""})` : basename(path); console.log(`\n${ok ? "✓" : "✗"} ${title} ${target}`); for (const issue of issues) console.log(` ${issue.level === "error" ? "error " : "warning"} ${issue.message}`); if (contrast.length > 0) { const failing = contrast.filter((c) => c.ratio < c.required).length; const lowest = [...contrast].sort((a, b) => a.ratio / a.required - b.ratio / b.required)[0]; const values = new Set(contrast.filter((c) => c.variant).map((c) => c.variant.label)).size; const where = [lowest.mode, ...(lowest.area === "page" ? [] : [lowest.area])].join(" "); console.log( ` contrast ${contrast.length} pairs checked at the default options${values > 0 ? ` and ${values} other option value${values === 1 ? "" : "s"}` : ""}, ${failing} below WCAG AA. Lowest: ${lowest.foreground} on ${lowest.background}, ${where}${lowest.variant ? ` (${lowest.variant.label})` : ""}, ${lowest.ratio.toFixed(2)}:1 (needs ${lowest.required}:1)`, ); } } const errors = reports.reduce((n, r) => n + r.issues.filter((i) => i.level === "error").length, 0); const warnings = reports.reduce((n, r) => n + r.issues.filter((i) => i.level === "warning").length, 0); console.log(`\n${errors === 0 ? "Passed" : "Failed"}: ${errors} error${errors === 1 ? "" : "s"}, ${warnings} warning${warnings === 1 ? "" : "s"}.`); } // Not process.exit(): it drops stdout still queued for a pipe, which cuts a // --json report off at 64 KB. process.exitCode = reports.every((report) => report.ok) ? 0 : 1;