import { readdir, readFile } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import path from 'node:path'; import { parse } from 'svelte/compiler'; const sourceRoot = fileURLToPath(new URL('../src/', import.meta.url)); const tokens = await readFile(path.join(sourceRoot, 'lib/ui/styles/tokens.css'), 'utf8'); const sharedBreakpointWidths = new Set( [...tokens.matchAll(/--breakpoint-[\w-]+:\s*([\d.]+px)\s*;/g)].map((match) => match[1]) ); // Existing layouts awaiting migration (Refs #130). Remove widths as each file // moves to the shared scale; a listed file cannot introduce other legacy widths. /** @type {Record} */ const legacyBreakpointWidths = { 'src/lib/ui/components/ArtifactDetail.svelte': [720, 1000], 'src/lib/ui/components/BetaPlanBuilder.svelte': [480, 850], 'src/lib/ui/components/BetaTestProgress.svelte': [720], 'src/lib/ui/components/BlueprintBuilder.svelte': [620], 'src/lib/ui/components/ConfigEditor.svelte': [600], 'src/lib/ui/components/ConfigInspector.svelte': [600], 'src/lib/ui/components/ConfigLibrary.svelte': [600], 'src/lib/ui/components/ConfigTextEditor.svelte': [600], 'src/lib/ui/components/DeployCollectionDialog.svelte': [440, 600], 'src/lib/ui/components/DeployServerDialog.svelte': [440], 'src/lib/ui/components/ProvisioningActivity.svelte': [600], 'src/lib/ui/components/ProvisioningProgress.svelte': [600], 'src/lib/ui/components/SettingsEditor.svelte': [720], 'src/lib/ui/components/capacity/RebalancePanel.svelte': [600], 'src/lib/ui/components/capacity/RebalanceRunView.svelte': [600], 'src/lib/ui/components/metrics/MetricsExplore.svelte': [540], 'src/lib/ui/components/metrics/MetricsSummary.svelte': [760], 'src/lib/ui/components/primitives/Button.svelte': [720], 'src/lib/ui/components/primitives/Card.svelte': [480], 'src/lib/ui/components/primitives/Dialog.svelte': [600], 'src/lib/ui/styles/components.css': [600], 'src/routes/account/+page.svelte': [540], 'src/routes/autoscaling/+page.svelte': [500], 'src/routes/betas/+page.svelte': [720], 'src/routes/betas/[id]/+page.svelte': [720], 'src/routes/betas/new/+page.svelte': [700], 'src/routes/campaigns/+page.svelte': [720], 'src/routes/campaigns/[id]/+page.svelte': [720], 'src/routes/capacity/+page.svelte': [600], 'src/routes/collections/launches/[id]/+page.svelte': [600], 'src/routes/hosts/operations/+page.svelte': [600], 'src/routes/leaderboard/+page.svelte': [800, 1000], 'src/routes/login/+page.svelte': [540], 'src/routes/matches/[id]/+page.svelte': [480, 800], 'src/routes/metrics/+page.svelte': [540], 'src/routes/plugins/+page.svelte': [720], 'src/routes/provisioning/+page.svelte': [720], 'src/routes/provisioning/[id]/+page.svelte': [600, 1000], 'src/routes/setup/+page.svelte': [540] }; /** * @param {string} source * @param {string} filename */ export function checkMediaWidths(source, filename) { // Parse styles so comments, script strings and CSS declaration values cannot // be mistaken for media queries. Wrap standalone CSS without adding a line. const cssSource = filename.endsWith('.css') ? `` : source; const stylesheet = parse(cssSource, { modern: true }).css; const legacyWidths = legacyBreakpointWidths[filename.replaceAll('\\', '/')] ?? []; const allowed = new Set([...sharedBreakpointWidths, ...legacyWidths.map((n) => `${n}px`)]); /** @type {string[]} */ const failures = []; /** @param {import('svelte/compiler').AST.CSS.StyleSheet | import('svelte/compiler').AST.CSS.Block | null} block */ function visit(block) { for (const node of block?.children ?? []) { if (node.type === 'Atrule' && node.name.toLowerCase() === 'media') { const query = node.prelude.replace(/\/\*[\s\S]*?\*\//g, ' '); // Check both sides independently to cover chained range syntax. const widths = [ ...query.matchAll( /(?]=?|=)\s*([^\s)]+)/gi ), ...query.matchAll(/([\d.]+(?:[a-z%]+)?)\s*(?:[<>]=?|=)\s*width\b/gi) ]; for (const match of widths) { if (allowed.has(match[1].toLowerCase())) continue; const line = cssSource.slice(0, node.start).split('\n').length; failures.push( `${filename}:${line}: use a shared media width (${[...sharedBreakpointWidths].join(', ')}): ${match[1]}` ); } } if ('block' in node) visit(node.block); } } visit(stylesheet); return failures; } const nativeInputTypes = new Set([ 'checkbox', 'radio', 'file', 'hidden', 'range', 'color', 'button', 'submit', 'reset', 'image' ]); /** * @param {string} source * @param {string} filename */ export function checkFormControls(source, filename) { if ( !filename.endsWith('.svelte') || /(?:^|\/)primitives\/(?:Input|Select)\.svelte$/.test(filename) ) return []; /** @type {string[]} */ const failures = []; /** @param {unknown} node */ function visit(node) { if (!node || typeof node !== 'object') return; if ('type' in node && node.type === 'RegularElement') { const element = /** @type {import('svelte/compiler').AST.RegularElement} */ (node); const type = element.attributes.find( (attribute) => attribute.type === 'Attribute' && attribute.name === 'type' ); const value = type?.type === 'Attribute' && type.value !== true ? Array.isArray(type.value) ? type.value[0] : type.value : undefined; const inputType = value?.type === 'Text' ? value.data : value?.expression.type === 'Literal' ? value.expression.value : undefined; if ( element.name === 'select' || (element.name === 'input' && (typeof inputType !== 'string' || !nativeInputTypes.has(inputType))) ) { const line = source.slice(0, element.start).split('\n').length; failures.push( `${filename}:${line}: use Field with ${element.name === 'select' ? 'Select' : 'Input'} instead of raw <${element.name}>` ); } } for (const value of Object.values(node)) { if (Array.isArray(value)) value.forEach(visit); else if (value && typeof value === 'object') visit(value); } } visit(parse(source, { modern: true }).fragment); return failures; } async function main() { /** @type {string[]} */ const failures = []; /** @param {string} directory */ async function visit(directory) { for (const entry of await readdir(directory, { withFileTypes: true })) { const file = path.join(directory, entry.name); if (entry.isDirectory()) await visit(file); else if (/\.(svelte|css)$/.test(entry.name)) { const source = await readFile(file, 'utf8'); const filename = path .relative(path.dirname(sourceRoot), file) .split(path.sep) .join('/'); failures.push(...checkMediaWidths(source, filename)); if (file === path.join(sourceRoot, 'lib/ui/styles/tokens.css')) continue; failures.push(...checkFormControls(source, filename)); for (const match of source.matchAll(/font-size\s*:\s*[\d.]+px\b/g)) { const line = source.slice(0, match.index).split('\n').length; failures.push( `${filename}:${line}: use a shared type token (12 px minimum): ${match[0]}` ); } } } } await visit(sourceRoot); for (const match of tokens.matchAll( /--text-(?:xs|sm|base|md|lg|xl|2xl|3xl|input):\s*([\d.]+)px/g )) { if (Number(match[1]) < 12) failures.push(`Type tokens must be at least 12 px: ${match[0]}`); } if (failures.length) { console.error(failures.join('\n')); process.exitCode = 1; } else console.log( 'UI styles: shared form controls, media widths, font sizes and 12 px minimum verified.' ); } if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main();