// 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'; import {SKELETON_SURFACE_TOKENS} from '@app/features/app/components/skeleton/SkeletonSurfaceContract'; 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 = [ {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 = { 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 { const srcDir = join(appDir, 'src'); const files: Array = []; 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; lightDefaults: Map; sources: Map; } { const darkDefaults = new Map(); const lightOverrides = new Map(); const sources = new Map(); 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, stack = new Set()): 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, sources: ReadonlyMap, ): Array { 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 { const body = values.map((value) => `\t${JSON.stringify(value)},`).join('\n'); return `export const ${name}: ReadonlyArray = [\n${body}\n];`; } function renderValueMap(name: string, values: ReadonlyMap): 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> = {\n${body}\n};`; } function renderNameUnion(definitions: ReadonlyArray): string { const body = definitions.map((definition) => `\t| ${JSON.stringify(definition.name)}`).join('\n'); return `export type ThemeVariableName =\n${body};`; } function renderDefinitions(definitions: ReadonlyArray): 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 = [\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; } ${renderNameUnion(definitions)} ${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)} `; } interface SkeletonSurfaceInvariant { file: string; requires?: ReadonlyArray; requiresPattern?: ReadonlyArray; forbids?: ReadonlyArray; forbidsPattern?: ReadonlyArray; counts?: Readonly>; minimums?: Readonly>; } const SKELETON_CHROME_BORDER_TOKEN = 'var(--skeleton-chrome-border)'; const SKELETON_SURFACE_INVARIANTS: ReadonlyArray = [ { file: 'src/app/globals.css', counts: { '--skeleton-chrome-border-color': 2, '--skeleton-chrome-border-color: color-mix(in srgb, var(--background-modifier-accent) 25%, transparent);': 1, '--skeleton-chrome-border: 0.0625rem solid var(--skeleton-chrome-border-color);': 1, '--chat-horizontal-padding-default: 1rem;': 1, '--guild-members-columns-selectable: var(--guild-members-select-column-width) var(--guild-members-columns);': 1, '--guilds-layout-item-bg: color-mix(in srgb, var(--guild-list-foreground) 72%, var(--background-primary) 28%);': 1, }, }, { file: 'src/features/app/components/skeleton/ChatSkeleton.module.css', minimums: {[SKELETON_CHROME_BORDER_TOKEN]: 1}, forbidsPattern: ['border[a-z-]*:\\s*[^;]*var\\(--background-modifier-accent\\)'], forbids: ['scopeBadge', 'scope-badge'], }, { file: 'src/features/app/components/skeleton/ChatSkeleton.tsx', requires: [ "import composerWrapperStyles from '@app/features/channel/components/textarea/InputWrapper.module.css';", 'composerWrapperStyles.composerRoot', "'--chat-horizontal-padding': remFromPx(messagePresentation.messageGutterPx),", "'--font-size': remFromPx(messagePresentation.fontSizePx),", "'--message-group-spacing': remFromPx(messagePresentation.groupSpacingPx),", "'--message-compact-timestamp-width': remFromPx(messagePresentation.compactTimestampWidthPx),", ], forbids: ['scopeBadge', 'scope-badge', '@tanstack/react-virtual'], }, { file: 'src/features/app/components/skeleton/DiscoverySkeleton.module.css', minimums: {[SKELETON_CHROME_BORDER_TOKEN]: 2}, forbidsPattern: ['border[a-z-]*:\\s*[^;]*var\\(--background-modifier-accent\\)'], }, { file: 'src/features/app/components/skeleton/FriendsSkeleton.module.css', minimums: {[SKELETON_CHROME_BORDER_TOKEN]: 4}, forbidsPattern: ['border[a-z-]*:\\s*[^;]*var\\(--background-modifier-accent\\)'], forbids: ['.activeNowPreview'], }, { file: 'src/features/app/components/skeleton/FriendsSkeleton.tsx', forbids: ['LIVE_BADGE', 'CONTEXT_CHEVRON', 'ACTIVE_NOW_CONTEXT_ICON_SIZE'], requires: ['function resolveFriendsListSections('], }, { file: 'src/features/app/components/skeleton/GuildRailSkeleton.module.css', requires: [ '.outageSlot {\n\tmin-height: var(--guild-list-item-box-size);\n}', '.sectionTrailingGap {\n\tpadding-bottom: var(--guild-list-item-gap);\n}', '.itemsTrailingGapCancel {\n\tmargin-bottom: calc(-1 * var(--guild-list-item-gap));\n}', 'background-color: var(--guilds-layout-item-bg);', ".item[data-selected='true'] .fluxerIcon", 'clip-path: inset(0);', '\ttransform: translateY(0rem);', ], forbids: ['--guild-list-indicator-', '::before'], counts: {'z-index: 0;': 3, 'z-index: 1;': 3}, }, { file: 'src/features/app/components/skeleton/GuildRailSkeleton.tsx', requires: [ "import guildStyles from '@app/features/app/components/layout/GuildsLayout.module.css';", 'guildStyles.guildIndicator', 'guildStyles.guildIndicatorBar', 'resolveGuildListIndicatorBarTarget(', 'const InlineDMPlaceholder = ', ], requiresPattern: [']*weight="fill"[^>]*className=\\{styles\\.fluxerIconGlyph\\}'], forbids: ['data-indicator', 'GUILD_RAIL_INDICATOR_METRICS', 'Math.round'], counts: {'styles.sectionTrailingGap': 2, 'styles.itemsTrailingGapCancel': 1}, }, { file: 'src/features/ui/components/Scroller.module.css', counts: {'clip-path: inset(0);': 1}, }, { file: 'src/features/channel/components/MemberListSkeleton.tsx', counts: {'style={MEMBER_LIST_METRICS_STYLE}': 2}, }, { file: 'src/features/app/components/layout/GuildsLayout.module.css', requires: [ '.guildListScrollContainer.guildListScrollContainer {\n\toverflow-anchor: auto;\n}', '--layout-user-area-overlay-height', '\tpadding-bottom: calc(\n\t\tvar(--layout-user-area-overlay-height, var(--layout-user-area-reserved-height, 0px)) +\n\t\tvar(--spacing-2)\n\t);', '.messageBubbleIcon {\n\theight: 1.75rem;\n\twidth: 1.75rem;', '.guildIndicator {', '.guildIndicatorBar {', ], forbids: ['.fluxerSymbolIcon'], }, { file: 'src/features/app/components/layout/GuildsLayout.tsx', forbids: ['useListScrollAnchor', 'usePersistentScrollAnchor', '@tanstack/react-virtual'], counts: {'scrollNode.scrollTop = ': 2}, }, { file: 'src/features/discovery/discovery/DiscoveryPage.tsx', counts: {"import {useVirtualizer} from '@tanstack/react-virtual';": 1}, }, { file: 'src/features/app/components/LongPressable.ts', forbids: ['useImperativeHandle'], requires: ['useMergeRefs'], }, { file: 'src/features/app/components/layout/sidebar_nav/GuildListIndicator.ts', requires: ['height: remFromPx(resolveGuildListIndicatorHeight(request))'], forbids: ['transform', 'scale', 'getAppZoomFactor', 'getAppRemScale'], counts: { 'function resolveGuildListIndicatorHeight': 1, 'export function resolveGuildListIndicatorBarTarget': 1, 'export ': 3, }, }, { file: 'src/features/app/components/layout/sidebar_nav/FluxerButton.tsx', requires: [ 'const shouldShowHoverState = isHovering || contextMenuOpen;', 'ChatCircleIcon', 'styles.messageBubbleIcon', 'styles.contextMenuHover', 'resolveGuildListIndicatorBarTarget(', ], forbids: ['scale', 'getAppZoomFactor'], }, { file: 'src/features/app/components/layout/sidebar_nav/FavoritesButton.tsx', requires: ['resolveGuildListIndicatorBarTarget('], forbids: ['scale', 'getAppZoomFactor'], }, { file: 'src/features/app/components/layout/sidebar_nav/GuildListDMItem.tsx', requires: [ 'const shouldShowHoverState = isHovering || contextMenuOpen;', 'guildStyles.contextMenuHover', 'resolveGuildListIndicatorBarTarget(', ], forbids: ['@tanstack/react-virtual', 'scale', 'getAppZoomFactor'], }, { file: 'src/features/app/components/layout/sidebar_nav/GuildFolderItem.tsx', requires: [ 'const shouldShowHoverState = isHovering || contextMenuOpen;', 'const FOLDER_BACKGROUND_FADE_TARGET = Object.freeze({opacity: 0});', "const FOLDER_GUILDS_COLLAPSE_TARGET = Object.freeze({opacity: 0, translateY: '-0.5rem'});", "to={{opacity: 1, translateY: '0rem'}}", 'resolveGuildListIndicatorBarTarget(', 'guildStyles.guildIndicatorBar', ], forbids: ['@tanstack/react-virtual', 'scale', 'getAppZoomFactor'], }, { file: 'src/features/app/components/layout/sidebar_nav/GuildFolderItem.module.css', forbids: ['.folderIndicator'], }, { file: 'src/features/app/components/layout/sidebar_nav/GuildListItemContent.tsx', requires: [ 'const selectedFromThisRow = peekDirectSelection(DirectSelectionSurface.GUILD_RAIL);', 'if (isInitialMount || selectedFromThisRow || !props.isSelected) return;', ], forbids: ['preserveInitialScrollPosition', '@tanstack/react-virtual', 'scale', 'getAppZoomFactor'], }, { file: 'src/features/app/components/layout/sidebar_nav/GuildListItemPresentation.tsx', forbids: ['@tanstack/react-virtual', 'scale', 'getAppZoomFactor'], }, { file: 'src/features/channel/components/ChannelMembers.tsx', forbids: ['@tanstack/react-virtual'], requires: ['styles.virtualRow'], }, ]; const SKELETON_STYLESHEET_DIRECTORIES: ReadonlyArray = [ 'src/features/app/components/skeleton', 'src/features/channel/components', 'src/features/user/components/profile', ]; const SKELETON_STYLESHEET_SUFFIX = 'Skeleton.module.css'; const BORDER_DECLARATION_PATTERN = /(? { const files: Array = []; 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(SKELETON_STYLESHEET_SUFFIX)) continue; files.push(toPosixPath(relative(appDir, absolutePath))); } }; for (const directory of SKELETON_STYLESHEET_DIRECTORIES) { visit(join(appDir, directory)); } return files.sort(); } interface SkeletonSharedRuleSource { readonly file: string; readonly selectors: ReadonlyArray; } const SKELETON_SHARED_RULE_SOURCES: ReadonlyArray = [ { file: 'src/features/app/components/layout/GuildsLayout.module.css', selectors: ['.guildIndicator', '.guildIndicatorBar'], }, ]; const CUSTOM_PROPERTY_REFERENCE_PATTERN = /var\(\s*(--[a-zA-Z0-9-]+)/g; function assertSkeletonSharedRuleTokens(appDir: string): void { const violations: Array = []; const declaredTokens = new Set(SKELETON_SURFACE_TOKENS); for (const source of SKELETON_SHARED_RULE_SOURCES) { const contents = readFileSync(join(appDir, source.file), 'utf8'); for (const selector of source.selectors) { const rule = new RegExp(`^${selector.replace('.', '\\.')}\\s*\\{([^}]*)\\}`, 'mu').exec(contents); if (rule == null) { violations.push(`${source.file} must declare ${selector}; skeleton markup renders through that rule.`); continue; } for (const match of rule[1].matchAll(CUSTOM_PROPERTY_REFERENCE_PATTERN)) { const token = match[1]; if (declaredTokens.has(token)) continue; violations.push( `${source.file} ${selector} reads ${token}, which skeleton markup also renders through; ` + 'list it in SKELETON_SURFACE_TOKENS.', ); } } } if (violations.length > 0) { throw new Error(`Skeleton surface invariants violated:\n${violations.map((line) => ` - ${line}`).join('\n')}`); } } const SKELETON_CHROME_BORDER_COLOR_DECLARATION = /--skeleton-chrome-border-color\s*:\s*([^;}]+)/g; function assertSkeletonChromeBorderColourIsSoftened(appDir: string, violations: Array): void { const file = 'src/app/globals.css'; const contents = readFileSync(join(appDir, file), 'utf8'); for (const match of contents.matchAll(SKELETON_CHROME_BORDER_COLOR_DECLARATION)) { const value = match[1].trim(); if (value.startsWith('var(--background-modifier-accent')) { violations.push( `${file} declares --skeleton-chrome-border-color as ${JSON.stringify(value)}; ` + 'skeleton chrome must use the softened colour, never the raw accent.', ); } } } function assertSkeletonBorderTokens(appDir: string): void { const violations: Array = []; assertSkeletonChromeBorderColourIsSoftened(appDir, violations); for (const file of collectSkeletonStylesheets(appDir)) { const contents = readFileSync(join(appDir, file), 'utf8'); if (contents.includes('--skeleton-chrome-border-color')) { violations.push( `${file} names --skeleton-chrome-border-color directly; skeleton chrome must use ${SKELETON_CHROME_BORDER_TOKEN}.`, ); } for (const match of contents.matchAll(BORDER_DECLARATION_PATTERN)) { const value = match[1].trim(); if (RAW_COLOR_PATTERN.test(value) || value.includes('--background-modifier-accent')) { violations.push( `${file} writes a literal border colour in ${JSON.stringify(`border: ${value}`)}; ` + 'skeleton borders must reference a shared token.', ); } } } if (violations.length > 0) { throw new Error(`Skeleton surface invariants violated:\n${violations.map((line) => ` - ${line}`).join('\n')}`); } } function assertSkeletonSurfaceInvariants(appDir: string): void { const violations: Array = []; for (const invariant of SKELETON_SURFACE_INVARIANTS) { const path = join(appDir, invariant.file); if (!existsSync(path)) { violations.push(`${invariant.file} is missing; a skeleton surface invariant depends on it.`); continue; } const contents = readFileSync(path, 'utf8'); for (const required of invariant.requires ?? []) { if (!contents.includes(required)) { violations.push(`${invariant.file} must contain ${JSON.stringify(required)}.`); } } for (const forbidden of invariant.forbids ?? []) { if (contents.includes(forbidden)) { violations.push(`${invariant.file} must not contain ${JSON.stringify(forbidden)}.`); } } for (const source of invariant.requiresPattern ?? []) { if (!new RegExp(source, 'u').test(contents)) { violations.push(`${invariant.file} must match /${source}/.`); } } for (const source of invariant.forbidsPattern ?? []) { if (new RegExp(source, 'u').test(contents)) { violations.push(`${invariant.file} must not match /${source}/.`); } } for (const [needle, expected] of Object.entries(invariant.counts ?? {})) { const actual = contents.split(needle).length - 1; if (actual !== expected) { violations.push(`${invariant.file} must contain ${needle} exactly ${expected} time(s); found ${actual}.`); } } for (const [needle, minimum] of Object.entries(invariant.minimums ?? {})) { const actual = contents.split(needle).length - 1; if (actual < minimum) { violations.push(`${invariant.file} must contain ${needle} at least ${minimum} time(s); found ${actual}.`); } } } if (violations.length > 0) { throw new Error(`Skeleton surface invariants violated:\n${violations.map((line) => ` - ${line}`).join('\n')}`); } } function main(): void { const scriptDir = import.meta.dirname; const appDir = resolve(scriptDir, '..'); assertSkeletonSurfaceInvariants(appDir); assertSkeletonSharedRuleTokens(appDir); assertSkeletonBorderTokens(appDir); 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();