676 lines
28 KiB
JavaScript
676 lines
28 KiB
JavaScript
/*** @ui-arch 视觉规则表:硬编码视觉字面量 → 设计令牌的唯一映射来源
|
||
*
|
||
* 为什么单独成模块:迁移脚本(scripts/visual-migrate.mjs)与审计门禁
|
||
* (packages/tools/cli/lib/visual-audit.mjs)必须用同一张表,否则「迁移完还报漂移」
|
||
* 或「门禁放过迁移漏掉的写法」这类派生语义不一致必然出现(CODING.md §5)。
|
||
* 改规则只改这里;新增一类硬编码 = 加一条 KIND_META + 一个 detect 分支 + migrateDeclaration 分支。
|
||
*/
|
||
|
||
/*** 皮肤包样式表相对仓库根的路径(迁移与审计共同的目标文件) ***/
|
||
export const UI_CSS_REL = 'packages/skin/ui/ui.css';
|
||
|
||
/*** 作用:各类硬编码的键名、中文名与判定口径,审计输出与报告都按这个顺序 ***/
|
||
export const KIND_META = [
|
||
{
|
||
key: 'font-size-literal',
|
||
label: '字号字面值',
|
||
hint: 'font-size 写死 px/em,应改用 --ua-font-size-*',
|
||
},
|
||
{
|
||
key: 'duration-literal',
|
||
label: '动效时长字面值',
|
||
hint: 'transition/animation 写死时长,应改用 --ua-duration-*',
|
||
},
|
||
{
|
||
key: 'shadow-literal',
|
||
label: '阴影字面值',
|
||
hint: 'box-shadow 里出现 px 长度,应整条改用 --ua-shadow-1..4',
|
||
},
|
||
{
|
||
key: 'radius-literal',
|
||
label: '圆角字面值',
|
||
hint: 'border-radius 写死 px,应改用 --ua-radius-*',
|
||
},
|
||
{
|
||
key: 'focus-ring-variant',
|
||
label: '焦点环变体',
|
||
hint: '自绘 0 0 0 Npx 色环,应改用 --ua-focus-ring / --ua-focus-ring-inset',
|
||
},
|
||
{
|
||
key: 'space-literal',
|
||
label: '内距字面值',
|
||
hint: 'padding/gap/margin 写死 px,应改用 --ua-space-*',
|
||
},
|
||
{
|
||
key: 'control-h-literal',
|
||
label: '控件高度字面值',
|
||
hint: 'height 写死控件档位值(28/34/40px),应改用 --ua-control-h-*',
|
||
},
|
||
{
|
||
key: 'weight-literal',
|
||
label: '字重字面值',
|
||
hint: 'font-weight 写死数字,应改用 --ua-weight-*',
|
||
},
|
||
{
|
||
key: 'leading-literal',
|
||
label: '行高字面值',
|
||
hint: 'line-height 写死数值,应改用 --ua-leading-*(px 行高是图形对齐技巧,不入此口径)',
|
||
},
|
||
{
|
||
key: 'z-literal',
|
||
label: '层级字面值',
|
||
hint: 'z-index 写死数字,应改用 --ua-z-* 五档(组件内 1/2 微排序豁免)',
|
||
},
|
||
{
|
||
key: 'border-literal',
|
||
label: '边框色字面值',
|
||
hint: 'border/border-color 写死色值(rgba()/#hex),组件轮廓用 --ua-color-border、分割线用 --ua-border-subtle',
|
||
},
|
||
{
|
||
key: 'mix-literal',
|
||
label: '混色浓度字面值',
|
||
hint: 'color-mix 的混入浓度写死百分比,应改用 --ua-alpha-* 六档(soft/tint/line/mid/strong/heavy)',
|
||
},
|
||
{
|
||
key: 'translate-literal',
|
||
label: '位移字面值',
|
||
hint: 'transform 的 translate 写死 px 位移,应改用 --ua-translate-* 档位(负号用 calc(-1 * var(...)),百分比居中豁免)',
|
||
},
|
||
];
|
||
|
||
/*** 作用:需要扫描的 CSS 属性白名单(其余属性本轮不动;用白名单顺带避开伪类选择器误判) ***/
|
||
const SCAN_PROPS = new Set([
|
||
'background-image',
|
||
'background',
|
||
'color',
|
||
'fill',
|
||
'stroke',
|
||
'font-size',
|
||
'transition',
|
||
'animation',
|
||
'transition-duration',
|
||
'animation-duration',
|
||
'box-shadow',
|
||
'border-radius',
|
||
'outline',
|
||
'padding',
|
||
'padding-top',
|
||
'padding-right',
|
||
'padding-bottom',
|
||
'padding-left',
|
||
'margin',
|
||
'margin-top',
|
||
'margin-right',
|
||
'margin-bottom',
|
||
'margin-left',
|
||
'gap',
|
||
'row-gap',
|
||
'column-gap',
|
||
'min-height',
|
||
'max-height',
|
||
'height',
|
||
'font-weight',
|
||
'line-height',
|
||
'z-index',
|
||
'border',
|
||
'border-top',
|
||
'border-right',
|
||
'border-bottom',
|
||
'border-left',
|
||
'border-color',
|
||
'border-top-color',
|
||
'border-right-color',
|
||
'border-bottom-color',
|
||
'border-left-color',
|
||
'transform',
|
||
]);
|
||
|
||
/*** 作用:边框类属性集合(轮廓与分割线共用一套色值口径,禁用字面色) ***/
|
||
const BORDER_PROPS = new Set([
|
||
'border',
|
||
'border-top',
|
||
'border-right',
|
||
'border-bottom',
|
||
'border-left',
|
||
'border-color',
|
||
'border-top-color',
|
||
'border-right-color',
|
||
'border-bottom-color',
|
||
'border-left-color',
|
||
]);
|
||
|
||
/*** 作用:内距类属性集合(迁移与统计共用同一判定) ***/
|
||
const SPACE_PROPS = new Set([
|
||
'padding',
|
||
'padding-top',
|
||
'padding-right',
|
||
'padding-bottom',
|
||
'padding-left',
|
||
'margin',
|
||
'margin-top',
|
||
'margin-right',
|
||
'margin-bottom',
|
||
'margin-left',
|
||
'gap',
|
||
'row-gap',
|
||
'column-gap',
|
||
]);
|
||
|
||
/*** 作用:内距 px 字面值 → 令牌(半步档位 05/15/25/35 与既有 1..4 组成完整梯子;1px 是边框语义不入表) ***/
|
||
const SPACE_TOKENS = new Map([
|
||
['2px', 'var(--ua-space-05)'],
|
||
['4px', 'var(--ua-space-1)'],
|
||
['6px', 'var(--ua-space-15)'],
|
||
['8px', 'var(--ua-space-2)'],
|
||
['10px', 'var(--ua-space-25)'],
|
||
['12px', 'var(--ua-space-3)'],
|
||
['14px', 'var(--ua-space-35)'],
|
||
['16px', 'var(--ua-space-4)'],
|
||
]);
|
||
|
||
/*** 作用:字号字面值 → 令牌(10/11 归并到 xs;22/26/34 是展示档;0.92em/0.85em 归相对缩放档) ***/
|
||
const FONT_SIZE_TOKENS = new Map([
|
||
['10px', 'var(--ua-font-size-xs)'],
|
||
['11px', 'var(--ua-font-size-xs)'],
|
||
['12px', 'var(--ua-font-size-sm)'],
|
||
['13px', 'var(--ua-font-size-md)'],
|
||
['14px', 'var(--ua-font-size-lg)'],
|
||
['16px', 'var(--ua-font-size-xl)'],
|
||
['18px', 'var(--ua-font-size-2xl)'],
|
||
['22px', 'var(--ua-font-size-3xl)'],
|
||
['26px', 'var(--ua-font-size-4xl)'],
|
||
['34px', 'var(--ua-font-size-5xl)'],
|
||
['0.92em', 'var(--ua-font-size-scale-md)'],
|
||
['0.85em', 'var(--ua-font-size-scale-sm)'],
|
||
]);
|
||
|
||
/*** 作用:圆角字面值 → 令牌(1px 细线、50% 圆形、999px 胶囊是语义形状,不属漂移,保留) ***/
|
||
const RADIUS_TOKENS = new Map([
|
||
['4px', 'var(--ua-radius-sm)'],
|
||
['10px', 'var(--ua-radius-md)'],
|
||
]);
|
||
|
||
/*** 作用:圆角里视为语义化、不参与漂移统计的取值 ***/
|
||
const RADIUS_LEGIT = new Set(['0', '1px', '50%', '999px', 'inherit']);
|
||
|
||
/*** 作用:纯黑投影的(位移:模糊:透明度)→ elevation 档位;表外的主题色投影留待后续按语义人工归并 ***/
|
||
const ELEVATION_BY_SHAPE = new Map([
|
||
['8:24:0.12', '2'],
|
||
['8:24:0.14', '2'],
|
||
['10:28:0.16', '3'],
|
||
['12:28:0.18', '4'],
|
||
['16:32:0.24', '4'],
|
||
['10:40:0.2', '4'],
|
||
['0:24:0.18', '2'],
|
||
['0:40:0.22', '4'],
|
||
]);
|
||
|
||
/*** 作用:动效时长三档的秒数上限(fast 取 0.15s,因为既有 111 处都是 0.15s,把离群值收进来不改变观感) ***/
|
||
const DURATION_STEPS = [
|
||
{ max: 0.17, token: 'var(--ua-duration-fast)' },
|
||
{ max: 0.23, token: 'var(--ua-duration-base)' },
|
||
{ max: 0.2501, token: 'var(--ua-duration-slow)' },
|
||
{ max: 0.36, token: 'var(--ua-duration-slower)' },
|
||
];
|
||
|
||
/*** 作用:循环动效(infinite)的时长 → 循环四档;0.7/0.8 收敛到中档、1.2/1.4 收敛到慢档,表外保留 ***/
|
||
const LOOP_DURATIONS = new Map([
|
||
['0.6s', 'var(--ua-duration-loop-fast)'],
|
||
['0.7s', 'var(--ua-duration-loop-mid)'],
|
||
['0.8s', 'var(--ua-duration-loop-mid)'],
|
||
['1.2s', 'var(--ua-duration-loop-slow)'],
|
||
['1.4s', 'var(--ua-duration-loop-slow)'],
|
||
['1.6s', 'var(--ua-duration-loop-long)'],
|
||
]);
|
||
|
||
/*** 作用:控件高度三档的字面值 → 令牌(28/34/40px 三个值保留给控件高度档位,其余尺寸不入此口径) ***/
|
||
const CONTROL_H_TOKENS = new Map([
|
||
['28px', 'var(--ua-control-h-sm)'],
|
||
['34px', 'var(--ua-control-h-md)'],
|
||
['40px', 'var(--ua-control-h-lg)'],
|
||
]);
|
||
|
||
/*** 作用:字重字面值 → 令牌(normal/medium/semibold/bold 四档覆盖常见取值) ***/
|
||
const WEIGHT_TOKENS = new Map([
|
||
['400', 'var(--ua-weight-normal)'],
|
||
['500', 'var(--ua-weight-medium)'],
|
||
['600', 'var(--ua-weight-semibold)'],
|
||
['700', 'var(--ua-weight-bold)'],
|
||
]);
|
||
|
||
/*** 作用:行高字面值 → 令牌(unitless 归 none/tight/normal 三档,就近收敛;px 行高是图形对齐不入表) ***/
|
||
const LEADING_TOKENS = new Map([
|
||
['1', 'var(--ua-leading-none)'],
|
||
['1.2', 'var(--ua-leading-none)'],
|
||
['1.35', 'var(--ua-leading-tight)'],
|
||
['1.4', 'var(--ua-leading-tight)'],
|
||
['1.5', 'var(--ua-leading-normal)'],
|
||
['1.55', 'var(--ua-leading-normal)'],
|
||
['1.6', 'var(--ua-leading-normal)'],
|
||
['1.65', 'var(--ua-leading-normal)'],
|
||
]);
|
||
|
||
/*** 作用:下拉类 z-index 字面值集合(历史上下拉/气泡弹层用的 20/30/40 档,统一归 --ua-z-dropdown) ***/
|
||
const Z_DROPDOWN_LIKE = new Set(['20', '30', '40']);
|
||
|
||
/*** 作用:混色浓度字面值 → --ua-alpha-* 档位(近档就近收敛:12→10、16/20/24→18、40→45、65/80→70,Δ≤6) ***/
|
||
const MIX_TOKENS = new Map([
|
||
['10', 'var(--ua-alpha-soft)'],
|
||
['12', 'var(--ua-alpha-soft)'],
|
||
['16', 'var(--ua-alpha-tint)'],
|
||
['18', 'var(--ua-alpha-tint)'],
|
||
['20', 'var(--ua-alpha-tint)'],
|
||
['24', 'var(--ua-alpha-tint)'],
|
||
['30', 'var(--ua-alpha-line)'],
|
||
['40', 'var(--ua-alpha-mid)'],
|
||
['45', 'var(--ua-alpha-mid)'],
|
||
['55', 'var(--ua-alpha-strong)'],
|
||
['65', 'var(--ua-alpha-heavy)'],
|
||
['70', 'var(--ua-alpha-heavy)'],
|
||
['80', 'var(--ua-alpha-heavy)'],
|
||
]);
|
||
|
||
/*** 作用:transform 位移字面值 → --ua-translate-* 档位(10px 就近收敛到 8 档,Δ≤2px 入场不可感;负号由迁移规则转 calc) ***/
|
||
const TRANSLATE_TOKENS = new Map([
|
||
['1px', 'var(--ua-translate-1)'],
|
||
['2px', 'var(--ua-translate-2)'],
|
||
['3px', 'var(--ua-translate-3)'],
|
||
['4px', 'var(--ua-translate-4)'],
|
||
['6px', 'var(--ua-translate-6)'],
|
||
['8px', 'var(--ua-translate-8)'],
|
||
['10px', 'var(--ua-translate-8)'],
|
||
['12px', 'var(--ua-translate-12)'],
|
||
]);
|
||
|
||
/*** 作用:提取值里所有 color-mix(...) 块(配平括号,嵌套 color-mix 逐层都算一条);返回全块文本与首色参(in srgb 之后的第一个顶层参数) ***/
|
||
function extractMixes(value) {
|
||
const out = [];
|
||
const src = String(value);
|
||
for (let i = src.indexOf('color-mix('); i !== -1; i = src.indexOf('color-mix(', i + 1)) {
|
||
let depth = 0;
|
||
let end = -1;
|
||
for (let k = i + 'color-mix'.length; k < src.length; k++) { // 从函数名后的 ( 起配平
|
||
if (src[k] === '(') depth++;
|
||
else if (src[k] === ')') { depth--; if (!depth) { end = k; break; } }
|
||
}
|
||
if (end === -1) break; // 未闭合:整值残缺,交给浏览器报错
|
||
const block = src.slice(i, end + 1);
|
||
const inner = block.slice('color-mix('.length, -1); // 去掉外壳与收尾括号
|
||
const args = splitTopLevel(inner).map((s) => s.trim());
|
||
out.push({ block, start: i, arg1: args[1] ?? '' }); // args[0] 是插值方法(in srgb),首色参跟在其后
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/*** 作用:首色参末尾的混入浓度:已是 --ua-alpha-* 令牌返回 null;写死百分比返回数字字符串(渐变停靠位不在 color-mix 首参末尾,天然豁免) ***/
|
||
function mixAmount(arg1) {
|
||
const s = String(arg1).trim();
|
||
if (/var\(--ua-alpha-[\w-]+\)\s*$/.test(s)) return null; // 已令牌化
|
||
const lit = s.match(/(\d+(?:\.\d+)?)%\s*$/);
|
||
return lit ? lit[1] : null;
|
||
}
|
||
|
||
/*** 作用:迁移单条声明里的所有 color-mix 浓度字面量:逐块把首色参尾部的 NN% 换成 --ua-alpha-* 档位(表外浓度保留交人工归档) ***/
|
||
function migrateMix(core) {
|
||
const src = String(core);
|
||
const mixes = extractMixes(src);
|
||
if (!mixes.length) return null;
|
||
let hits = 0;
|
||
let out = '';
|
||
let cursor = 0;
|
||
for (const { block, start, arg1 } of mixes) {
|
||
const amt = mixAmount(arg1);
|
||
const token = amt !== null ? MIX_TOKENS.get(amt) : null;
|
||
if (!token) continue;
|
||
const nextArg1 = arg1 ? arg1.replace(/(\d+(?:\.\d+)?)%\s*$/, token) : arg1; // 复用原有的「色 浓度」间隔空格
|
||
const nextBlock = nextArg1 === arg1 || !arg1 ? block : block.replace(arg1, nextArg1); // arg1 是 block 的精确子串,整串替换安全
|
||
hits++;
|
||
out += src.slice(cursor, start) + nextBlock;
|
||
cursor = start + block.length;
|
||
}
|
||
if (!hits) return null;
|
||
out += src.slice(cursor);
|
||
return { kind: 'mix-literal', value: out };
|
||
}
|
||
|
||
/*** 作用:压缩空白,便于用稳定字符串比对(不改语义) ***/
|
||
export function squash(value) {
|
||
return String(value).replace(/\s+/g, ' ').trim();
|
||
}
|
||
|
||
/*** 作用:把整段 CSS 的注释替换成等长空格,保持偏移与行号可对齐(迁移时按偏移回写原文) ***/
|
||
export function blankComments(src) {
|
||
let out = '';
|
||
let i = 0;
|
||
while (i < src.length) {
|
||
if (src[i] === '/' && src[i + 1] === '*') {
|
||
const close = src.indexOf('*/', i + 2);
|
||
const stop = close === -1 ? src.length : close + 2; // 未闭合时一路吞到文件尾
|
||
for (let k = i; k < stop; k++) out += src[k] === '\n' ? '\n' : ' ';
|
||
i = stop;
|
||
} else {
|
||
out += src[i];
|
||
i++;
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/*** 作用:逐条产出「属性 + 值」及其在原文中的偏移;只认白名单属性且必须以分号收尾(避开伪类选择器) ***/
|
||
export function eachDeclaration(code) {
|
||
const re = /(transform|border-top-color|border-right-color|border-bottom-color|border-left-color|border-color|border-top|border-right|border-bottom|border-left|border|padding-top|padding-right|padding-bottom|padding-left|padding|margin-top|margin-right|margin-bottom|margin-left|margin|row-gap|column-gap|gap|font-weight|line-height|z-index|min-height|max-height|height|transition-duration|animation-duration|transition|animation|font-size|box-shadow|border-radius|outline|background-image|background|color|fill|stroke)\s*:\s*([^;{}]*);/gi;
|
||
const list = [];
|
||
for (const m of code.matchAll(re)) {
|
||
const prop = m[1].toLowerCase();
|
||
if (!SCAN_PROPS.has(prop)) continue; // 白名单外的属性不进入迁移与统计
|
||
const valueEnd = m.index + m[0].length - 1; // 指向结尾分号
|
||
list.push({ prop, value: m[2], valueStart: valueEnd - m[2].length, valueEnd });
|
||
}
|
||
return list;
|
||
}
|
||
|
||
/*** 作用:拆出尾部的 !important,迁移只看核心值、尾部原样保留 ***/
|
||
function splitImportant(raw) {
|
||
const m = String(raw).match(/^(.*?)(\s*!important\s*)$/i);
|
||
return m ? { core: m[1], tail: m[2] } : { core: raw, tail: '' };
|
||
}
|
||
|
||
/*** 作用:按顶层逗号切分(跳过括号内的逗号,cubic-bezier 的入参不会被切开) ***/
|
||
function splitTopLevel(value) {
|
||
const parts = [];
|
||
let depth = 0;
|
||
let start = 0;
|
||
for (let i = 0; i < value.length; i++) {
|
||
const ch = value[i];
|
||
if (ch === '(') depth++;
|
||
else if (ch === ')') depth = Math.max(0, depth - 1);
|
||
else if (ch === ',' && depth === 0) {
|
||
parts.push(value.slice(start, i));
|
||
start = i + 1;
|
||
}
|
||
}
|
||
parts.push(value.slice(start));
|
||
return parts;
|
||
}
|
||
|
||
/*** 作用:单个时长字面值 → 档位令牌;超出三档上限的返回 null(如 0.3s / 1.6s 的加载动画节奏,保留原值) ***/
|
||
function durationToken(text) {
|
||
const m = String(text).match(/^(\d*\.?\d+)(ms|s)$/i);
|
||
if (!m) return null;
|
||
const sec = m[2].toLowerCase() === 'ms' ? Number(m[1]) / 1000 : Number(m[1]);
|
||
for (const step of DURATION_STEPS) if (sec <= step.max) return step.token;
|
||
return null;
|
||
}
|
||
|
||
/*** 作用:判断一段动效值里是否已写了缓动(含令牌形式;写了就保留,不覆盖作者意图) ***/
|
||
function hasTiming(value) {
|
||
if (/var\(--ua-ease/.test(value)) return true;
|
||
if (/(cubic-bezier|steps|linear)\(/.test(value)) return true; // 函数式缓动:参数里的逗号不是分段符
|
||
return /(^|[\s,])(step-start|step-end|ease-in-out|ease-in|ease-out|ease|linear)(?=$|[\s,])/.test(value);
|
||
}
|
||
|
||
/*** 作用:迁移单个动效声明值(逐段替换时长,缺缓动时补 --ua-ease);返回 null 表示无需改动 ***/
|
||
function migrateMotion(prop, core) {
|
||
const shorthand = prop === 'transition' || prop === 'animation'; // 只有简写能补缓动,-duration 属性补了是非法的
|
||
const loop = /infinite/.test(core) && (prop === 'animation' || prop === 'animation-duration'); // 无限循环走循环四档
|
||
const parts = splitTopLevel(core);
|
||
let hits = 0;
|
||
const next = parts.map((segment) => {
|
||
let replaced = 0;
|
||
let out = segment.replace(/(\d*\.?\d+)(ms|s)\b/gi, (token) => {
|
||
const mapped = loop ? LOOP_DURATIONS.get(token.toLowerCase()) ?? null : durationToken(token);
|
||
if (!mapped) return token; // 表外时长保留,交给后续视觉条目单独评估
|
||
replaced++;
|
||
hits++;
|
||
return mapped;
|
||
});
|
||
if (shorthand && replaced && !hasTiming(out)) out = `${out} var(--ua-ease)`; // 只补缺失的缓动,写过的保
|
||
return out;
|
||
});
|
||
if (!hits) return null;
|
||
return { kind: 'duration-literal', value: next.join(',') };
|
||
}
|
||
|
||
/*** 作用:迁移单条 box-shadow:焦点环收敛成两个令牌,纯黑投影按 位移/模糊/透明度 归入 elevation 四档 ***/
|
||
function migrateShadow(core) {
|
||
const value = squash(core);
|
||
// 焦点环:2/3px 的强调色描边环;5px 4% 这类是呼吸光晕,动效依赖两个不同状态,不并入令牌
|
||
const ring = value.match(/^(inset )?0 0 0 ([23])px color-mix\(in srgb, var\(--ua-color-accent\) (\d+)%, transparent\)$/);
|
||
if (ring && Number(ring[3]) >= 10) {
|
||
return { kind: 'focus-ring-variant', value: ring[1] ? 'var(--ua-focus-ring-inset)' : 'var(--ua-focus-ring)' };
|
||
}
|
||
const offset = value.match(/^0 (\d+)px (\d+)px rgba\(0, 0, 0, ([\d.]+)\)$/); // 带位移的投影
|
||
const glow = value.match(/^0 0 (\d+)px rgba\(0, 0, 0, ([\d.]+)\)$/); // 无位移的发光投影
|
||
const hit = offset
|
||
? ELEVATION_BY_SHAPE.get(`${offset[1]}:${offset[2]}:${offset[3]}`)
|
||
: glow
|
||
? ELEVATION_BY_SHAPE.get(`0:${glow[1]}:${glow[2]}`)
|
||
: undefined;
|
||
if (!hit) return null; // 主题色投影 / 状态色环 / 遮罩色块保留,由视觉条目逐条人工归并
|
||
return { kind: 'shadow-literal', value: `var(--ua-shadow-${hit})` };
|
||
}
|
||
|
||
/*** 作用:迁移单条内距声明:逐个 px 记号查 SPACE_TOKENS 替换(负值与 calc 内不算,返回 null 表示无需改动) ***/
|
||
function migrateSpace(core) {
|
||
let hits = 0;
|
||
const next = String(core).replace(/(^|[\s(,])(-?\d+(?:\.\d+)?)px\b/gi, (full, lead, num) => {
|
||
if (String(num).startsWith('-')) return full; // 负外距是偏移语义,不入令牌
|
||
const mapped = SPACE_TOKENS.get(`${num}px`);
|
||
if (!mapped) return full; // 表外取值(3px / 5px / 22px 等)留给后续条目评估
|
||
hits++;
|
||
return `${lead}${mapped}`;
|
||
});
|
||
if (!hits) return null;
|
||
return { kind: 'space-literal', value: next };
|
||
}
|
||
|
||
/*** 作用:迁移单条控件高度:整值命中三档即换令牌(其余 height 值不动),返回 null 表示无需改动 ***/
|
||
function migrateControlH(core) {
|
||
const token = CONTROL_H_TOKENS.get(squash(core));
|
||
if (!token) return null;
|
||
return { kind: 'control-h-literal', value: token };
|
||
}
|
||
|
||
/*** 作用:迁移单条字重:整值命中四档即换令牌 ***/
|
||
function migrateWeight(core) {
|
||
const token = WEIGHT_TOKENS.get(squash(core));
|
||
if (!token) return null;
|
||
return { kind: 'weight-literal', value: token };
|
||
}
|
||
|
||
/*** 作用:迁移单条行高:unitless 数值命中映射表即换令牌(px 与表外值不动) ***/
|
||
function migrateLeading(core) {
|
||
const token = LEADING_TOKENS.get(squash(core));
|
||
if (!token) return null;
|
||
return { kind: 'leading-literal', value: token };
|
||
}
|
||
|
||
/*** 作用:迁移单条 transform:px 位移逐个换 --ua-translate-* 档位,负号转 calc(-1 * var(...));百分比居中与 0 天然不入表 ***/
|
||
function migrateTranslate(core) {
|
||
let hits = 0;
|
||
const next = String(core).replace(/(-?\d+(?:\.\d+)?)px\b/gi, (full, num) => {
|
||
const token = TRANSLATE_TOKENS.get(`${Math.abs(Number(num))}px`);
|
||
if (!token) return full; // 表外取值留给后续条目评估
|
||
hits++;
|
||
return Number(num) < 0 ? `calc(-1 * ${token})` : token;
|
||
});
|
||
if (!hits) return null;
|
||
return { kind: 'translate-literal', value: next };
|
||
}
|
||
|
||
/*** 作用:迁移单条层级:下拉类常见字面值(20/30/40)归 dropdown 档;其余取值层级语义需按选择器人工归档,留给门禁拦截 ***/
|
||
function migrateZ(core) {
|
||
const token = Z_DROPDOWN_LIKE.has(squash(core)) ? 'var(--ua-z-dropdown)' : null;
|
||
if (!token) return null;
|
||
return { kind: 'z-literal', value: token };
|
||
}
|
||
|
||
/*** 作用:迁移单条边框:纯黑 rgba 边(历史上手写的「深一层黑边」)归到基础边框色;其余留在门禁里由人按轮廓/分割线语义定位 ***/
|
||
function migrateBorder(prop, core) {
|
||
if (!BORDER_PROPS.has(prop)) return null;
|
||
const value = squash(core);
|
||
const next = value.replace(/rgba\(\s*0\s*,\s*0\s*,\s*0\s*,\s*[\d.]+\s*\)/g, 'var(--ua-color-border)'); // 空格宽容,避免只认一种写法
|
||
if (next === value) return null;
|
||
return { kind: 'border-literal', value: next };
|
||
}
|
||
|
||
/*** 作用:判定单条声明是否需要迁移,返回 { kind, value } 或 null(幂等:已是令牌就返回 null) ***/
|
||
export function migrateDeclaration(prop, rawValue) {
|
||
const { core, tail } = splitImportant(rawValue);
|
||
let result = null;
|
||
if (prop === 'font-size') {
|
||
const token = FONT_SIZE_TOKENS.get(squash(core));
|
||
if (token) result = { kind: 'font-size-literal', value: token };
|
||
} else if (prop === 'border-radius') {
|
||
const token = RADIUS_TOKENS.get(squash(core));
|
||
if (token) result = { kind: 'radius-literal', value: token };
|
||
} else if (prop === 'transition' || prop === 'animation' || prop === 'transition-duration' || prop === 'animation-duration') {
|
||
result = migrateMotion(prop, core);
|
||
} else if (prop === 'box-shadow') {
|
||
result = migrateShadow(core);
|
||
} else if (SPACE_PROPS.has(prop)) {
|
||
result = migrateSpace(core);
|
||
} else if (prop === 'height') {
|
||
result = migrateControlH(core);
|
||
} else if (prop === 'font-weight') {
|
||
result = migrateWeight(core);
|
||
} else if (prop === 'line-height') {
|
||
result = migrateLeading(core);
|
||
} else if (prop === 'z-index') {
|
||
result = migrateZ(core);
|
||
} else if (prop === 'transform') {
|
||
result = migrateTranslate(core); // 位移量走独立档位,不与内距梯子混用
|
||
} else {
|
||
result = migrateMix(core) ?? migrateBorder(prop, core); // 其余属性:先收敛 color-mix 浓度,再收敛纯黑 rgba 边
|
||
}
|
||
if (!result) return null;
|
||
return { kind: result.kind, value: result.value + tail };
|
||
}
|
||
|
||
/*** 作用:统计单条声明在一个类别下的命中数(0 表示该类别下不算漂移);口径与迁移规则严格对应 ***/
|
||
function detect(prop, rawValue, key) {
|
||
const { core } = splitImportant(rawValue);
|
||
const value = squash(core);
|
||
if (key === 'font-size-literal') {
|
||
if (prop !== 'font-size') return 0;
|
||
if (/var\(/.test(value)) return 0; // 已走令牌(含 calc(var(...) * 系数))不算漂移
|
||
return /\d+(px|em|rem)\b/.test(value) ? 1 : 0;
|
||
}
|
||
if (key === 'duration-literal') {
|
||
if (!['transition', 'animation', 'transition-duration', 'animation-duration'].includes(prop)) return 0;
|
||
const stripped = value.replace(/var\(--ua-duration-[\w-]+\)/g, ' '); // 已是令牌的时长先剔除
|
||
return (stripped.match(/\b\d*\.?\d+(ms|s)\b/gi) ?? []).length;
|
||
}
|
||
if (key === 'shadow-literal') {
|
||
if (prop !== 'box-shadow') return 0;
|
||
if (value === 'none') return 0;
|
||
if (/^0 0 0 (1[0-9]{2,}|[2-9][0-9]{2,})px /.test(value)) return 0; // 巨型扩散是遮罩色块(0 0 0 9999px),不是投影
|
||
return /\b\d+(\.\d+)?px\b/.test(value) ? 1 : 0;
|
||
}
|
||
if (key === 'radius-literal') {
|
||
if (prop !== 'border-radius') return 0;
|
||
if (/^var\(/.test(value)) return 0;
|
||
if (RADIUS_LEGIT.has(value)) return 0;
|
||
return /\b\d+(\.\d+)?px\b/.test(value) ? 1 : 0;
|
||
}
|
||
if (key === 'focus-ring-variant') {
|
||
// 口径:只统计 2-8px 的 box-shadow 自绘色环。outline 焦点描边是仓库既有的正统兜底写法
|
||
// (:focus-visible 全局规则),算进去会让新组件照抄现有写法就被门禁拦住;
|
||
// 1px 内框是边框语义(backlog 里另有边框条目),与页面底色断开的环是几何技巧,都不入此类别。
|
||
if (prop !== 'box-shadow') return 0;
|
||
if (value === 'var(--ua-focus-ring)' || value === 'var(--ua-focus-ring-inset)') return 0; // 已统一,不算变体
|
||
if (!/^(inset )?0 0 0 [2-8]px \S/.test(value)) return 0;
|
||
return /var\(--ua-color-(accent|warning|danger)/.test(value) ? 1 : 0;
|
||
}
|
||
if (key === 'space-literal') {
|
||
if (!SPACE_PROPS.has(prop)) return 0;
|
||
// 口径:数非负、非 1px 的 px 记号(1px 是边框/分隔线语义,负外距是偏移语义,都不算漂移)
|
||
const tokens = String(value).match(/(^|[\s(,])(\d+(?:\.\d+)?)px\b/gi) ?? [];
|
||
return tokens.filter((t) => !/(^|[\s(,])1px$/i.test(t)).length;
|
||
}
|
||
if (key === 'control-h-literal') {
|
||
if (prop !== 'height') return 0; // min/max-height 是约束不是控件档位,不入此口径
|
||
return CONTROL_H_TOKENS.has(squash(core)) ? 1 : 0;
|
||
}
|
||
if (key === 'weight-literal') {
|
||
if (prop !== 'font-weight') return 0;
|
||
if (/^var\(/.test(value)) return 0;
|
||
return /\d+/.test(value) ? 1 : 0; // normal/bold 关键字与数值都算漂移,一律走令牌
|
||
}
|
||
if (key === 'leading-literal') {
|
||
if (prop !== 'line-height') return 0;
|
||
if (/^var\(/.test(value)) return 0;
|
||
if (/px|em|rem|%|calc/.test(value)) return 0; // px 行高是图形对齐技巧(图标 / 角标),不入此口径
|
||
return /^\d+(\.\d+)?$/.test(value) ? 1 : 0; // unitless 数值归三档令牌
|
||
}
|
||
if (key === 'z-literal') {
|
||
if (prop !== 'z-index') return 0;
|
||
if (/^var\(/.test(value)) return 0;
|
||
if (value === '1' || value === '2') return 0; // 组件内微排序(表头压行、水印叠层、头像悬停),不是全局层级
|
||
return /^\d+$/.test(value) ? 1 : 0;
|
||
}
|
||
if (key === 'border-literal') {
|
||
if (!BORDER_PROPS.has(prop)) return 0;
|
||
if (value === 'none' || value === '0' || value === 'inherit') return 0; // 无边框与继承不算色值漂移
|
||
const stripped = value.replace(/var\(--ua-[\w-]+\)/g, ' '); // 已是令牌的色先剔除
|
||
return /(#[0-9a-f]{3,8}\b|rgba?\(|hsla?\()/i.test(stripped) ? 1 : 0;
|
||
}
|
||
if (key === 'mix-literal') {
|
||
return extractMixes(value).filter(({ arg1 }) => mixAmount(arg1) !== null).length; // 只认 color-mix 块内首色参的写死浓度;渐变停靠位与几何百分比天然不在口径内
|
||
}
|
||
if (key === 'translate-literal') {
|
||
if (prop !== 'transform') return 0;
|
||
const stripped = value.replace(/var\(--ua-translate-[\w-]+\)/g, ' '); // 已令牌化的位移先剔除
|
||
return (stripped.match(/-?\d+(\.\d+)?px\b/gi) ?? []).length; // 百分比(居中/轨道)与 0 不算漂移
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
/*** 作用:扫描整段 CSS,返回各类硬编码的计数与命中行号(审计命令的唯一统计口径) ***/
|
||
export function scanSource(src) {
|
||
const code = blankComments(src);
|
||
const lineAt = (offset) => {
|
||
let line = 1;
|
||
for (let i = 0; i < offset && i < code.length; i++) if (code[i] === '\n') line++;
|
||
return line;
|
||
};
|
||
const counts = {};
|
||
const hits = {};
|
||
for (const meta of KIND_META) {
|
||
counts[meta.key] = 0;
|
||
hits[meta.key] = [];
|
||
}
|
||
for (const decl of eachDeclaration(code)) {
|
||
for (const meta of KIND_META) {
|
||
const n = detect(decl.prop, decl.value, meta.key);
|
||
if (!n) continue;
|
||
counts[meta.key] += n;
|
||
hits[meta.key].push({ line: lineAt(decl.valueStart), prop: decl.prop, value: squash(decl.value) });
|
||
}
|
||
}
|
||
return { counts, hits };
|
||
}
|
||
|
||
/*** 作用:迁移整段 CSS,返回改写后的文本与每类替换处数(幂等:无字面量时 text 与入参完全一致) ***/
|
||
export function migrateSource(src) {
|
||
const code = blankComments(src);
|
||
const counts = {};
|
||
for (const meta of KIND_META) counts[meta.key] = 0;
|
||
let out = '';
|
||
let cursor = 0;
|
||
for (const decl of eachDeclaration(code)) {
|
||
const replaced = migrateDeclaration(decl.prop, src.slice(decl.valueStart, decl.valueEnd));
|
||
if (!replaced) continue;
|
||
out += src.slice(cursor, decl.valueStart) + replaced.value;
|
||
cursor = decl.valueEnd;
|
||
counts[replaced.kind] += 1;
|
||
}
|
||
out += src.slice(cursor);
|
||
return { text: out, counts };
|
||
}
|
||
|
||
/*** 作用:把各类计数拍平成「类别: 数量」数组,供审计报告与基线文件共用同一顺序 ***/
|
||
export function countRows(counts) {
|
||
return KIND_META.map((meta) => ({ ...meta, count: counts[meta.key] ?? 0 }));
|
||
}
|