@dsh-workbench/module-tools 0.2.12 → 0.2.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,213 @@
1
+ // 字号口径(S5,用户 2026-10-17 定的那条):**不禁大字号,只禁"刻度外手搓"**。
2
+ //
3
+ // 口径原文:「某个卡片的重点数字大是合法的(例如套餐余额那个 28px),只禁刻度外手搓。
4
+ // 所以:先把**刻度**从令牌定义里读出来(那是唯一真相),再列出不在刻度上、且不是引用令牌的字面值。」
5
+ //
6
+ // 这个模块只**读**、只**归类**、只**拼 note**:它不判红、不改退出码、没有开关、也不写文件。
7
+ // 今天这份清单落在 `checks/style.mjs` 通过时的那几行 note 上(黄);真做成红要等棘轮那一半
8
+ // 想清楚(口径与将来的形态写在 `.scratch/font-scale/spec.md`)。
9
+ //
10
+ // 三类的口径(同名同义,全程只用这三个词):
11
+ // a) 引用令牌 —— `var(--dsw-font-*)` / `var(--wb-font-*)`,合规,只报总数;
12
+ // b) 刻度外字面值 —— 数值不在刻度上,**这份就是要的清单**;
13
+ // c) 刻度上字面值 —— 数值在刻度上但没写成 `var(...)`:最容易收进令牌的一批,但**不是错**。
14
+ //
15
+ // 为什么刻度要"读"而不是写死:令牌定义是唯一真相。外壳侧现读 `tokens/scale.css` 与
16
+ // `tokens.css`;dsh 侧在外壳仓里解析不到(见下面 DSH_ALIAS_FONT_SIZES 那段),那一半是照抄的常量。
17
+ // 两边都不发明档位 —— dsh 没有的档写在外壳自己的 token 里才算数。
18
+
19
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
20
+ import { join, relative } from 'node:path';
21
+
22
+ /**
23
+ * dsh 别名刻度 —— `--dsw-font-*-font-size` 的**值**,7 档。
24
+ *
25
+ * 出处:装出来的 dsh 那份 `@deepseek-ai/dsh-client-ui-theme/lib/client.js:1425`
26
+ * (2026-10-17 实测,dsh 0.2.1;那一整行是 CSS 变量声明),逐条抄的值:
27
+ * `--dsw-font-xxxs-11-font-size: 11px` · `--dsw-font-xxs-12-font-size: 12px` ·
28
+ * `--dsw-font-xs-13-font-size: 13px` · `--dsw-font-s-14-font-size: 14px` ·
29
+ * `--dsw-font-base-16-font-size: 16px` · `--dsw-font-l-20-font-size: 20px` ·
30
+ * `--dsw-font-xl-24-font-size: 24px`
31
+ * 两个坑,抄的时候都按**值**收、不按名字:
32
+ * · `--dsw-font-m-18` 的值其实是 **16px**(名字与值不一致),所以刻度里没有 18 这一档;
33
+ * · markdown 那几个(h1 `calc(21px + delta)` / h2 19 / h3 18 / small 12 …)是**内容字号**,
34
+ * 跟着用户的 `--dsh-content-font-size` 走,不是面板排版的档,不收。
35
+ *
36
+ * 为什么是常量而不是现读:外壳仓里根本没有 dsh 的 theme 包(只有 `dsh-client-ui-primitives`),
37
+ * `require.resolve` 直接 MODULE_NOT_FOUND;`--dsw-*` 是 dsh 实例在**运行时**注入的,源码树里没有定义。
38
+ * 哪天真要它现读(在装齐 dsh 的环境里):resolve 到 theme 之后解析同一批 `-font-size` 的值,
39
+ * 再把这份常量删掉 —— 别让它长成第二份真相。
40
+ */
41
+ export const DSH_ALIAS_FONT_SIZES = [11, 12, 13, 14, 16, 20, 24];
42
+
43
+ /** 外壳自己的字号令牌(`--wb-font-*: <n>px`)—— 刻度定义,不是手搓值。 */
44
+ const SHELL_FONT_TOKEN = /--wb-font-[a-z0-9-]+\s*:\s*([0-9]+(?:\.[0-9]+)?)px/g;
45
+
46
+ /** 从外壳的令牌文件里读出它自己的那几档(文件不在场就跳过:仓外的工作区读不到)。 */
47
+ export function shellFontScaleIn(files) {
48
+ const sizes = new Set();
49
+ for (const file of files) {
50
+ if (!existsSync(file)) continue;
51
+ for (const hit of readFileSync(file, 'utf8').matchAll(SHELL_FONT_TOKEN)) {
52
+ sizes.add(Number(hit[1]));
53
+ }
54
+ }
55
+ return sizes;
56
+ }
57
+
58
+ /** 合起来的刻度:dsh 别名那 7 档 + 外壳自己 token 里的档(现读)。 */
59
+ export function fontScaleOf(shellTokenFiles = []) {
60
+ return new Set([...DSH_ALIAS_FONT_SIZES, ...shellFontScaleIn(shellTokenFiles)]);
61
+ }
62
+
63
+ const CSS_FONT_SIZE = /(?:^|[;{\s])font-size\s*:\s*([^;{}]+)/g;
64
+ const CSS_FONT_SHORTHAND = /(?:^|[;{\s])font\s*:\s*([^;{}]+)/g;
65
+ const JS_FONT_SIZE = /\bfontSize\s*[:=]\s*\{?\s*(['"]?)([^,}'"\n]+)\1\s*\}?/g;
66
+ const FONT_TOKEN_REF = /var\(\s*(--[a-z0-9-]*font[a-z0-9-]*)/g;
67
+ // `--dsw-font-family` / `--dsw-font-mono` 也带 `font`,但它们是字族不是字号 ——
68
+ // `font: 11px/18px var(--dsw-font-family)` 那行的字号是字面的 11px,不能算"引用令牌"。
69
+ const FONT_FAMILY_TOKEN = /font-(?:family|mono)/;
70
+ const NUMERIC_VALUE = /^([0-9]*\.?[0-9]+)(px|rem|em|%)?$/;
71
+
72
+ /** 值里引用的**字号**令牌名(排掉字族/等宽那两族)。 */
73
+ function sizeTokenRefs(value) {
74
+ const names = [];
75
+ for (const hit of value.matchAll(FONT_TOKEN_REF)) {
76
+ if (!FONT_FAMILY_TOKEN.test(hit[1])) names.push(hit[1]);
77
+ }
78
+ return names;
79
+ }
80
+
81
+ /**
82
+ * 把一个字号声明的值分成 ref / size / relative / keyword。
83
+ * `relative` 是 `em` / `%`(跟着父级走,判不了刻度外)、`keyword` 是 `inherit` / `unset` 这类。
84
+ * `rem` 按 1rem = 16px 折(本仓没有根字号覆盖;dsh 也没改 `html` 的 font-size)。
85
+ */
86
+ export function classifyFontValue(value) {
87
+ const raw = String(value).trim();
88
+ const refs = sizeTokenRefs(raw);
89
+ if (refs.length > 0) return { kind: 'ref', refs };
90
+ const first = (raw.split(/\s+/)[0] ?? '').replace(/\/.*$/, '');
91
+ const hit = NUMERIC_VALUE.exec(first);
92
+ if (hit === null) return { kind: 'keyword', value: first };
93
+ const unit = hit[2] ?? 'px';
94
+ if (unit === 'em' || unit === '%') return { kind: 'relative', value: first };
95
+ const px = unit === 'rem' ? Number(hit[1]) * 16 : Number(hit[1]);
96
+ return { kind: 'size', px, value: first };
97
+ }
98
+
99
+ function isTestSource(file) {
100
+ return /(^|[\\/])__tests__[\\/]/.test(file) || /\.test\.[cm]?tsx?$/.test(file);
101
+ }
102
+
103
+ function walk(dir, acc = []) {
104
+ let entries;
105
+ try {
106
+ entries = readdirSync(dir);
107
+ } catch {
108
+ return acc;
109
+ }
110
+ for (const entry of entries) {
111
+ if (entry === 'node_modules' || entry === 'dist') continue;
112
+ const full = join(dir, entry);
113
+ if (statSync(full).isDirectory()) walk(full, acc);
114
+ else if (/\.(css|ts|tsx)$/.test(full)) acc.push(full);
115
+ }
116
+ return acc;
117
+ }
118
+
119
+ /**
120
+ * 扫一组根下的字号声明并归类。`roots` 是 `{ label, dir }`:label 是报出来的包名。
121
+ * 测试文件不扫(用例里那几处是断言用的字符串,不是界面字号);`node_modules` / `dist` 不扫。
122
+ * 返回里 `refFiles` / `litFiles` 是**涉及文件数**(同一个文件只算一次)。
123
+ */
124
+ export function auditFontSizes({ roots, scale }) {
125
+ const refs = [];
126
+ const offScale = [];
127
+ const onScale = [];
128
+ const relativeSizes = [];
129
+ const refFiles = new Set();
130
+ const litFiles = new Set();
131
+ for (const root of roots) {
132
+ for (const file of walk(root.dir)) {
133
+ if (!/\.css$/.test(file) && isTestSource(file)) continue;
134
+ let text;
135
+ try {
136
+ text = readFileSync(file, 'utf8');
137
+ } catch {
138
+ continue;
139
+ }
140
+ const rel = relative(root.dir, file);
141
+ text.split('\n').forEach((line, at) => {
142
+ const values = [];
143
+ for (const hit of line.matchAll(CSS_FONT_SIZE)) values.push({ value: hit[1], decl: hit[0] });
144
+ for (const hit of line.matchAll(CSS_FONT_SHORTHAND)) values.push({ value: hit[1], decl: hit[0] });
145
+ for (const hit of line.matchAll(JS_FONT_SIZE)) values.push({ value: hit[2], decl: hit[0] });
146
+ for (const one of values) {
147
+ const verdict = classifyFontValue(one.value);
148
+ if (verdict.kind === 'keyword') continue;
149
+ const where = {
150
+ pkg: root.label,
151
+ file: rel,
152
+ line: at + 1,
153
+ decl: one.decl.replace(/^[;{\s]+/, '').trim().slice(0, 56),
154
+ };
155
+ if (verdict.kind === 'ref') {
156
+ refFiles.add(`${root.label}:${rel}`);
157
+ refs.push(where);
158
+ continue;
159
+ }
160
+ litFiles.add(`${root.label}:${rel}`);
161
+ if (verdict.kind === 'relative') {
162
+ relativeSizes.push({ ...where, px: verdict.value });
163
+ } else if (scale.has(verdict.px)) {
164
+ onScale.push({ ...where, px: verdict.px });
165
+ } else {
166
+ offScale.push({ ...where, px: verdict.px });
167
+ }
168
+ }
169
+ });
170
+ }
171
+ }
172
+ return { refs, offScale, onScale, relative: relativeSizes, refFiles, litFiles };
173
+ }
174
+
175
+ const formatSize = (value) => String(value);
176
+
177
+ /** 最多印十来行明细:刻度外的先印(那是这份清单要的东西),再印刻度上的。 */
178
+ const DETAIL_LIMIT = 12;
179
+
180
+ function detailOf(hit, verdict) {
181
+ const head = `${hit.pkg}: ${hit.file}:${hit.line} ${hit.decl}`;
182
+ return verdict === 'off'
183
+ ? `· ${head} —— ${formatSize(hit.px)} 不在刻度上(刻度外)`
184
+ : `· ${head} —— ${formatSize(hit.px)} 在刻度上,可收进令牌(但不是错)`;
185
+ }
186
+
187
+ /**
188
+ * 拼成 `checks/style.mjs` 通过时的那几行 note:第一行汇总(各类条数 + 涉及文件数 + 指路),
189
+ * 后面最多 `DETAIL_LIMIT` 行明细。零命中时只留一行"都是 0"。
190
+ */
191
+ export function fontScaleNotes(report, scale, { doc = '.scratch/font-scale/spec.md' } = {}) {
192
+ const sizes = [...scale].sort((a, b) => a - b).map(formatSize).join(' / ');
193
+ const head =
194
+ `字号口径(黄,不判红):刻度 ${sizes}(从 --dsw-font-* 与 --wb-font-* 的**定义**读出来,` +
195
+ `出处见 ${doc});引用令牌 ${report.refs.length} 处 / ${report.refFiles.size} 文件(合规,不列);` +
196
+ `刻度外字面值 ${report.offScale.length} 处 / ${report.litFiles.size} 文件;` +
197
+ `刻度上字面值 ${report.onScale.length} 处;相对单位(em/%)判不了 ${report.relative.length} 处`;
198
+ const all = [
199
+ ...report.offScale.map((hit) => detailOf(hit, 'off')),
200
+ ...report.onScale.map((hit) => detailOf(hit, 'on')),
201
+ ];
202
+ if (all.length === 0) {
203
+ return [`${head} —— 面板的字号全走令牌,一处刻度外的字面值都没有`];
204
+ }
205
+ const notes = [head, ...all.slice(0, DETAIL_LIMIT)];
206
+ if (all.length > DETAIL_LIMIT) {
207
+ notes.push(
208
+ `…另有 ${all.length - DETAIL_LIMIT} 处(刻度外 ${report.offScale.length} / 刻度上 ${report.onScale.length}),` +
209
+ `完整清单见 ${doc}`,
210
+ );
211
+ }
212
+ return notes;
213
+ }
@@ -208,9 +208,9 @@ prefs: {
208
208
  }
209
209
  ```
210
210
 
211
- 浏览器半侧:`useHostWrite({ channel: 'prefs', resource: 'your-module' })` + `Field` / `TextField`,外面套
212
- `SettingsSection`,保存用 `SaveBar`;存失败留在原地。(输入框内边距与"标签 + 提示"排布是外壳给的默认,
213
- 不用为这个写 CSS。)语义:没写过 ≠ 选空、清空要把文件收掉、不认识的值报错并列出还认的候选。
211
+ 浏览器半侧:`useHostWrite({ channel: 'prefs', resource: 'your-module' })` + `FormRows` / `FormRow` /
212
+ `TextField`,外面套 `SettingsSection`,保存用 `SaveBar`;存失败留在原地。(标签、行距与输入框内边距都是
213
+ 外壳给的默认,不用为这个写 CSS。)语义:没写过 ≠ 选空、清空要把文件收掉、不认识的值报错并列出还认的候选。
214
214
 
215
215
  ### 靠什么跑起来:`env`
216
216