205 lines
8.7 KiB
JavaScript
205 lines
8.7 KiB
JavaScript
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<string, number[]>} */
|
|
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') ? `<style>${source}</style>` : 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(
|
|
/(?<![\w-])(?:min-|max-)?width\s*(?::|[<>]=?|=)\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();
|