Files
gongxue-base/scripts/taro-visual-guardrails.js
2026-07-01 05:35:07 +08:00

229 lines
7.0 KiB
JavaScript

import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
const repoRoot = process.cwd();
const defaultRoot = path.join(repoRoot, 'apps', 'taro', 'src');
const allowedPillRadiusSelectors = [
'progress',
'track',
'fill',
'chip',
'badge',
'tag',
'pill',
'watermark',
];
const allowedLinearGradientSelectors = [
'profile-avatar',
'avatar-choice-icon',
'asset-watermark',
];
function parseArgs(argv) {
const options = {
root: defaultRoot,
json: false,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === '--json') options.json = true;
else if (arg === '--root') {
options.root = path.resolve(repoRoot, argv[index + 1] || '');
index += 1;
} else if (arg.startsWith('--root=')) {
options.root = path.resolve(repoRoot, arg.slice('--root='.length));
}
}
return options;
}
function normalizeSlashes(value) {
return value.replace(/\\/g, '/');
}
function relative(filePath) {
return normalizeSlashes(path.relative(repoRoot, filePath));
}
function readText(filePath) {
return fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
}
function walkCssFiles(dir) {
if (!fs.existsSync(dir)) return [];
const result = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const entryPath = path.join(dir, entry.name);
if (entry.isDirectory()) result.push(...walkCssFiles(entryPath));
else if (entry.name.endsWith('.css')) result.push(entryPath);
}
return result.sort((a, b) => relative(a).localeCompare(relative(b)));
}
function createCollector() {
const checks = [];
function push(status, id, message, details = {}) {
checks.push({ status, id, message, details });
}
return {
checks,
pass: (id, message, details) => push('pass', id, message, details),
fail: (id, message, details) => push('fail', id, message, details),
};
}
function summarize(checks, scannedFiles) {
return checks.reduce(
(summary, item) => {
summary[item.status] += 1;
return summary;
},
{ fail: 0, pass: 0, scannedFiles },
);
}
function lineNumber(text, index) {
return text.slice(0, index).split('\n').length;
}
function selectorBefore(text, index) {
const before = text.slice(0, index);
const open = before.lastIndexOf('{');
const close = before.lastIndexOf('}');
if (open <= close) return '';
const selectorStart = before.lastIndexOf('}', open - 1);
return before.slice(selectorStart + 1, open).trim().replace(/\s+/g, ' ');
}
function selectorMatches(selector, tokens) {
const normalized = selector.toLowerCase();
return tokens.some(token => normalized.includes(token));
}
function collectBorderRadiusViolations(files) {
const violations = [];
const radiusPattern = /border-radius\s*:\s*([^;]+);/gi;
for (const filePath of files) {
const text = readText(filePath);
for (const match of text.matchAll(radiusPattern)) {
const value = match[1].trim();
const selector = selectorBefore(text, match.index || 0);
if (/999px/.test(value)) {
if (!selectorMatches(selector, allowedPillRadiusSelectors)) {
violations.push({
file: relative(filePath),
line: lineNumber(text, match.index || 0),
selector,
value,
reason: '999px radius is reserved for progress bars, chips, badges and similar pill controls.',
});
}
continue;
}
const pxValues = [...value.matchAll(/([0-9]+(?:\.[0-9]+)?)px/g)].map(item => Number(item[1]));
const tooLarge = pxValues.filter(item => item > 8);
if (tooLarge.length) {
violations.push({
file: relative(filePath),
line: lineNumber(text, match.index || 0),
selector,
value,
reason: 'Cards, panels, inputs and buttons should stay at 8px radius or less.',
});
}
}
}
return violations;
}
function collectPatternViolations(files, pattern, reason, allow = () => false) {
const violations = [];
for (const filePath of files) {
const text = readText(filePath);
for (const match of text.matchAll(pattern)) {
const selector = selectorBefore(text, match.index || 0);
if (allow(selector, match, text, filePath)) continue;
violations.push({
file: relative(filePath),
line: lineNumber(text, match.index || 0),
selector,
value: match[0].trim(),
reason,
});
}
}
return violations;
}
function emitCheck(collector, id, message, violations) {
if (violations.length) collector.fail(id, message, { violations: violations.slice(0, 30), count: violations.length });
else collector.pass(id, message);
}
function main() {
const options = parseArgs(process.argv.slice(2));
const files = walkCssFiles(options.root);
const collector = createCollector();
emitCheck(
collector,
'css.border_radius',
'Taro CSS uses 8px-or-less radii for cards, panels, buttons and inputs',
collectBorderRadiusViolations(files),
);
emitCheck(
collector,
'css.viewport_font_size',
'Taro CSS does not scale font-size with viewport units',
collectPatternViolations(files, /font-size\s*:\s*[^;]*(?:vw|vh|vmin|vmax)[^;]*;/gi, 'Use fixed px/rpx-like sizing, not viewport-scaled font sizes.'),
);
emitCheck(
collector,
'css.letter_spacing',
'Taro CSS does not use negative letter spacing',
collectPatternViolations(files, /letter-spacing\s*:\s*-\s*[^;]+;/gi, 'Letter spacing must be 0 or positive.'),
);
emitCheck(
collector,
'css.decorative_effects',
'Taro CSS avoids decorative radial gradients, blur backgrounds and unapproved linear gradients',
[
...collectPatternViolations(files, /radial-gradient\s*\(/gi, 'Do not add decorative radial/orb gradients.'),
...collectPatternViolations(files, /filter\s*:\s*[^;]*blur\s*\(/gi, 'Do not add blurred decorative backgrounds.'),
...collectPatternViolations(
files,
/linear-gradient\s*\(/gi,
'Linear gradients require a documented UI purpose and scoped selector.',
selector => selectorMatches(selector, allowedLinearGradientSelectors),
),
],
);
emitCheck(
collector,
'css.pill_radius_scope',
'999px radius is scoped to progress bars, chips, badges and pill controls',
collectBorderRadiusViolations(files).filter(item => item.value.includes('999px')),
);
const payload = {
summary: summarize(collector.checks, files.length),
root: relative(options.root),
checks: collector.checks,
};
if (options.json) {
console.log(JSON.stringify(payload, null, 2));
} else {
console.log(`Taro visual guardrails: ${payload.summary.fail} fail(s), ${payload.summary.pass} pass(es), ${payload.summary.scannedFiles} file(s) scanned`);
for (const item of collector.checks) {
const marker = item.status === 'fail' ? 'FAIL' : 'PASS';
console.log(`[${marker}] ${item.id}: ${item.message}`);
}
}
if (payload.summary.fail > 0) process.exitCode = 1;
}
main();