forked from wangziqi/gongxue-base
279 lines
8.0 KiB
JavaScript
279 lines
8.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 = [
|
|
'avatar',
|
|
'progress',
|
|
'track',
|
|
'fill',
|
|
'chip',
|
|
'badge',
|
|
'tag',
|
|
'pill',
|
|
'meta',
|
|
'option-prefix',
|
|
'watermark',
|
|
'scrollbar',
|
|
'thumb',
|
|
'dot',
|
|
'::before',
|
|
'::after',
|
|
];
|
|
|
|
const allowedLinearGradientSelectors = [
|
|
'body',
|
|
'page',
|
|
'layout',
|
|
'student-page',
|
|
'admin-page',
|
|
'platform-page',
|
|
'bootstrap-page',
|
|
'header',
|
|
'hero',
|
|
'band',
|
|
'surface',
|
|
'module',
|
|
'tone-',
|
|
'button.primary',
|
|
'primary-button',
|
|
'avatar',
|
|
'profile-avatar',
|
|
'avatar-choice-icon',
|
|
'brand-icon',
|
|
'shell::after',
|
|
'mini-avatar',
|
|
'sidebar-user-tag',
|
|
'form',
|
|
'panel',
|
|
'revealed',
|
|
'trend',
|
|
'points',
|
|
'nested',
|
|
'asset-watermark',
|
|
];
|
|
|
|
const allowedBackdropBlurSelectors = [
|
|
'sidebar',
|
|
'mobile-dock',
|
|
'mobile-nav',
|
|
];
|
|
|
|
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 > 32);
|
|
if (tooLarge.length) {
|
|
violations.push({
|
|
file: relative(filePath),
|
|
line: lineNumber(text, match.index || 0),
|
|
selector,
|
|
value,
|
|
reason: 'Legacy question-bank cards, panels and buttons should stay within the 8-32px rounded scale.',
|
|
});
|
|
}
|
|
}
|
|
}
|
|
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 keeps legacy radii within the old question-bank rounded scale',
|
|
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 uses only legacy-approved grid, banner, module and state gradients',
|
|
[
|
|
...collectPatternViolations(files, /radial-gradient\s*\(/gi, 'Do not add decorative radial/orb gradients.'),
|
|
...collectPatternViolations(
|
|
files,
|
|
/(^|[;\s{])filter\s*:\s*[^;]*blur\s*\(/gi,
|
|
'Do not add raw blurred decorative backgrounds; use scoped backdrop-filter only for legacy glass navigation.',
|
|
),
|
|
...collectPatternViolations(
|
|
files,
|
|
/backdrop-filter\s*:\s*[^;]*blur\s*\(/gi,
|
|
'Backdrop blur is only allowed for legacy glass navigation surfaces.',
|
|
selector => selectorMatches(selector, allowedBackdropBlurSelectors),
|
|
),
|
|
...collectPatternViolations(
|
|
files,
|
|
/linear-gradient\s*\(/gi,
|
|
'Linear gradients must be scoped to legacy grid backgrounds, blue banners, module cards, primary actions or state panels.',
|
|
selector => selectorMatches(selector, allowedLinearGradientSelectors),
|
|
),
|
|
],
|
|
);
|
|
emitCheck(
|
|
collector,
|
|
'css.pill_radius_scope',
|
|
'999px radius is scoped to progress, chips, badges, avatars, scrollbars and decorative legacy markers',
|
|
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();
|