agentcss 0.26.0 → 0.27.0

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.
Files changed (44) hide show
  1. package/01-tokens/README.md +1 -1
  2. package/01-tokens/responsive-demo.html +1 -1
  3. package/01-tokens/tokens.core.css +1 -1
  4. package/01-tokens/tokens.css +2 -2
  5. package/02-primitives/date-range/date-range.js +1 -1
  6. package/02-primitives/timeline/demo.html +1 -1
  7. package/03-patterns/drawer/demo.html +1 -1
  8. package/03-patterns/overlay/overlay.css +1 -1
  9. package/03-patterns/states/states.css +1 -1
  10. package/04-recipes/app-shell/README.md +1 -1
  11. package/04-recipes/app-shell/demo.html +1 -1
  12. package/04-recipes/longform/content.html +6 -6
  13. package/04-recipes/report/demo.html +2 -2
  14. package/04-recipes/table/README.md +1 -1
  15. package/04-recipes/table/demo.html +1 -1
  16. package/09-assets/bar/bar.css +1 -1
  17. package/09-assets/model-viewer/model-viewer.js +1 -1
  18. package/AGENTS.md +3 -1
  19. package/CHANGELOG.md +103 -5
  20. package/CONTRIBUTING.md +44 -3
  21. package/README.md +2 -0
  22. package/README.zh-CN.md +2 -0
  23. package/START-HERE.md +12 -5
  24. package/ai/START-HERE.md +40 -6
  25. package/ai/check-html.js +658 -0
  26. package/ai/cli.js +557 -67
  27. package/ai/components.json +1 -1
  28. package/ai/pdf.js +2 -2
  29. package/ai/recipes.json +86 -0
  30. package/ai/scaffold.js +195 -0
  31. package/ai/shot.js +345 -0
  32. package/ai/validate.js +65 -6
  33. package/dist/COSTS.md +10 -10
  34. package/docs/ACCESSIBILITY.md +2 -2
  35. package/docs/ARCHITECTURE.md +116 -0
  36. package/docs/LAYER-BASELINE.md +2 -2
  37. package/docs/PRINT.md +3 -3
  38. package/docs/RELEASE-PROCESS.md +52 -2
  39. package/docs/STABILITY.md +1 -1
  40. package/docs/VERIFY-RELEASE.md +17 -0
  41. package/index.html +3 -3
  42. package/llms-full.txt +17 -3
  43. package/llms.txt +26 -5
  44. package/package.json +2 -1
@@ -0,0 +1,658 @@
1
+ /*
2
+ * check-html.js — 检查**已经写出来的那份页面**(不是计划,是产物)
3
+ *
4
+ * ============================================================================
5
+ * 🔴 为什么需要它:契约校验通过,页面依然可能是坏的
6
+ * ----------------------------------------------------------------------------
7
+ * `ai check <doc.json>` 校验的是**文档模型**:节点类型对不对、组件在不在档位里。
8
+ * 它证明不了三件最要命的事:
9
+ *
10
+ * ① 写出来的类名在库里**根本不存在** —— 不报错、不警告,只是一点样式都没有;
11
+ * ② 页面引了 `.card` 却**没引** `card.css` —— 同上,而且更难发现:
12
+ * 类名是对的、文档也是对的,只是少了一个 `<link>`;
13
+ * ③ 语义结构缺件 —— 表格没有 `<caption>`、图没有 `alt`、标题从 h2 跳到 h4。
14
+ *
15
+ * 这三类都不会让页面"打不开",只会让它**看起来像是另一个人做的**。
16
+ * ⇒ 校验必须落在产物上,且判据必须能从文件本身算出来(不联网、不启动浏览器)。
17
+ *
18
+ * ============================================================================
19
+ * 🔴 类名白名单只有一个来源:库自己的 CSS
20
+ * ----------------------------------------------------------------------------
21
+ * 不维护第二份「合法类名清单」。理由不是洁癖:清单会和 CSS 分叉,
22
+ * 而分叉的方向永远是"清单里有、CSS 里没了"—— 那时门禁放行、页面掉样式,
23
+ * 是最难查的一种假绿。这里每次都从磁盘上的 CSS 现算(进程内缓存)。
24
+ *
25
+ * 而且检查分两级,因为两种失败的含义完全不同:
26
+ * · 类在库里、但这页没引到定义它的样式表 → **错误**(一定是漏了 <link>)
27
+ * · 类在库里也没有 → **警告**(可能是笔误,也可能是页面自己的钩子)
28
+ *
29
+ * ⚠️ 页面内联的 `<style>` 也算来源。单文件页面(把 tokens.css 内联进 <style>)
30
+ * 是这套库推荐的用法之一,把它们判成"没引样式"是纯粹的假红。
31
+ *
32
+ * ============================================================================
33
+ * 判据(每条都有反向控制,见 05-audit/html-check-gate.js)
34
+ * ----------------------------------------------------------------------------
35
+ * 错误:html-lang / meta-charset / meta-viewport / img-alt / table-caption /
36
+ * th-scope / id-duplicate / heading-skip / control-name / rel-noopener /
37
+ * class-not-linked
38
+ * 警告:class-unknown / empty-link
39
+ *
40
+ * 用法:
41
+ * const { checkPage } = require('./check-html');
42
+ * const r = checkPage('page.html', { recipe: 'report/finance' });
43
+ * if (!r.ok) r.findings.forEach(f => ...);
44
+ */
45
+ 'use strict';
46
+
47
+ var fs = require('fs');
48
+ var path = require('path');
49
+ var url = require('url');
50
+
51
+ var AI = __dirname;
52
+ var ROOT = path.join(AI, '..');
53
+
54
+ /* 库的资产层。类名白名单只从这里来 —— 与 `ai assets` 的扫描口径同一份事实。 */
55
+ var ASSET_DIRS = ['01-tokens', '02-primitives', '03-patterns', '04-recipes', '09-assets'];
56
+
57
+ /* 空元素:没有结束标签,也不进栈。 */
58
+ var VOID = {
59
+ area: 1, base: 1, br: 1, col: 1, embed: 1, hr: 1, img: 1, input: 1,
60
+ link: 1, meta: 1, param: 1, source: 1, track: 1, wbr: 1
61
+ };
62
+
63
+ /* 内容按**文本**处理的元素:里面的 `<` 不是标签,是字面量。
64
+ ⚠️ `<pre>` **不在**这一列 —— 按 HTML 规范 pre 里仍然是标记,
65
+ 所以 `<pre><div class="x">` 里的 div 是真元素(写错了就该被抓到)。 */
66
+ var RAW = { script: 1, style: 1, textarea: 1, title: 1 };
67
+
68
+ /* 判据清单。level 决定 `ok`;hint 是**改法**(报错只说"不对"等于把活儿推回给调用方)。 */
69
+ var RULES = {
70
+ 'html-lang': {
71
+ level: 'error', title: '<html> 缺 lang',
72
+ hint: '写 <html lang="zh-CN">。读屏与断词按它决定发音与断行,缺了会用系统默认。'
73
+ },
74
+ 'meta-charset': {
75
+ level: 'error', title: '缺 <meta charset>',
76
+ hint: '在 <head> 最前面加 <meta charset="utf-8">,否则中文在部分环境下会乱码。'
77
+ },
78
+ 'meta-viewport': {
79
+ level: 'error', title: '缺 <meta name="viewport">',
80
+ hint: '加 <meta name="viewport" content="width=device-width, initial-scale=1">。'
81
+ + '没有它,手机按 980px 排版再整体缩小,字会小到读不了。'
82
+ },
83
+ 'img-alt': {
84
+ level: 'error', title: '<img> 缺 alt',
85
+ hint: '装饰性图片写 alt=""(空字符串是有意的声明),有信息的图写描述。'
86
+ + '缺这个属性时读屏会念出整个文件名。'
87
+ },
88
+ 'table-name': {
89
+ level: 'error', title: '<table> 没有名字',
90
+ hint: '给表格一个可访问名称:<caption>…</caption>,或 <table aria-label="…">。'
91
+ + '读屏用户靠它知道"这张表比的是什么";一张没有名字的表,念出来是一串没有上下文的数字。'
92
+ },
93
+ 'th-scope': {
94
+ level: 'error', title: '<th> 缺 scope',
95
+ hint: '写 scope="col" 或 scope="row"。读屏靠它把单元格和表头对起来。'
96
+ },
97
+ 'id-duplicate': {
98
+ level: 'error', title: 'id 重复',
99
+ hint: '同一份文档里 id 必须唯一:锚点跳转、<label for>、aria-labelledby 都按它找元素。'
100
+ },
101
+ 'heading-skip': {
102
+ level: 'error', title: '标题跳级',
103
+ hint: '标题只跳一级(h2 → h3)。跳级会让"按标题导航"的人以为中间少了一节。'
104
+ + '需要更小的字,用字号类,不要用 h5。'
105
+ },
106
+ 'control-name': {
107
+ level: 'error', title: '表单控件没有可读名称',
108
+ hint: '三选一:<label for="x"> + id、aria-label、或把控件包进 <label> 里。'
109
+ },
110
+ 'rel-noopener': {
111
+ level: 'error', title: 'target="_blank" 缺 rel="noopener"',
112
+ hint: '写 rel="noopener"(或 noreferrer):否则新窗口能通过 window.opener 改这一页。'
113
+ },
114
+ 'class-not-linked': {
115
+ level: 'error', title: '类名库里有,但这页没引到定义它的样式表',
116
+ hint: '补一条 <link rel="stylesheet">。这类缺失不报错、不警告,只是一点样式都不生效。'
117
+ },
118
+ 'sheet-unreadable': {
119
+ level: 'error', title: '样式表一个都读不到',
120
+ hint: '检查 <link href> 的相对路径:它是相对**这个页面**解析的。'
121
+ + '页面在项目根目录时,库的路径通常是 node_modules/agentcss/…;'
122
+ + '换过位置就得改前缀(ai scaffold 的 --css-prefix 就是为这个准备的)。'
123
+ },
124
+ 'class-unknown': {
125
+ level: 'warn', title: '类名库里没有',
126
+ hint: '若是笔误就改掉;若是页面自己的类,在页内 <style> 里定义它(否则它什么也不会做)。'
127
+ },
128
+ 'empty-link': {
129
+ level: 'warn', title: '<a> 没有 href',
130
+ hint: '占位链接写 href="#" 也点不动。要么给真实地址,要么换成 <button>。'
131
+ },
132
+ 'recipe-root': {
133
+ level: 'error', title: '版式根类不对',
134
+ hint: '--recipe 指定的版式必须真的落在页面上(例:report/finance 要 .rep.rep--finance)。'
135
+ },
136
+ 'recipe-required': {
137
+ level: 'error', title: '这一版必须有的一件东西不见了',
138
+ hint: '见 ai/recipes.json:每条都是"这个阅读场景缺了它就不成立"的东西。'
139
+ }
140
+ };
141
+
142
+ /* ============================================================================
143
+ * 分词
144
+ * ============================================================================
145
+ * 不引入解析器(零依赖是硬约束),但也不做"正则找 class= 属性"那种事:
146
+ * 那个做法会把 <script> 里的模板字符串、注释里的示例、<style> 里的选择器
147
+ * 全部当成现状。这套库在这上面翻过三次车(判据把注释当代码),不再重复。
148
+ */
149
+ function lineIndex(src) {
150
+ var nl = [];
151
+ for (var i = 0; i < src.length; i++) if (src.charCodeAt(i) === 10) nl.push(i);
152
+ return function (idx) {
153
+ var lo = 0, hi = nl.length;
154
+ while (lo < hi) { var mid = (lo + hi) >> 1; if (nl[mid] < idx) lo = mid + 1; else hi = mid; }
155
+ return lo + 1;
156
+ };
157
+ }
158
+
159
+ /** 解析 `name=value` 序列,返回 {attrs, end, selfClose}。`end` 指向 `>` 之后。 */
160
+ function parseAttrs(src, from) {
161
+ var attrs = Object.create(null);
162
+ var j = from, n = src.length, selfClose = false;
163
+ while (j < n) {
164
+ while (j < n && /\s/.test(src[j])) j++;
165
+ if (j >= n) break;
166
+ if (src[j] === '>') { j++; break; }
167
+ if (src[j] === '/' && src[j + 1] === '>') { selfClose = true; j += 2; break; }
168
+ var k = j;
169
+ while (k < n && !/[\s>=\/]/.test(src[k])) k++;
170
+ if (k === j) { j++; continue; } // 防死循环:不认识的字节直接跳过一个
171
+ var name = src.slice(j, k).toLowerCase();
172
+ j = k;
173
+ while (j < n && /\s/.test(src[j])) j++;
174
+ var val = '';
175
+ if (src[j] === '=') {
176
+ j++;
177
+ while (j < n && /\s/.test(src[j])) j++;
178
+ var q = src[j];
179
+ if (q === '"' || q === "'") {
180
+ var end = src.indexOf(q, j + 1);
181
+ var stop = end < 0 ? n : end;
182
+ val = src.slice(j + 1, stop);
183
+ j = end < 0 ? n : end + 1;
184
+ } else {
185
+ var e = j;
186
+ while (e < n && !/[\s>]/.test(src[e])) e++;
187
+ val = src.slice(j, e);
188
+ j = e;
189
+ }
190
+ }
191
+ /* 重复属性按 HTML 规范取**第一个**(浏览器就是这么做的) */
192
+ if (!(name in attrs)) attrs[name] = val;
193
+ }
194
+ return { attrs: attrs, end: j, selfClose: selfClose };
195
+ }
196
+
197
+ /**
198
+ * @param {string} src
199
+ * @returns {Array<{type:string, tag?:string, attrs?:object, line:number, at:number, endAt:number, content?:string}>}
200
+ */
201
+ function tokenize(src) {
202
+ var lineAt = lineIndex(src);
203
+ var nodes = [];
204
+ var i = 0, n = src.length;
205
+
206
+ while (i < n) {
207
+ var lt = src.indexOf('<', i);
208
+ if (lt < 0) break;
209
+ i = lt;
210
+
211
+ if (src.slice(i, i + 4) === '<!--') {
212
+ var ce = src.indexOf('-->', i + 4);
213
+ i = ce < 0 ? n : ce + 3;
214
+ continue;
215
+ }
216
+ /* <!doctype> / <?xml / <![CDATA[ —— 都不是元素 */
217
+ if (src[i + 1] === '!' || src[i + 1] === '?') {
218
+ var de = src.indexOf('>', i);
219
+ i = de < 0 ? n : de + 1;
220
+ continue;
221
+ }
222
+
223
+ var mEnd = /^<\/([a-zA-Z][a-zA-Z0-9:-]*)\s*>/.exec(src.slice(i, i + 200));
224
+ if (mEnd) {
225
+ nodes.push({ type: 'end', tag: mEnd[1].toLowerCase(), line: lineAt(i), at: i, endAt: i + mEnd[0].length });
226
+ i += mEnd[0].length;
227
+ continue;
228
+ }
229
+
230
+ var mStart = /^<([a-zA-Z][a-zA-Z0-9:-]*)/.exec(src.slice(i, i + 200));
231
+ if (!mStart) { i++; continue; } // 孤立的 '<'(正文里的比较符号)
232
+
233
+ var tag = mStart[1].toLowerCase();
234
+ var parsed = parseAttrs(src, i + mStart[0].length);
235
+ var node = {
236
+ type: 'start', tag: tag, attrs: parsed.attrs,
237
+ line: lineAt(i), at: i, endAt: parsed.end, selfClose: parsed.selfClose
238
+ };
239
+ nodes.push(node);
240
+ i = parsed.end;
241
+
242
+ if (RAW[tag]) {
243
+ var closeRe = new RegExp('</' + tag + '\\s*>', 'i');
244
+ var rest = src.slice(i);
245
+ var mm = closeRe.exec(rest);
246
+ var content = mm ? rest.slice(0, mm.index) : rest;
247
+ if (tag === 'style') node.content = content;
248
+ var stopAt = mm ? i + mm.index : n;
249
+ if (mm) nodes.push({ type: 'end', tag: tag, line: lineAt(stopAt), at: stopAt, endAt: stopAt + mm[0].length });
250
+ i = mm ? stopAt + mm[0].length : n;
251
+ }
252
+ }
253
+ return nodes;
254
+ }
255
+
256
+ /* ============================================================================
257
+ * 样式表 → 类名
258
+ * ============================================================================ */
259
+
260
+ /** 从 CSS 文本里取类名。先剥注释:`/* .ghost *​/` 不是定义。 */
261
+ function classesInCss(text) {
262
+ var s = String(text).replace(/\/\*[\s\S]*?\*\//g, '');
263
+ var out = [];
264
+ var re = /\.(-?[_a-zA-Z][\w-]*)/g, m;
265
+ while ((m = re.exec(s))) out.push(m[1]);
266
+ return out;
267
+ }
268
+
269
+ function importsInCss(text) {
270
+ var s = String(text).replace(/\/\*[\s\S]*?\*\//g, '');
271
+ var out = [];
272
+ var re = /@import\s+(?:url\(\s*)?['"]?([^'")]+)['"]?\s*\)?/g, m;
273
+ while ((m = re.exec(s))) out.push(m[1].trim());
274
+ return out;
275
+ }
276
+
277
+ /** 库的类名地图:class → 定义它的文件(相对库根)。进程内只算一次。 */
278
+ var LIB = null;
279
+ function libraryClassMap() {
280
+ if (LIB) return LIB;
281
+ var map = Object.create(null);
282
+ var files = 0;
283
+
284
+ function walk(dir) {
285
+ var names;
286
+ try { names = fs.readdirSync(dir); } catch (e) { return; }
287
+ for (var i = 0; i < names.length; i++) {
288
+ var n = names[i];
289
+ if (n.charAt(0) === '_' || n.charAt(0) === '.') continue;
290
+ var p = path.join(dir, n);
291
+ var st;
292
+ try { st = fs.statSync(p); } catch (e) { continue; }
293
+ if (st.isDirectory()) { walk(p); continue; }
294
+ if (!/\.css$/.test(n)) continue;
295
+ files++;
296
+ var rel = path.relative(ROOT, p).split(path.sep).join('/');
297
+ var cls = classesInCss(fs.readFileSync(p, 'utf8'));
298
+ for (var k = 0; k < cls.length; k++) {
299
+ if (!map[cls[k]]) map[cls[k]] = [];
300
+ if (map[cls[k]].indexOf(rel) < 0) map[cls[k]].push(rel);
301
+ }
302
+ }
303
+ }
304
+ for (var d = 0; d < ASSET_DIRS.length; d++) walk(path.join(ROOT, ASSET_DIRS[d]));
305
+ LIB = { map: map, files: files };
306
+ return LIB;
307
+ }
308
+
309
+ /** 把 `<link href>` / `@import` 的写法解析成本地绝对路径;远程/内联返回 null。 */
310
+ function resolveSheet(href, baseDir) {
311
+ if (!href) return null;
312
+ var h = String(href).trim();
313
+ if (/^data:/i.test(h)) return null;
314
+ if (/^https?:/i.test(h)) return { remote: h };
315
+ if (/^file:/i.test(h)) {
316
+ try { return { file: url.fileURLToPath(h) }; } catch (e) { return null; }
317
+ }
318
+ if (h.charAt(0) === '/' || /^[a-zA-Z]:[\\/]/.test(h)) return { file: h };
319
+ return { file: path.resolve(baseDir, h.replace(/[?#].*$/, '')) };
320
+ }
321
+
322
+ function collectClasses(absFile, seen, into, depth) {
323
+ if (depth > 3 || seen[absFile]) return false;
324
+ seen[absFile] = 1;
325
+ var txt;
326
+ try { txt = fs.readFileSync(absFile, 'utf8'); } catch (e) { return false; }
327
+ classesInCss(txt).forEach(function (c) { into[c] = 1; });
328
+ importsInCss(txt).forEach(function (spec) {
329
+ var r = resolveSheet(spec, path.dirname(absFile));
330
+ if (r && r.file) collectClasses(r.file, seen, into, depth + 1);
331
+ });
332
+ return true;
333
+ }
334
+
335
+ /* ============================================================================
336
+ * 检查
337
+ * ============================================================================ */
338
+
339
+ function stripTags(html) {
340
+ return String(html).replace(/<[^>]*>/g, ' ').replace(/&[a-z#0-9]+;/gi, ' ').replace(/\s+/g, ' ').trim();
341
+ }
342
+
343
+ function checkPage(file, opts) {
344
+ opts = opts || {};
345
+ var abs = path.resolve(file);
346
+ var src;
347
+ try {
348
+ src = fs.readFileSync(abs, 'utf8');
349
+ } catch (e) {
350
+ return { ok: false, code: 'unreadable', message: '读不到文件:' + abs };
351
+ }
352
+ var pageDir = path.dirname(abs);
353
+ var nodes = tokenize(src);
354
+ var findings = [];
355
+ var notes = [];
356
+
357
+ function add(rule, line, msg, extra) {
358
+ var meta = RULES[rule];
359
+ /* `level: 'off'` 是给门禁做**元反向控制**用的:关掉一条判据,夹具里对应
360
+ 那一条必须消失 —— 这样才能证明每条命中确实由它自己产生,
361
+ 而不是被邻居顺手抓到(那种"覆盖"在改判据时会静默失效)。 */
362
+ if (!meta || meta.level === 'off') return;
363
+ var f = { rule: rule, level: meta.level, line: line, msg: msg };
364
+ if (extra) for (var k in extra) f[k] = extra[k];
365
+ findings.push(f);
366
+ }
367
+
368
+ /* ---------- ① 文档级 ---------- */
369
+ var htmlLang = null, sawHtml = false, hasCharset = false, hasViewport = false;
370
+
371
+ /* ---------- ② 结构 ---------- */
372
+ var stack = [];
373
+ var lastHeading = 0;
374
+ var ids = Object.create(null);
375
+ var labelFor = Object.create(null);
376
+ var tables = [];
377
+ var usedClasses = Object.create(null);
378
+ var firstUseLine = Object.create(null);
379
+
380
+ function inLabel() {
381
+ for (var i = stack.length - 1; i >= 0; i--) if (stack[i].tag === 'label') return true;
382
+ return false;
383
+ }
384
+
385
+ /* `aria-hidden="true"` 的子树**不在无障碍树里**。
386
+ 对它们要求"可访问名称"是无意义的:读屏根本不会念到,
387
+ 而补一个 aria-label 也不会让它被念到。演示页的"✅/❌ 对照"就是这样:
388
+ 两块表刻意对读屏隐藏,它们要表达的是外观差异,不是数据。 */
389
+ function inHidden() {
390
+ for (var i = stack.length - 1; i >= 0; i--) if (stack[i].hid) return true;
391
+ return false;
392
+ }
393
+
394
+ for (var pass = 0; pass < 2; pass++) {
395
+ /* 两趟:第一趟收集 <label for> 与 id(label 可能在控件之后),第二趟判控件名。
396
+ 同一份 nodes 走两遍比"先收集再回填"更不容易漏。
397
+ ⚠️ 每趟都要把栈清零:上一趟留下的残余会让 inLabel() 判错。 */
398
+ stack.length = 0;
399
+ lastHeading = 0;
400
+ tables.length = 0;
401
+ for (var idx = 0; idx < nodes.length; idx++) {
402
+ var nd = nodes[idx];
403
+ if (nd.type === 'end') {
404
+ /* 表格在**闭合时**判:<caption> 可能出现在任何位置 */
405
+ if (nd.tag === 'table') {
406
+ var tb = tables.pop();
407
+ if (tb && !tb.name && !tb.hidden) {
408
+ add('table-name', tb.line, '<table> 既没有 <caption>,也没有 aria-label');
409
+ }
410
+ }
411
+ var found = -1;
412
+ for (var s = stack.length - 1; s >= 0; s--) {
413
+ if (stack[s].tag === nd.tag) { found = s; break; }
414
+ }
415
+ if (found >= 0) stack.length = found;
416
+ continue;
417
+ }
418
+ if (nd.type !== 'start') continue;
419
+ var tag = nd.tag, a = nd.attrs;
420
+
421
+ if (pass === 0) {
422
+ /* ⚠️ 属性不存在时 `a.lang` 是 undefined,不是 '':直接拿去和 null 比
423
+ 会让"没有 lang"这条静默漏掉(门禁的反例集合当场抓到了这个写法)。 */
424
+ if (tag === 'html') { sawHtml = true; htmlLang = ('lang' in a) ? String(a.lang) : ''; }
425
+ if (tag === 'meta') {
426
+ if (a.charset) hasCharset = true;
427
+ if (String(a.name || '').toLowerCase() === 'viewport') hasViewport = true;
428
+ }
429
+ if (a['class']) {
430
+ var cs = String(a['class']).split(/\s+/);
431
+ for (var ci = 0; ci < cs.length; ci++) {
432
+ var c = cs[ci];
433
+ if (!c) continue;
434
+ /* 模板占位({{x}} / ${x} / <% %>)不是类名,是构建期才有的东西 */
435
+ if (/[{}<>$%=]/.test(c)) continue;
436
+ if (!usedClasses[c]) { usedClasses[c] = 1; firstUseLine[c] = nd.line; }
437
+ }
438
+ }
439
+ if (a.id) {
440
+ if (ids[a.id] !== undefined) {
441
+ add('id-duplicate', nd.line, 'id="' + a.id + '" 和第 ' + ids[a.id] + ' 行重复');
442
+ } else ids[a.id] = nd.line;
443
+ }
444
+ if (tag === 'label' && a['for']) labelFor[a['for']] = 1;
445
+ } else {
446
+ if (tag === 'img' && !('alt' in a)) {
447
+ add('img-alt', nd.line, '<img> 缺 alt(src=' + String(a.src || '').slice(0, 60) + ')');
448
+ }
449
+ if (tag === 'table') {
450
+ tables.push({
451
+ name: String(a['aria-label'] || '').trim() !== ''
452
+ || String(a['aria-labelledby'] || '').trim() !== '',
453
+ hidden: inHidden() || String(a['aria-hidden'] || '').toLowerCase() === 'true',
454
+ line: nd.line
455
+ });
456
+ }
457
+ if (tag === 'caption' && tables.length) {
458
+ var closeAt = src.indexOf('</caption', nd.endAt);
459
+ var text = closeAt < 0 ? '' : stripTags(src.slice(nd.endAt, closeAt));
460
+ if (text) tables[tables.length - 1].name = true;
461
+ }
462
+ if (tag === 'th' && !a.scope && !inHidden()) add('th-scope', nd.line, '<th> 缺 scope');
463
+ if (/^h[1-6]$/.test(tag)) {
464
+ var lv = Number(tag.charAt(1));
465
+ if (lastHeading && lv > lastHeading + 1) {
466
+ add('heading-skip', nd.line, 'h' + lastHeading + ' → h' + lv + ' 跳了 ' + (lv - lastHeading - 1) + ' 级');
467
+ }
468
+ lastHeading = lv;
469
+ }
470
+ if (tag === 'a') {
471
+ if (!('href' in a) || String(a.href).trim() === '' || String(a.href).trim() === '#') {
472
+ add('empty-link', nd.line, '<a> 没有可用地址(' + (('href' in a) ? 'href="' + a.href + '"' : '缺 href') + ')');
473
+ }
474
+ if (String(a.target || '') === '_blank') {
475
+ var rel = String(a.rel || '').toLowerCase();
476
+ if (rel.indexOf('noopener') < 0 && rel.indexOf('noreferrer') < 0) {
477
+ add('rel-noopener', nd.line, 'target="_blank" 缺 rel="noopener"');
478
+ }
479
+ }
480
+ }
481
+ if (tag === 'input' || tag === 'select' || tag === 'textarea') {
482
+ var type = String(a.type || '').toLowerCase();
483
+ var hasValueAsName = (tag === 'input' && /^(submit|button|reset)$/.test(type)
484
+ && String(a.value || '').trim() !== '');
485
+ var named = String(a['aria-label'] || '').trim() !== ''
486
+ || String(a['aria-labelledby'] || '').trim() !== ''
487
+ || (a.id && labelFor[a.id])
488
+ || inLabel()
489
+ || (tag === 'input' && type === 'hidden')
490
+ || hasValueAsName;
491
+ if (!named && !inHidden()) {
492
+ add('control-name', nd.line, '<' + tag + '> 没有可读名称'
493
+ + (a.id ? '(id="' + a.id + '" 没有对应的 <label for>)' : ''));
494
+ }
495
+ }
496
+ }
497
+ if (!VOID[tag] && !nd.selfClose) {
498
+ stack.push({ tag: tag, hid: String(a['aria-hidden'] || '').toLowerCase() === 'true' });
499
+ }
500
+ }
501
+ }
502
+
503
+ /* caption 的收口在 `</table>` 那一支里(见上),这里不再补判 */
504
+
505
+ if (!sawHtml) {
506
+ add('html-lang', 1, '整个文件没有 <html> 标签');
507
+ } else if (String(htmlLang).trim() === '') {
508
+ add('html-lang', 1, '<html> 的 lang 是空的');
509
+ }
510
+ if (!hasCharset) add('meta-charset', 1, '<head> 里没有 <meta charset>');
511
+ if (!hasViewport) add('meta-viewport', 1, '没有 <meta name="viewport">');
512
+
513
+ /* ---------- ③ 类名:对样式表 ---------- */
514
+ var linked = Object.create(null);
515
+ var seen = Object.create(null);
516
+ var localSheets = 0, remoteSheets = 0, readSheets = 0, firstSheetLine = 0;
517
+ var sheetHrefs = [];
518
+
519
+ for (var li = 0; li < nodes.length; li++) {
520
+ var ln = nodes[li];
521
+ if (ln.type !== 'start' || ln.tag !== 'link') continue;
522
+ var rel = String(ln.attrs.rel || '').toLowerCase();
523
+ if (rel.indexOf('stylesheet') < 0) continue;
524
+ var r = resolveSheet(ln.attrs.href, pageDir);
525
+ if (!r) continue; // data: —— 内联样式表,跳过
526
+ if (r.remote) { remoteSheets++; continue; }
527
+ localSheets++;
528
+ if (!firstSheetLine) firstSheetLine = ln.line;
529
+ if (readSheets <= 3) sheetHrefs.push(String(ln.attrs.href));
530
+ if (collectClasses(r.file, seen, linked, 0)) readSheets++;
531
+ }
532
+
533
+ /* 引了样式表却一个都读不到 —— 这**不是**"类名缺定义",是路径整段错了。
534
+ 分开报的理由:前者要改十几个类,后者只要改一个前缀;混在一起会让人
535
+ 去改那十几个类,而真正的错在 <link> 上。 */
536
+ if (localSheets > 0 && readSheets === 0) {
537
+ add('sheet-unreadable', firstSheetLine,
538
+ '引了 ' + localSheets + ' 个样式表,一个都读不到:' + sheetHrefs.slice(0, 3).join('、')
539
+ + (localSheets > 3 ? ' 等' : ''));
540
+ }
541
+
542
+ var inline = Object.create(null);
543
+ for (var sj = 0; sj < nodes.length; sj++) {
544
+ if (nodes[sj].type === 'start' && nodes[sj].tag === 'style' && nodes[sj].content) {
545
+ classesInCss(nodes[sj].content).forEach(function (c) { inline[c] = 1; });
546
+ }
547
+ }
548
+
549
+ var lib = libraryClassMap();
550
+ var judgeLinked = localSheets > 0 && remoteSheets === 0 && readSheets > 0;
551
+ var skipped = [];
552
+
553
+ if (!judgeLinked) {
554
+ skipped.push({
555
+ rule: 'class-not-linked',
556
+ why: localSheets === 0
557
+ ? '这页没有引用任何本地样式表(单文件写法)—— 无法判断"引没引到",只能判断类名存不存在'
558
+ : readSheets === 0
559
+ ? '引用的样式表一个都没读到(见上面那条)—— 路径不通时,"类名没被引到"这个判断没有意义'
560
+ : '有 ' + remoteSheets + ' 个远程样式表读不到 —— 无法判断类名是否由它定义'
561
+ });
562
+ }
563
+
564
+ for (var cname in usedClasses) {
565
+ /* 页内样式表或本地样式表里有定义 ⇒ 这一页确实会给它样式,不论能不能判 linkage */
566
+ if (inline[cname] || linked[cname]) continue;
567
+ if (lib.map[cname]) {
568
+ /* 库里有、这页没引到 —— 只在"该页引的都是本地样式表"时才敢下这个结论 */
569
+ if (!judgeLinked) continue;
570
+ var def = lib.map[cname][0];
571
+ add('class-not-linked', firstUseLine[cname],
572
+ '.' + cname + ' 定义在 ' + def + ',这页没有引用它',
573
+ { file: def });
574
+ } else {
575
+ add('class-unknown', firstUseLine[cname], '.' + cname + ' 在库里没有定义');
576
+ }
577
+ }
578
+
579
+ /* ---------- ④ 版式必有件 ---------- */
580
+ var recipe = opts.recipe || null;
581
+ var recipeInfo = null;
582
+ if (recipe) {
583
+ var all = readRecipes();
584
+ recipeInfo = all && all.recipes && all.recipes[recipe];
585
+ if (!recipeInfo) {
586
+ return {
587
+ ok: false, code: 'unknown-recipe',
588
+ message: '未知版式:' + recipe + '(可用:' + Object.keys((all && all.recipes) || {}).join(' / ') + ')'
589
+ };
590
+ }
591
+ var rootSel = String(recipeInfo.root || '');
592
+ var rootClasses = rootSel.split('.').filter(Boolean);
593
+ var missingRoot = rootClasses.filter(function (c) { return !usedClasses[c]; });
594
+ if (missingRoot.length) {
595
+ add('recipe-root', 1, '这页不是 ' + recipe + ' 版式:缺 .' + missingRoot.join(' 与 .'));
596
+ }
597
+ (recipeInfo.requires || []).forEach(function (req) {
598
+ var cls = String(req.selector || '').split('.').filter(Boolean);
599
+ /* 必要件必须**出现在页面上**(用了这个类),只把它写进 <style> 不算 */
600
+ var miss = cls.filter(function (c) { return !usedClasses[c]; });
601
+ if (miss.length) {
602
+ add('recipe-required', 1, '缺「' + req.label + '」(' + req.selector + ')',
603
+ { label: req.label, selector: req.selector });
604
+ }
605
+ });
606
+ (recipeInfo.unchecked || []).forEach(function (u) {
607
+ notes.push({ kind: 'unchecked', label: u.label, why: u.why });
608
+ });
609
+ }
610
+
611
+ /* ---------- ⑤ 收口 ---------- */
612
+ var levelRank = { error: 0, warn: 1 };
613
+ findings.sort(function (x, y) {
614
+ return (x.line - y.line) || (levelRank[x.level] - levelRank[y.level]) || (x.rule < y.rule ? -1 : 1);
615
+ });
616
+ var counts = { error: 0, warn: 0 };
617
+ findings.forEach(function (f) { counts[f.level]++; });
618
+
619
+ var strict = !!opts.strict;
620
+ var blocking = counts.error + (strict ? counts.warn : 0);
621
+
622
+ return {
623
+ ok: blocking === 0,
624
+ mode: 'html',
625
+ file: file,
626
+ recipe: recipe || null,
627
+ counts: counts,
628
+ findings: findings,
629
+ notes: notes,
630
+ skipped: skipped,
631
+ sources: {
632
+ localSheets: localSheets,
633
+ remoteSheets: remoteSheets,
634
+ inlineStyle: Object.keys(inline).length > 0,
635
+ libraryCssFiles: lib.files,
636
+ libraryClasses: Object.keys(lib.map).length
637
+ },
638
+ rules: Object.keys(RULES)
639
+ };
640
+ }
641
+
642
+ /** 读 `ai/recipes.json`。文件不在 ⇒ 返回 null(调用方据此报"没有版式判据")。 */
643
+ function readRecipes() {
644
+ try {
645
+ return JSON.parse(fs.readFileSync(path.join(AI, 'recipes.json'), 'utf8'));
646
+ } catch (e) {
647
+ return null;
648
+ }
649
+ }
650
+
651
+ module.exports = {
652
+ checkPage: checkPage,
653
+ tokenize: tokenize,
654
+ classesInCss: classesInCss,
655
+ libraryClassMap: libraryClassMap,
656
+ readRecipes: readRecipes,
657
+ RULES: RULES
658
+ };