scripts/theme-check.mjs
Rama main · réplica de lectura del repositorio original.
#!/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 <theme folder or .zip>... [--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 <theme folder or .zip>... [--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;