scripts/theme-preview.mjs
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env node
/**
* Renders a theme in light and dark mode on a fixture page built from the
* app's own styles (scripts/theme-preview/fixture.html), for the theme
* store's screenshots and for authors checking their work. The theme goes
* through the app's parser and CSS sanitizer first; a theme that does not
* parse is not rendered. The page cannot reach the network.
*
* Run: pnpm theme:preview <theme folder or .zip> [--out <dir>] [--modes light,dark]
* Writes <out>/light.png and <out>/dark.png (default out: ./theme-preview).
*/
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
import { join, resolve } from "node:path";
import { appearance, readTheme, repoRoot } from "./theme-files.mjs";
const { themeFromFiles, themeCss } = await appearance("theme-package.ts");
const { themeOptionAttributes, themeOptionVariables } = await appearance("themes.ts");
const WIDTH = 1280;
const HEIGHT = 800;
function parseArgs(argv) {
const args = { out: "theme-preview", modes: ["light", "dark"], targets: [] };
for (let i = 0; i < argv.length; i += 1) {
if (argv[i] === "--out") args.out = argv[++i];
else if (argv[i] === "--modes") args.modes = argv[++i].split(",").filter((m) => m === "light" || m === "dark");
else args.targets.push(argv[i]);
}
return args;
}
async function compileCss() {
const require = createRequire(join(repoRoot, "package.json"));
const postcss = require("postcss");
const tailwind = require("@tailwindcss/postcss");
const from = join(repoRoot, "scripts/theme-preview/fixture.css");
const input = `
@import "tailwindcss" source(none);
@import "../../packages/ui/styles/tokens.css";
@import "../../packages/ui/styles/theme-variables.css";
@import "../../packages/ui/styles/appearance.css";
@import "../../packages/ui/styles/base.css";
@import "../../packages/views/editor/styles/prose.css";
@import "../../packages/views/editor/styles/code.css";
@custom-variant dark (&:is(.dark *));
@source "./fixture.html";
:root {
--font-sans: var(--font-ui, Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif);
--font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
`;
const result = await postcss([tailwind({ base: repoRoot })]).process(input, { from });
return result.css;
}
const declare = (variables) =>
Object.entries(variables)
.map(([name, value]) => `${name}: ${value};`)
.join(" ");
/** The page as the app would draw it with the theme applied at its option defaults. */
function page(theme, mode, appCss) {
const variables = themeOptionVariables(theme, undefined);
const attributes = { "data-theme": theme.id, ...themeOptionAttributes(theme, undefined) };
const attrs = Object.entries(attributes)
.filter(([, value]) => value !== null)
.map(([name, value]) => ` ${name}="${String(value).replace(/"/g, """)}"`)
.join("");
const adjustments = Object.keys(variables.light).length
? `:root.theme-light { ${declare(variables.light)} } :root.theme-dark { ${declare(variables.dark)} }`
: "";
const fixture = readFileSync(join(repoRoot, "scripts/theme-preview/fixture.html"), "utf8");
// Inline <style> content cannot close its element: the sanitizer leaves no
// markup in theme CSS, and "</" is escaped here as well.
const style = (css) => `<style>${css.replace(/<\//g, "<\\/")}</style>`;
return `<!doctype html><html lang="en" class="${mode} theme-${mode} is-web is-focused"${attrs}><head><meta charset="utf-8">${style(appCss)}${style(themeCss(theme))}${style(adjustments)}</head><body>${fixture}</body></html>`;
}
const args = parseArgs(process.argv.slice(2));
if (args.targets.length !== 1 || args.modes.length === 0) {
console.error("Usage: pnpm theme:preview <theme folder or .zip> [--out <dir>] [--modes light,dark]");
process.exit(2);
}
const target = resolve(process.env.INIT_CWD ?? process.cwd(), args.targets[0]);
const read = await readTheme(target);
const result = read.error ? { ok: false, errors: [read.error] } : themeFromFiles(read.files);
if (!result.ok) {
console.error(`✗ ${args.targets[0]} does not pass the checks:\n${result.errors.map((e) => ` ${e}`).join("\n")}`);
process.exit(1);
}
const out = resolve(process.env.INIT_CWD ?? process.cwd(), args.out);
mkdirSync(out, { recursive: true });
const appCss = await compileCss();
const { chromium } = createRequire(join(repoRoot, "package.json"))("@playwright/test");
const browser = await chromium.launch();
try {
const context = await browser.newContext({
viewport: { width: WIDTH, height: HEIGHT },
deviceScaleFactor: 1,
javaScriptEnabled: false,
});
await context.route("**/*", (route) =>
route.request().url().startsWith("data:") ? route.continue() : route.abort(),
);
for (const mode of args.modes) {
const tab = await context.newPage();
await tab.setContent(page(result.theme, mode, appCss), { waitUntil: "load" });
const file = join(out, `${mode}.png`);
writeFileSync(file, await tab.screenshot({ animations: "disabled" }));
await tab.close();
console.log(`✓ ${result.theme.name} ${mode}: ${file}`);
}
} finally {
await browser.close();
}