feat(miniprogram): Token 常量生成脚本 + useCanvasTokens hook (E2-1 Phase 2)

- 新增 scripts/generate-tokens.ts 从 SCSS 解析 CSS 变量生成 token-values.ts
- 新增 useCanvasTokens hook 供 Canvas 组件适老化/医生端切换
- vitest include 扩展覆盖 scripts/__tests__/
- 10 单元测试覆盖 SCSS 解析和变量替换
This commit is contained in:
iven
2026-05-22 08:13:28 +08:00
parent 96a6196373
commit ca9d065d31
6 changed files with 430 additions and 1 deletions

View File

@@ -0,0 +1,97 @@
import { describe, it, expect } from 'vitest';
import {
parseTokensFromScss,
parseScssVariables,
resolveTokenValues,
} from '../generate-tokens';
describe('parseScssVariables', () => {
it('extracts SCSS variable declarations', () => {
const scss = '$pri: #C4623A;\n$bg: #F5F0EB;';
const vars = parseScssVariables(scss);
expect(vars).toEqual({ pri: '#C4623A', bg: '#F5F0EB' });
});
it('ignores comments and non-variable lines', () => {
const scss = '// comment\n$pri: #C4623A;';
const vars = parseScssVariables(scss);
expect(vars).toEqual({ pri: '#C4623A' });
});
it('handles values with spaces and units', () => {
const scss = '$shadow-sm: 0 1px 4px rgba(45, 42, 38, 0.06);\n$r: 16px;';
const vars = parseScssVariables(scss);
expect(vars['shadow-sm']).toBe('0 1px 4px rgba(45, 42, 38, 0.06)');
expect(vars['r']).toBe('16px');
});
});
describe('parseTokensFromScss', () => {
it('extracts --tk-* CSS variables from page selector', () => {
const scss = `page {
--tk-font-h1: 28px;
--tk-font-body: 16px;
}`;
const tokens = parseTokensFromScss(scss, 'page');
expect(tokens).toEqual({ 'font-h1': '28px', 'font-body': '16px' });
});
it('extracts from class selector', () => {
const scss = `.elder-mode {
--tk-font-h1: 32px;
}`;
const tokens = parseTokensFromScss(scss, '.elder-mode');
expect(tokens).toEqual({ 'font-h1': '32px' });
});
it('captures SCSS variable references as-is for later resolution', () => {
const scss = `page {
--tk-pri: #{$pri};
--tk-font-body: 16px;
}`;
const tokens = parseTokensFromScss(scss, 'page');
expect(tokens['pri']).toBe('#{$pri}');
expect(tokens['font-body']).toBe('16px');
});
it('returns empty object when selector not found', () => {
const scss = `page { --tk-font-h1: 28px; }`;
const tokens = parseTokensFromScss(scss, '.nonexistent');
expect(tokens).toEqual({});
});
});
describe('resolveTokenValues', () => {
it('replaces SCSS variable references with actual values', () => {
const tokensScss = `page {
--tk-pri: #{$pri};
}`;
const varsScss = '$pri: #C4623A;';
const result = resolveTokenValues(tokensScss, varsScss, 'page');
expect(result).toEqual({ pri: '#C4623A' });
});
it('handles plain values without SCSS references', () => {
const tokensScss = `page {
--tk-card-radius: 16px;
--tk-font-body: 16px;
}`;
const varsScss = '$r: 16px;';
const result = resolveTokenValues(tokensScss, varsScss, 'page');
expect(result['card-radius']).toBe('16px');
expect(result['font-body']).toBe('16px');
});
it('handles multiple variable references', () => {
const tokensScss = `page {
--tk-pri: #{$pri};
--tk-pri-l: #{$pri-l};
--tk-font-body: 16px;
}`;
const varsScss = '$pri: #C4623A;\n$pri-l: #F0DDD4;';
const result = resolveTokenValues(tokensScss, varsScss, 'page');
expect(result['pri']).toBe('#C4623A');
expect(result['pri-l']).toBe('#F0DDD4');
expect(result['font-body']).toBe('16px');
});
});

View File

@@ -0,0 +1,133 @@
// scripts/generate-tokens.ts
// 从 src/styles/tokens.scss + variables.scss 解析 CSS 变量,
// 输出 src/styles/token-values.ts 供 JS 运行时使用
import { readFileSync, writeFileSync } from 'fs';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
interface TokenMap {
[key: string]: string;
}
/** 解析 SCSS 文件中的 CSS 变量声明 */
function parseTokensFromScss(
scssContent: string,
selector: string = 'page',
): TokenMap {
const tokens: TokenMap = {};
const blockRegex = new RegExp(
`${selector.replace('.', '\\.')}\\s*\\{([\\s\\S]+?)\\n\\}`,
);
const blockMatch = scssContent.match(blockRegex);
if (!blockMatch) return tokens;
const varRegex = /--tk-([\w-]+)\s*:\s*([^;]+);/g;
let match: RegExpExecArray | null;
while ((match = varRegex.exec(blockMatch[1])) !== null) {
tokens[match[1]] = match[2].trim();
}
return tokens;
}
/** 解析 SCSS 变量(如 $pri: #C4623A用于替换 #{...} 引用 */
function parseScssVariables(scssContent: string): Record<string, string> {
const vars: Record<string, string> = {};
const regex = /\$([\w-]+)\s*:\s*([^;]+);/g;
let match: RegExpExecArray | null;
while ((match = regex.exec(scssContent)) !== null) {
vars[match[1]] = match[2].trim();
}
return vars;
}
/** 解析 tokens.scss替换 SCSS 变量引用 */
function resolveTokenValues(
tokensContent: string,
variablesContent: string,
selector: string,
): TokenMap {
const scssVars = parseScssVariables(variablesContent);
const rawTokens = parseTokensFromScss(tokensContent, selector);
const resolved: TokenMap = {};
for (const [key, value] of Object.entries(rawTokens)) {
let resolvedValue = value;
resolvedValue = resolvedValue.replace(
/#\{\$([\w-]+)\}/g,
(_, varName) => scssVars[varName] || '',
);
resolved[key] = resolvedValue;
}
return resolved;
}
function generateTokenFile(
tokensPath: string,
variablesPath: string,
outputPath: string,
): void {
const tokensContent = readFileSync(tokensPath, 'utf-8');
const variablesContent = readFileSync(variablesPath, 'utf-8');
const normalTokens = resolveTokenValues(
tokensContent,
variablesContent,
'page',
);
const elderTokens = resolveTokenValues(
tokensContent,
variablesContent,
'.elder-mode',
);
const doctorTokens = resolveTokenValues(
tokensContent,
variablesContent,
'.doctor-mode',
);
const output = `// Auto-generated by scripts/generate-tokens.ts — DO NOT EDIT
// Generated at: ${new Date().toISOString()}
export const TOKEN_VALUES = ${JSON.stringify(normalTokens, null, 2)} as const;
export const ELDER_TOKEN_OVERRIDES = ${JSON.stringify(elderTokens, null, 2)} as const;
export const DOCTOR_TOKEN_OVERRIDES = ${JSON.stringify(doctorTokens, null, 2)} as const;
// Canvas 专用:字号(数字,单位 px
export const CANVAS_FONT_NORMAL = {
yLabel: 10,
xLabel: 10,
tooltip: 12,
pointNormal: 3,
pointAbnormal: 5,
} as const;
export const CANVAS_FONT_ELDER = {
yLabel: 14,
xLabel: 14,
tooltip: 16,
pointNormal: 5,
pointAbnormal: 8,
} as const;
`;
writeFileSync(outputPath, output, 'utf-8');
console.log(`[generate-tokens] Generated ${outputPath}`);
console.log(` Normal: ${Object.keys(normalTokens).length} tokens`);
console.log(` Elder: ${Object.keys(elderTokens).length} overrides`);
console.log(` Doctor: ${Object.keys(doctorTokens).length} overrides`);
}
// CLI 入口
if (process.argv[1]?.endsWith('generate-tokens.ts')) {
const root = resolve(__dirname, '..');
generateTokenFile(
resolve(root, 'src/styles/tokens.scss'),
resolve(root, 'src/styles/variables.scss'),
resolve(root, 'src/styles/token-values.ts'),
);
}
export { parseTokensFromScss, parseScssVariables, resolveTokenValues, generateTokenFile };