Files
fluxer/fluxer_app/scripts/GenerateThemeVariables.ts
T

477 lines
16 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-or-later
import {existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync} from 'node:fs';
import {dirname, join, relative, resolve} from 'node:path';
type ThemeVariableKind = 'color' | 'font' | 'dimension' | 'number' | 'shadow' | 'transition' | 'other';
interface CssSource {
file: string;
label: string;
generatedBy?: string;
}
interface VariableDefinition {
name: string;
kind: ThemeVariableKind;
groupId: string;
groupLabel: string;
source: string;
}
const PRIORITY_CSS_SOURCES: ReadonlyArray<CssSource> = [
{file: 'src/app/globals.css', label: 'globals'},
{
file: 'src/features/theme/styles/generated/color-system.css',
label: 'color-system',
generatedBy: 'pnpm generate:colors',
},
{
file: 'src/features/theme/styles/generated/message-layout.css',
label: 'message-layout',
generatedBy: 'pnpm generate:message-layout',
},
];
const PRIORITY_SOURCE_INDEX = new Map(PRIORITY_CSS_SOURCES.map((source, index) => [source.file, index]));
const IGNORED_SOURCE_PREFIXES = ['src/features/theme_studio/', 'src/theme/'];
const BUNDLED_FONT_VARIABLES_CSS = resolve(
import.meta.dirname,
'..',
'..',
'packages',
'fonts',
'css',
'variables.css',
);
function bundledFontStack(name: string): string {
const css = readFileSync(BUNDLED_FONT_VARIABLES_CSS, 'utf8');
const declaration = new RegExp(`${name}:([^;]*);`).exec(css);
if (!declaration) {
throw new Error(`${name} is not declared in ${BUNDLED_FONT_VARIABLES_CSS}`);
}
return declaration[1].replace(/\s+/g, ' ').trim();
}
const EXTRA_GLOBAL_DEFAULTS: ReadonlyArray<{name: string; value: string; source: string}> = [
{
name: '--font-sans',
value: bundledFontStack('--font-sans'),
source: 'runtime-fonts',
},
{
name: '--font-mono',
value: bundledFontStack('--font-mono'),
source: 'runtime-fonts',
},
{name: '--font-size', value: '1rem', source: 'runtime-accessibility'},
{name: '--chat-horizontal-padding', value: '1rem', source: 'runtime-accessibility'},
{name: '--message-group-spacing', value: '1rem', source: 'runtime-accessibility'},
{name: '--link-decoration', value: 'none', source: 'runtime-accessibility'},
{name: '--markup-strikethrough-color', value: 'currentColor', source: 'runtime-accessibility'},
];
const GROUP_LABELS: Record<string, string> = {
typography: 'Typography',
surfaces: 'Surfaces',
headers: 'Headers',
text: 'Text',
brand: 'Brand & accents',
status: 'Status indicators',
borders: 'Borders & focus',
alerts: 'Alerts & callouts',
markup: 'Markup & mentions',
buttons: 'Buttons',
code: 'Code & terminal',
tables: 'Tables',
scrolling: 'Scrolling',
layout: 'Layout',
messages: 'Messages',
emoji: 'Emoji',
motion: 'Motion',
layering: 'Layering',
media: 'Media',
forms: 'Forms',
other: 'Other',
};
function stripAtRuleBlocks(css: string): string {
let output = '';
let index = 0;
while (index < css.length) {
if (css[index] !== '@') {
output += css[index];
index += 1;
continue;
}
const nextSemicolon = css.indexOf(';', index);
const nextBrace = css.indexOf('{', index);
if (nextBrace === -1 || (nextSemicolon !== -1 && nextSemicolon < nextBrace)) {
index = nextSemicolon === -1 ? css.length : nextSemicolon + 1;
continue;
}
let depth = 0;
let cursor = nextBrace;
for (; cursor < css.length; cursor += 1) {
if (css[cursor] === '{') depth += 1;
if (css[cursor] === '}') {
depth -= 1;
if (depth === 0) {
cursor += 1;
break;
}
}
}
index = cursor;
}
return output;
}
function toPosixPath(path: string): string {
return path.replaceAll('\\', '/');
}
function discoverCssSources(appDir: string): ReadonlyArray<CssSource> {
const srcDir = join(appDir, 'src');
const files: Array<string> = [];
const visit = (directory: string) => {
for (const entry of readdirSync(directory, {withFileTypes: true})) {
const absolutePath = join(directory, entry.name);
if (entry.isDirectory()) {
visit(absolutePath);
continue;
}
if (!entry.isFile() || !entry.name.endsWith('.css')) continue;
const sourceFile = toPosixPath(relative(appDir, absolutePath));
if (IGNORED_SOURCE_PREFIXES.some((prefix) => sourceFile.startsWith(prefix))) continue;
files.push(sourceFile);
}
};
visit(srcDir);
const discovered = new Set(files);
const missing = PRIORITY_CSS_SOURCES.filter((source) => !discovered.has(source.file));
if (missing.length > 0) {
const remedies = missing.map(
(source) => ` ${source.file} is missing. Run \`${source.generatedBy ?? 'pnpm build'}\` first.`,
);
throw new Error(
`Cannot generate the theme variable manifest from a partial input.\n${remedies.join('\n')}\n` +
'Writing anyway would silently delete every variable those sources declare from a tracked file.',
);
}
return files
.sort((left, right) => {
const leftPriority = PRIORITY_SOURCE_INDEX.get(left);
const rightPriority = PRIORITY_SOURCE_INDEX.get(right);
if (leftPriority !== undefined || rightPriority !== undefined) {
return (leftPriority ?? Number.MAX_SAFE_INTEGER) - (rightPriority ?? Number.MAX_SAFE_INTEGER);
}
return left.localeCompare(right);
})
.map((file) => ({
file,
label: PRIORITY_CSS_SOURCES.find((source) => source.file === file)?.label ?? file.replace(/^src\//, ''),
}));
}
function selectorHas(selector: string, target: string): boolean {
return selector
.split(',')
.map((part) => part.trim())
.includes(target);
}
function extractDeclarations(block: string): Array<[string, string]> {
const declarations: Array<[string, string]> = [];
const pattern = /(--[a-zA-Z0-9_-]+)\s*:\s*([^;]+);/g;
let match: RegExpExecArray | null;
while ((match = pattern.exec(block)) !== null) {
const name = match[1] as string;
const value = (match[2] as string).replace(/\s+/g, ' ').trim();
declarations.push([name, value]);
}
return declarations;
}
function readSourceVariables(appDir: string): {
darkDefaults: Map<string, string>;
lightDefaults: Map<string, string>;
sources: Map<string, string>;
} {
const darkDefaults = new Map<string, string>();
const lightOverrides = new Map<string, string>();
const sources = new Map<string, string>();
for (const source of discoverCssSources(appDir)) {
const absolutePath = join(appDir, source.file);
const css = stripAtRuleBlocks(readFileSync(absolutePath, 'utf8').replace(/\/\*[\s\S]*?\*\//g, ''));
const blockPattern = /([^{}]+)\{([^{}]*)\}/g;
let match: RegExpExecArray | null;
while ((match = blockPattern.exec(css)) !== null) {
const selector = (match[1] as string).trim();
const block = match[2] as string;
const isRoot = selectorHas(selector, ':root');
const isLight = selectorHas(selector, '.theme-light');
if (!isRoot && !isLight) continue;
for (const [name, value] of extractDeclarations(block)) {
if (isRoot) {
darkDefaults.set(name, value);
sources.set(name, source.label);
}
if (isLight) {
lightOverrides.set(name, value);
sources.set(name, source.label);
}
}
}
}
for (const extra of EXTRA_GLOBAL_DEFAULTS) {
if (!darkDefaults.has(extra.name)) {
darkDefaults.set(extra.name, extra.value);
sources.set(extra.name, extra.source);
}
}
const lightDefaults = new Map(darkDefaults);
for (const [name, value] of lightOverrides) {
lightDefaults.set(name, value);
}
return {darkDefaults, lightDefaults, sources};
}
function findMatchingParen(text: string, openIndex: number): number {
let depth = 0;
for (let index = openIndex; index < text.length; index += 1) {
const character = text[index];
if (character === '(') depth += 1;
if (character === ')') {
depth -= 1;
if (depth === 0) return index;
}
}
return -1;
}
function splitVarArguments(inner: string): {dependency: string; fallback: string | null} {
let depth = 0;
for (let index = 0; index < inner.length; index += 1) {
const character = inner[index];
if (character === '(') depth += 1;
if (character === ')') depth -= 1;
if (character === ',' && depth === 0) {
return {dependency: inner.slice(0, index).trim(), fallback: inner.slice(index + 1).trim()};
}
}
return {dependency: inner.trim(), fallback: null};
}
function resolveVariableValue(name: string, values: ReadonlyMap<string, string>, stack = new Set<string>()): string {
const value = values.get(name);
if (!value) return '';
let result = '';
let cursor = 0;
while (cursor < value.length) {
const start = value.indexOf('var(', cursor);
if (start === -1) {
result += value.slice(cursor);
return result;
}
const close = findMatchingParen(value, start + 3);
if (close === -1) {
result += value.slice(cursor);
return result;
}
result += value.slice(cursor, start);
const full = value.slice(start, close + 1);
const {dependency, fallback} = splitVarArguments(value.slice(start + 4, close));
if (dependency === '--saturation-factor') {
result += full;
} else if (stack.has(dependency) || !values.get(dependency)) {
result += fallback ?? full;
} else {
const nextStack = new Set(stack);
nextStack.add(name);
result += resolveVariableValue(dependency, values, nextStack);
}
cursor = close + 1;
}
return result;
}
function getGroupId(name: string): string {
if (name.startsWith('--font')) return 'typography';
if (name.startsWith('--z-index')) return 'layering';
if (name.startsWith('--transition')) return 'motion';
if (name.startsWith('--shadow')) return 'borders';
if (name.includes('scrollbar')) return 'scrolling';
if (name.startsWith('--message')) return 'messages';
if (name.includes('typing')) return 'messages';
if (name.includes('emoji')) return 'emoji';
if (name.includes('textarea') || name.includes('input') || name.includes('form')) return 'forms';
if (name.includes('button') || name.includes('control-button')) return 'buttons';
if (name.startsWith('--code') || name.startsWith('--ansi') || name === '--text-code') return 'code';
if (name.includes('table')) return 'tables';
if (name.startsWith('--markup') || name.includes('spoiler')) return 'markup';
if (name.startsWith('--alert')) return 'alerts';
if (name.startsWith('--status')) return 'status';
if (name.startsWith('--brand') || name.startsWith('--accent') || name.startsWith('--plutonium')) return 'brand';
if (name.startsWith('--text')) return 'text';
if (name.includes('border') || name.includes('focus') || name.includes('radius')) return 'borders';
if (name.includes('layout') || name.includes('spacing') || name.includes('padding') || name.includes('gap'))
return 'layout';
if (name.includes('width') || name.includes('height') || name.includes('size') || name.includes('gutter'))
return 'layout';
if (name.includes('media') || name.includes('avatar') || name.includes('guild-icon')) return 'media';
if (name.includes('bg') || name.includes('background') || name.includes('surface') || name.includes('guild-list')) {
return 'surfaces';
}
return 'other';
}
function getKind(name: string, value: string): ThemeVariableKind {
const lowerName = name.toLowerCase();
const lowerValue = value.toLowerCase();
if (name === '--font-sans' || name === '--font-mono') return 'font';
if (name.startsWith('--shadow')) return 'shadow';
if (name.startsWith('--transition') || /\b\d+(?:\.\d+)?m?s\b/.test(lowerValue)) return 'transition';
if (/^-?\d+(?:\.\d+)?$/.test(value)) return 'number';
if (lowerName.includes('opacity')) return 'number';
if (lowerValue.includes(' solid ')) return 'other';
if (
lowerValue === 'transparent' ||
lowerValue === 'currentcolor' ||
lowerValue.startsWith('#') ||
lowerValue.startsWith('hsl') ||
lowerValue.startsWith('rgb') ||
lowerValue.startsWith('color-mix')
) {
return 'color';
}
if (
/(?:^|\s)-?\d*\.?\d+(?:px|rem|em|%|vh|vw|dvh|svh|cqi)\b/.test(lowerValue) ||
lowerValue.includes('calc(') ||
lowerValue.includes('clamp(') ||
lowerValue.includes('min(') ||
lowerValue.includes('max(')
) {
return 'dimension';
}
if (
lowerName.startsWith('--ansi') ||
lowerName.includes('color') ||
lowerName.startsWith('--text-') ||
lowerName.endsWith('-text') ||
lowerName.includes('-text-') ||
lowerName.includes('bg') ||
lowerName.includes('background') ||
lowerName.includes('fill') ||
lowerName.includes('accent') ||
lowerName.includes('brand') ||
lowerName.includes('status') ||
lowerName.includes('alert') ||
lowerName.includes('selection')
) {
return 'color';
}
return 'other';
}
function buildDefinitions(
darkDefaults: ReadonlyMap<string, string>,
sources: ReadonlyMap<string, string>,
): Array<VariableDefinition> {
return [...darkDefaults.keys()]
.sort((left, right) => left.localeCompare(right))
.map((name) => {
const value = resolveVariableValue(name, darkDefaults);
const groupId = getGroupId(name);
return {
name,
kind: getKind(name, value),
groupId,
groupLabel: GROUP_LABELS[groupId] ?? GROUP_LABELS.other,
source: sources.get(name) ?? 'unknown',
};
});
}
function renderStringArray(name: string, values: ReadonlyArray<string>): string {
const body = values.map((value) => `\t${JSON.stringify(value)},`).join('\n');
return `export const ${name}: ReadonlyArray<string> = [\n${body}\n];`;
}
function renderValueMap(name: string, values: ReadonlyMap<string, string>): string {
const body = [...values.keys()]
.sort((left, right) => left.localeCompare(right))
.map((key) => `\t${JSON.stringify(key)}: ${JSON.stringify(resolveVariableValue(key, values))},`)
.join('\n');
return `export const ${name}: Readonly<Record<string, string>> = {\n${body}\n};`;
}
function renderDefinitions(definitions: ReadonlyArray<VariableDefinition>): string {
const body = definitions
.map(
(definition) =>
`\t{name: ${JSON.stringify(definition.name)}, kind: ${JSON.stringify(definition.kind)}, groupId: ${JSON.stringify(definition.groupId)}, groupLabel: ${JSON.stringify(definition.groupLabel)}, source: ${JSON.stringify(definition.source)}},`,
)
.join('\n');
return `export const THEME_VARIABLES: ReadonlyArray<ThemeVariableDefinition> = [\n${body}\n];`;
}
function render(appDir: string): string {
const {darkDefaults, lightDefaults, sources} = readSourceVariables(appDir);
const definitions = buildDefinitions(darkDefaults, sources);
const colorVariables = definitions
.filter((definition) => definition.kind === 'color')
.map((definition) => definition.name);
const fontVariables = definitions
.filter((definition) => definition.kind === 'font')
.map((definition) => definition.name);
return `// SPDX-License-Identifier: AGPL-3.0-or-later
// Generated by scripts/GenerateThemeVariables.ts. Do not edit by hand.
export type ThemeVariableKind = 'color' | 'font' | 'dimension' | 'number' | 'shadow' | 'transition' | 'other';
export interface ThemeVariableDefinition {
\tname: string;
\tkind: ThemeVariableKind;
\tgroupId: string;
\tgroupLabel: string;
\tsource: string;
}
${renderDefinitions(definitions)}
${renderStringArray(
'THEME_VARIABLE_NAMES',
definitions.map((definition) => definition.name),
)}
${renderStringArray('THEME_COLOR_VARIABLES', colorVariables)}
${renderStringArray('THEME_FONT_VARIABLES', fontVariables)}
${renderValueMap('THEME_STUDIO_DARK_DEFAULT_VARIABLE_VALUES', darkDefaults)}
${renderValueMap('THEME_STUDIO_LIGHT_DEFAULT_VARIABLE_VALUES', lightDefaults)}
`;
}
function main(): void {
const scriptDir = import.meta.dirname;
const appDir = resolve(scriptDir, '..');
const outputPath = join(appDir, 'src', 'features', 'theme', 'variables', 'ThemeVariableManifest.ts');
const contents = render(appDir);
if (process.argv.includes('--check')) {
if (!existsSync(outputPath) || readFileSync(outputPath, 'utf8') !== contents) {
throw new Error(`${relative(appDir, outputPath)} is stale. Run pnpm generate:theme-variables.`);
}
console.log(`Checked ${relative(appDir, outputPath)}`);
return;
}
mkdirSync(dirname(outputPath), {recursive: true});
writeFileSync(outputPath, contents);
console.log(`Wrote ${relative(appDir, outputPath)}`);
}
main();