@lark-apaas/coding-steering 0.1.70-alpha.20261021140057 → 0.1.71

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 (36) hide show
  1. package/README.md +2 -2
  2. package/package.json +6 -7
  3. package/steering/nestjs-react-fullstack/skills/client-builtins-file-storage-service/SKILL.md +1 -0
  4. package/steering/nestjs-react-fullstack/skills/coding-guide/SKILL.md +2 -1
  5. package/steering/nestjs-react-fullstack/skills/design-guide/SKILL.md +164 -133
  6. package/steering/nestjs-react-fullstack/skills/plugin-guide/SKILL.md +2 -0
  7. package/steering/nestjs-react-fullstack/skills_local/coding-guide/SKILL.md +1 -0
  8. package/steering/nestjs-react-fullstack/skills_local/plugin-guide/SKILL.md +1 -0
  9. package/steering/vite-react/skills/plugin-guide/SKILL.md +5 -2
  10. package/steering/vite-react/skills/plugin-guide/references/plugin-coding-guide.md +44 -0
  11. package/steering/nestjs-react-fullstack/skills/browser-compat/SKILL.md +0 -168
  12. package/steering/nestjs-react-fullstack/skills/browser-compat/scripts/check-browser-compat.mjs +0 -799
  13. package/steering/nestjs-react-fullstack/skills/design-guide/references/broadsheet.md +0 -313
  14. package/steering/nestjs-react-fullstack/skills/design-guide/references/claude-editorial-research.md +0 -375
  15. package/steering/nestjs-react-fullstack/skills/design-guide/references/crimson-frosted-glass.md +0 -221
  16. package/steering/nestjs-react-fullstack/skills/design-guide/references/cybernetic-vault-terminal.md +0 -74
  17. package/steering/nestjs-react-fullstack/skills/design-guide/references/dashboard.md +0 -234
  18. package/steering/nestjs-react-fullstack/skills/design-guide/references/digital-e-guide.md +0 -349
  19. package/steering/nestjs-react-fullstack/skills/design-guide/references/finance-report.md +0 -169
  20. package/steering/nestjs-react-fullstack/skills/design-guide/references/flowbite.md +0 -179
  21. package/steering/nestjs-react-fullstack/skills/design-guide/references/frontend-design.md +0 -66
  22. package/steering/nestjs-react-fullstack/skills/design-guide/references/handwritten-sketch.md +0 -215
  23. package/steering/nestjs-react-fullstack/skills/design-guide/references/industry.md +0 -348
  24. package/steering/nestjs-react-fullstack/skills/design-guide/references/metabase.md +0 -226
  25. package/steering/nestjs-react-fullstack/skills/design-guide/references/minimal-jade.md +0 -205
  26. package/steering/nestjs-react-fullstack/skills/design-guide/references/nebula-crimson.md +0 -306
  27. package/steering/nestjs-react-fullstack/skills/design-guide/references/nexuscore-analytics.md +0 -74
  28. package/steering/nestjs-react-fullstack/skills/design-guide/references/organic-minimalism.md +0 -253
  29. package/steering/nestjs-react-fullstack/skills/design-guide/references/pm-spec.md +0 -234
  30. package/steering/nestjs-react-fullstack/skills/design-guide/references/pop-art.md +0 -272
  31. package/steering/nestjs-react-fullstack/skills/design-guide/references/sap-fiori-horizon.md +0 -154
  32. package/steering/nestjs-react-fullstack/skills/design-guide/references/token-mapping.md +0 -68
  33. package/steering/nestjs-react-fullstack/skills/design-guide/references/warm-elegance.md +0 -217
  34. package/steering/nestjs-react-fullstack/skills/design-guide/references/wild-orange.md +0 -203
  35. package/steering/vite-react/skills/browser-compat/SKILL.md +0 -168
  36. package/steering/vite-react/skills/browser-compat/scripts/check-browser-compat.mjs +0 -799
@@ -1,799 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * 浏览器兼容验证:扫描构建产物,判断指定浏览器能否正常运行。
4
- *
5
- * 用法(在项目根目录执行,先完成构建):
6
- * node <skill 目录>/scripts/check-browser-compat.mjs --browser "<浏览器>" [--dir <产物目录>] [--bcd <目录>]
7
- * --browser 完整 UA 字符串,或简写:chrome 93 / android 93 / webview 93 / ios 15.4 / safari 16 / firefox 92 / edge 90 / samsung 14
8
- * --dir 构建产物目录,默认 dist
9
- * --bcd (可选)已有的 @mdn/browser-compat-data 目录,离线或测试时用
10
- *
11
- * 浏览器支持数据全部运行时现查 MDN(@mdn/browser-compat-data,首次运行装到 node_modules/.cache/browser-compat/bcd,
12
- * 不改项目 package.json / lockfile),脚本里不写任何版本号。
13
- * - JS:浏览器不认识的语法会让所在脚本整份解析失败。脚本里只有一张「代码节点 → MDN 条目」的对照表(SYNTAX),
14
- * 覆盖面由测试对照 MDN 数据校验:MDN 新增语法条目时测试会失败,提醒补一行。
15
- * - CSS:反查 MDN —— 列出目标浏览器不支持的 @规则、属性、选择器、函数,再到产物里找。MDN 条目名就是 CSS 写法,不需要对照表。
16
- *
17
- * 退出码:
18
- * 0 兼容
19
- * 1 有 JS 语法在该浏览器上无法解析(所在脚本整份加载失败;打包产物里的通常表现为白屏)
20
- * 3 JS 都能解析,但有打包配置修不了的问题:运行时构造的正则执行到时会报错,或大面积样式会被丢弃
21
- * 2 参数、网络、环境问题或无法判断(看 stderr),不要据此改配置
22
- * 依赖:项目里的 Vite(用它导出的 parseAst 解析 JS、它依赖的 postcss 解析 CSS;已验证 Vite 7、8)。
23
- */
24
- import { execFileSync } from 'node:child_process';
25
- import fs from 'node:fs';
26
- import path from 'node:path';
27
- import { createRequire } from 'node:module';
28
- import { fileURLToPath, pathToFileURL } from 'node:url';
29
-
30
- // ---------- JS:代码节点 → MDN 条目(只有对应关系,版本号运行时查) ----------
31
-
32
- const isPrivate = (n) => n?.type === 'PrivateIdentifier';
33
- const isLiteral = (n) => n?.type === 'Literal';
34
- const usesAssert = (n, c) => /^\s*assert\b/.test(c.code.slice(n.source?.end, n.end));
35
-
36
- /** [MDN 条目(可多个), 节点类型, 判断条件]。正则里的语法由 analyzeRegex 识别,见 REGEX_KEYS */
37
- // prettier-ignore
38
- export const SYNTAX = [
39
- ['javascript.classes.public_class_fields', 'PropertyDefinition', (n) => !n.static && !isPrivate(n.key)],
40
- ['javascript.classes.static.class_fields', 'PropertyDefinition', (n) => n.static && !isPrivate(n.key)],
41
- ['javascript.classes.private_class_fields', 'PropertyDefinition', (n) => isPrivate(n.key)],
42
- ['javascript.classes.private_class_methods', 'MethodDefinition', (n) => isPrivate(n.key)],
43
- ['javascript.classes.private_class_fields_in', 'BinaryExpression', (n) => n.operator === 'in' && isPrivate(n.left)],
44
- ['javascript.classes.static.initialization_blocks', 'StaticBlock'],
45
- ['javascript.functions.method_definitions.async_generator_methods', 'FunctionExpression', (n, c) => n.async && n.generator && c.isMethod],
46
- ['javascript.operators.async_generator_function', 'FunctionExpression', (n, c) => n.async && n.generator && !c.isMethod],
47
- ['javascript.statements.async_generator_function', 'FunctionDeclaration', (n) => n.async && n.generator],
48
- ['javascript.grammar.hashbang_comments', 'Program', (_, c) => c.code.startsWith('#!')],
49
- ['javascript.builtins.BigInt', 'Literal', (n) => n.bigint != null],
50
- ['javascript.builtins.JSON.json_superset', 'Literal', (n, c) => typeof n.value === 'string' && /[\u2028\u2029]/.test(c.code.slice(n.start, n.end))],
51
- ['javascript.functions.rest_parameters.destructuring', 'RestElement', (n, c) => /Function/.test(c.parent?.type ?? '') && /Pattern$/.test(n.argument.type)],
52
- ['javascript.operators.generator_function.trailing_comma', 'FunctionExpression', (n, c) => n.generator && n.params.length > 0 && /^\s*,/.test(c.code.slice(n.params.at(-1).end, n.body.start))],
53
- ['javascript.grammar.numeric_separators', 'Literal', (n) => (typeof n.value === 'number' || n.bigint != null) && String(n.raw).includes('_')],
54
- ['javascript.grammar.template_literals.template_literal_revision', 'TaggedTemplateExpression', (n) => n.quasi.quasis.some((q) => q.value.cooked == null)],
55
- ['javascript.grammar.trailing_commas.trailing_commas_in_dynamic_import', 'ImportExpression', (n, c) => /,\s*\)$/.test(c.code.slice(n.start, n.end))],
56
- ['javascript.operators.await.top_level', 'AwaitExpression', (_, c) => c.fnDepth === 0],
57
- ['javascript.operators.await.top_level', 'ForOfStatement', (n, c) => n.await && c.fnDepth === 0],
58
- ['javascript.operators.destructuring.rest_in_objects', 'ObjectPattern', (n) => n.properties.some((p) => p.type === 'RestElement')],
59
- ['javascript.operators.import', 'ImportExpression'],
60
- ['javascript.operators.import.options_parameter', 'ImportExpression', (n) => n.options != null],
61
- ['javascript.operators.import_meta', 'MetaProperty', (n) => n.meta.name === 'import'],
62
- ['javascript.operators.new_target', 'MetaProperty', (n) => n.meta.name === 'new'],
63
- ['javascript.operators.logical_and_assignment', 'AssignmentExpression', (n) => n.operator === '&&='],
64
- ['javascript.operators.logical_or_assignment', 'AssignmentExpression', (n) => n.operator === '||='],
65
- ['javascript.operators.nullish_coalescing_assignment', 'AssignmentExpression', (n) => n.operator === '??='],
66
- ['javascript.operators.nullish_coalescing', 'LogicalExpression', (n) => n.operator === '??'],
67
- ['javascript.operators.optional_chaining', 'ChainExpression'],
68
- [['javascript.operators.object_initializer.spread_properties', 'javascript.operators.spread.spread_in_object_literals'], 'ObjectExpression', (n) => n.properties.some((p) => p.type === 'SpreadElement')],
69
- [['javascript.statements.for_await_of', 'javascript.statements.for_of.async_iterators'], 'ForOfStatement', (n) => n.await],
70
- ['javascript.statements.try_catch.optional_catch_binding', 'CatchClause', (n) => !n.param],
71
- ['javascript.statements.using', 'VariableDeclaration', (n) => n.kind === 'using'],
72
- ['javascript.statements.await_using', 'VariableDeclaration', (n) => n.kind === 'await using'],
73
- ['javascript.statements.export.namespace', 'ExportAllDeclaration', (n) => n.exported != null],
74
- ['javascript.statements.export.arbitrary_module_namespace_identifier_names', 'ExportSpecifier', (n) => isLiteral(n.exported) || isLiteral(n.local)],
75
- ['javascript.statements.export.arbitrary_module_namespace_identifier_names', 'ExportAllDeclaration', (n) => isLiteral(n.exported)],
76
- ['javascript.statements.import.arbitrary_module_namespace_identifier_names', 'ImportSpecifier', (n) => isLiteral(n.imported)],
77
- ...['ImportDeclaration', 'ExportNamedDeclaration', 'ExportAllDeclaration'].flatMap((type) => [
78
- ['javascript.statements.import.import_attributes', type, (n, c) => n.attributes?.length > 0 && !usesAssert(n, c)],
79
- ['javascript.statements.import.import_assertions', type, (n, c) => n.attributes?.length > 0 && usesAssert(n, c)],
80
- ]),
81
- ];
82
-
83
- export const REGEX_KEYS = {
84
- lookbehind: 'javascript.regular_expressions.lookbehind_assertion',
85
- namedGroup: 'javascript.regular_expressions.named_capturing_group',
86
- duplicateNames:
87
- 'javascript.regular_expressions.named_capturing_group.duplicate_named_capturing_groups',
88
- namedBackreference: 'javascript.regular_expressions.named_backreference',
89
- modifier: 'javascript.regular_expressions.modifier',
90
- unicodeProperty: 'javascript.regular_expressions.unicode_character_class_escape',
91
- s: 'javascript.builtins.RegExp.dotAll',
92
- d: 'javascript.builtins.RegExp.hasIndices',
93
- v: 'javascript.builtins.RegExp.unicodeSets',
94
- };
95
-
96
- /** 逐字符扫描正则,返回用到的 MDN 条目:跳过转义字符,字符类 [...] 里的内容不当作分组 */
97
- export function analyzeRegex(pattern, flags = '') {
98
- const out = new Set(
99
- [...flags].filter((f) => REGEX_KEYS[f] && f.length === 1).map((f) => REGEX_KEYS[f]),
100
- );
101
- const unicode = flags.includes('u') || flags.includes('v');
102
- const names = new Map();
103
- let backref = false;
104
- let classDepth = 0;
105
- for (let i = 0; i < pattern.length; i++) {
106
- const c = pattern[i];
107
- if (c === '\\') {
108
- const n = pattern[i + 1];
109
- if (unicode && (n === 'p' || n === 'P') && pattern[i + 2] === '{')
110
- out.add(REGEX_KEYS.unicodeProperty);
111
- if (n === 'k' && pattern[i + 2] === '<') backref = true;
112
- i++;
113
- } else if (c === '[') {
114
- classDepth = flags.includes('v') ? classDepth + 1 : 1; // 只有 v 标志下字符类能嵌套
115
- } else if (c === ']' && classDepth > 0) {
116
- classDepth--;
117
- } else if (classDepth === 0 && c === '(' && pattern[i + 1] === '?') {
118
- const rest = pattern.slice(i + 2);
119
- let m;
120
- if (rest.startsWith('<=') || rest.startsWith('<!')) out.add(REGEX_KEYS.lookbehind);
121
- else if ((m = rest.match(/^<([^>]+)>/))) names.set(m[1], (names.get(m[1]) || 0) + 1);
122
- else if (rest[0] !== ':' && /^[ims]*(?:-[ims]+)?:/.test(rest)) out.add(REGEX_KEYS.modifier);
123
- }
124
- }
125
- if (names.size) out.add(REGEX_KEYS.namedGroup);
126
- if ([...names.values()].some((n) => n > 1)) out.add(REGEX_KEYS.duplicateNames);
127
- if (backref && (names.size || unicode)) out.add(REGEX_KEYS.namedBackreference); // 否则 \k 只是普通转义
128
- return out;
129
- }
130
-
131
- // ---------- 通用工具 ----------
132
-
133
- class UsageError extends Error {}
134
- const fail = (msg) => {
135
- throw new UsageError(msg);
136
- };
137
-
138
- export function cmpVersion(a, b) {
139
- const pa = String(a).split('.').map(Number);
140
- const pb = String(b).split('.').map(Number);
141
- for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
142
- const d = (pa[i] || 0) - (pb[i] || 0);
143
- if (d) return d;
144
- }
145
- return 0;
146
- }
147
-
148
- /** 浏览器 version 是否满足最低版本 need;need === false 表示该浏览器任何版本都不支持 */
149
- const supports = (version, need) => need !== false && cmpVersion(version, need) >= 0;
150
-
151
- /**
152
- * 从 MDN 条目取「最早能用」的版本:排除需开 flag、带前缀、换名的实现;部分实现也算。
153
- * 返回 null 表示 MDN 里没有这个条目或没有该浏览器的数据(调用方当作无法判断);false 表示不支持。
154
- */
155
- export function minVersion(bcd, key, browser) {
156
- const support = key.split('.').reduce((o, k) => o?.[k], bcd)?.__compat?.support;
157
- if (!support || !(browser in support)) return null;
158
- const versions = []
159
- .concat(support[browser])
160
- .filter(
161
- (e) =>
162
- !e.flags &&
163
- !e.prefix &&
164
- !e.alternative_name &&
165
- /^≤?\d+(\.\d+)*$/.test(String(e.version_added)),
166
- )
167
- .map((e) => e.version_added.replace(/^≤/, ''))
168
- .sort(cmpVersion);
169
- return versions[0] ?? false;
170
- }
171
-
172
- /** MDN 条目的说明文字(去掉 HTML) */
173
- function describe(bcd, key) {
174
- const d = key.split('.').reduce((o, k) => o?.[k], bcd)?.__compat?.description;
175
- const text = (d || key.split('.').pop().replace(/_/g, ' ')).replace(/<[^>]+>/g, '');
176
- return text
177
- .replace(/&lt;/g, '<')
178
- .replace(/&gt;/g, '>')
179
- .replace(/&amp;/g, '&')
180
- .replace(/&quot;/g, '"');
181
- }
182
-
183
- const BROWSER_ALIASES = {
184
- chrome: 'chrome',
185
- android: 'chrome_android',
186
- webview: 'webview_android',
187
- edge: 'edge',
188
- firefox: 'firefox',
189
- safari: 'safari',
190
- ios: 'safari_ios',
191
- samsung: 'samsunginternet_android',
192
- };
193
-
194
- /** 识别浏览器;返回 { id, label, version },无法识别返回 null,能识别但无法验证的返回 { error } */
195
- export function parseBrowser(input) {
196
- if (!input) return null;
197
- const ua = String(input).trim();
198
- let m;
199
- if (
200
- (m = ua.match(
201
- /^(chrome|android|webview|edge|firefox|safari|ios|samsung)\s*v?(\d+(?:\.\d+)*)$/i,
202
- ))
203
- ) {
204
- const id = BROWSER_ALIASES[m[1].toLowerCase()];
205
- if ((id === 'chrome_android' || id === 'webview_android') && Number(m[2].split('.')[0]) < 30)
206
- return {
207
- error: `「${ua}」看起来是安卓系统版本,不是浏览器内核版本。请改用 Chrome 内核版本(chrome://version 里 Chrome/ 后面的数字),或直接传完整 UA`,
208
- };
209
- return { id, label: `${m[1]} ${m[2]}`, version: m[2] };
210
- }
211
- // iOS 上所有浏览器和 App 内页面都用系统 WebKit:Safari 的 UA 带 Version/x,比可能被冻结的系统版本号更准
212
- if ((m = ua.match(/(?:iPhone|iPad|iPod).*?OS (\d+)[_.](\d+)/))) {
213
- const version = ua.match(/Version\/(\d+(?:\.\d+)?)/)?.[1] ?? `${m[1]}.${m[2]}`;
214
- return { id: 'safari_ios', label: `iOS ${version}(WebKit)`, version };
215
- }
216
- if ((m = ua.match(/SamsungBrowser\/(\d+(?:\.\d+)?)/)))
217
- return { id: 'samsunginternet_android', label: `Samsung Internet ${m[1]}`, version: m[1] };
218
- if ((m = ua.match(/Edg(?:e|A)?\/(\d+)/)))
219
- return { id: 'edge', label: `Edge ${m[1]}`, version: m[1] };
220
- if ((m = ua.match(/Firefox\/(\d+(?:\.\d+)?)/)))
221
- return { id: 'firefox', label: `Firefox ${m[1]}`, version: m[1] };
222
- if ((m = ua.match(/Chrome\/(\d+)/))) {
223
- if (!/Android/i.test(ua)) return { id: 'chrome', label: `Chrome ${m[1]}`, version: m[1] };
224
- const webview = /; wv\)/.test(ua);
225
- return {
226
- id: webview ? 'webview_android' : 'chrome_android',
227
- label: `Android ${webview ? 'WebView' : 'Chrome 内核'} ${m[1]}`,
228
- version: m[1],
229
- };
230
- }
231
- if (/Android/i.test(ua))
232
- return {
233
- error:
234
- '这个安卓 UA 里没有 Chrome/ 内核版本号(老系统自带浏览器、部分厂商浏览器),判断不了内核版本,无法验证。可以请用户换 Chrome 内核的浏览器复现',
235
- };
236
- if ((m = ua.match(/Version\/(\d+(?:\.\d+)?).*Safari/)))
237
- return { id: 'safari', label: `Safari ${m[1]}`, version: m[1] };
238
- return null;
239
- }
240
-
241
- // ---------- 收集产物 ----------
242
-
243
- function walkFiles(dir, out = []) {
244
- for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
245
- const p = path.join(dir, e.name);
246
- if (e.isDirectory()) e.name !== 'node_modules' && walkFiles(p, out);
247
- else out.push(p);
248
- }
249
- return out;
250
- }
251
-
252
- /** 产物里的 JS 文件和 HTML 内联脚本、CSS 文件和 HTML 内联样式 */
253
- export function collect(dir) {
254
- const scripts = [];
255
- const styles = [];
256
- for (const p of walkFiles(dir)) {
257
- if (/\.m?js$/.test(p))
258
- scripts.push({ file: p, code: fs.readFileSync(p, 'utf8'), sourceType: 'module' });
259
- else if (p.endsWith('.css')) styles.push({ file: p, code: fs.readFileSync(p, 'utf8') });
260
- else if (/\.html?$/.test(p)) {
261
- const html = fs.readFileSync(p, 'utf8');
262
- let i = 0;
263
- for (const m of html.matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script>/gi)) {
264
- const type = m[1].match(/\btype\s*=\s*["']?([^"'\s>]+)/i)?.[1];
265
- if (/\bsrc\s*=/.test(m[1]) || !m[2].trim()) continue;
266
- if (type && !/^(module|text\/javascript|application\/javascript)$/i.test(type)) continue; // json / importmap 等
267
- scripts.push({
268
- file: `${p}#inline-script-${++i}`,
269
- code: m[2],
270
- sourceType: type === 'module' ? 'module' : 'script',
271
- });
272
- }
273
- let j = 0;
274
- for (const m of html.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi))
275
- if (m[1].trim()) styles.push({ file: `${p}#inline-style-${++j}`, code: m[1] });
276
- }
277
- }
278
- return { scripts, styles };
279
- }
280
-
281
- // ---------- JS 识别 ----------
282
-
283
- const staticString = (n) =>
284
- n?.type === 'Literal' && typeof n.value === 'string'
285
- ? n.value
286
- : n?.type === 'TemplateLiteral' && n.expressions.length === 0
287
- ? n.quasis[0]?.value?.cooked
288
- : undefined;
289
- const calleeName = (n) =>
290
- n?.type === 'Identifier'
291
- ? n.name
292
- : n?.type === 'MemberExpression' && !n.computed
293
- ? n.property?.name
294
- : null;
295
-
296
- /**
297
- * 识别产物用到的语法。每个条目在每个文件只记第一处。
298
- * hits:解析期就要支持的语法;loadTime:模块顶层的 RegExp(...)(脚本一加载就执行,等同于整份失败);
299
- * runtime:函数里的 RegExp(...)(执行到才报错);
300
- * tryRegExp:写在 try 块里的 RegExp(多为特性检测,不计入结论);unparsable:解析失败的脚本
301
- */
302
- export function detectJs(scripts, parseAst) {
303
- const rules = new Map();
304
- for (const [keys, type, when] of SYNTAX)
305
- rules.set(type, [...(rules.get(type) || []), { keys: [].concat(keys), when }]);
306
- const hits = new Map();
307
- const loadTime = new Map();
308
- const runtime = new Map();
309
- const tryRegExp = [];
310
- const unparsable = [];
311
- const add = (map, key, file, pos) => {
312
- const list = map.get(key) || [];
313
- if (!list.some((l) => l.file === file)) list.push({ file, pos });
314
- map.set(key, list);
315
- };
316
- for (const { file, code, sourceType } of scripts) {
317
- let ast;
318
- let error;
319
- // 先按模块解析,失败再按经典脚本试一次(public/ 里的老 SDK 等不是模块)
320
- for (const type of sourceType === 'module' ? ['module', 'script'] : ['script']) {
321
- try {
322
- ast = parseAst(code, { lang: 'js', sourceType: type });
323
- break;
324
- } catch (e) {
325
- error ??= e;
326
- }
327
- }
328
- if (!ast) {
329
- const lines = String(error?.message || error)
330
- .split('\n')
331
- .map((l) => l.trim());
332
- const useful = lines.filter((l) => l && !/^Parse failed with/.test(l));
333
- unparsable.push({ file, error: useful.slice(0, 2).join(' ').slice(0, 200) });
334
- continue;
335
- }
336
- // 用显式栈遍历,避免超深的表达式把调用栈撑爆
337
- const stack = [[ast, null, 0, false]];
338
- while (stack.length) {
339
- const [n, parent, fnDepth, inTry] = stack.pop();
340
- const ctx = {
341
- code,
342
- fnDepth,
343
- parent,
344
- isMethod:
345
- parent?.type === 'MethodDefinition' || (parent?.type === 'Property' && parent.method),
346
- };
347
- for (const r of rules.get(n.type) || [])
348
- if (!r.when || r.when(n, ctx)) for (const k of r.keys) add(hits, k, file, n.start ?? 0);
349
- if (n.type === 'Literal' && n.regex)
350
- for (const k of analyzeRegex(n.regex.pattern, n.regex.flags)) add(hits, k, file, n.start);
351
- // 打包器把目标浏览器不支持的正则字面量改写成 RegExp("…"),报错从解析期推迟到执行时
352
- if (
353
- (n.type === 'CallExpression' || n.type === 'NewExpression') &&
354
- calleeName(n.callee) === 'RegExp'
355
- ) {
356
- const pattern = staticString(n.arguments[0]);
357
- const flags = n.arguments[1] ? staticString(n.arguments[1]) : '';
358
- const keys = analyzeRegex(pattern ?? '', flags ?? ''); // pattern 是变量时只能检查标志
359
- if (keys.size && inTry) tryRegExp.push({ file, pos: n.start });
360
- else for (const k of keys) add(fnDepth === 0 ? loadTime : runtime, k, file, n.start);
361
- }
362
- const isFn =
363
- n.type === 'FunctionDeclaration' ||
364
- n.type === 'FunctionExpression' ||
365
- n.type === 'ArrowFunctionExpression';
366
- for (const k in n) {
367
- if (k === 'parent') continue;
368
- for (const c of [].concat(n[k] ?? []))
369
- if (c && typeof c.type === 'string')
370
- // 进入函数体就不再算在 try 里:函数可能在 try 之外被调用
371
- stack.push([
372
- c,
373
- n,
374
- fnDepth + (isFn ? 1 : 0),
375
- isFn ? false : inTry || (n.type === 'TryStatement' && k === 'block'),
376
- ]);
377
- }
378
- }
379
- }
380
- return { hits, loadTime, runtime, tryRegExp, unparsable };
381
- }
382
-
383
- // ---------- CSS:反查 MDN ----------
384
-
385
- const lastUrlSegment = (c) =>
386
- decodeURIComponent(
387
- String(c?.mdn_url || '')
388
- .split('/')
389
- .pop(),
390
- );
391
-
392
- /** 列出目标浏览器不支持的 CSS 条目:@规则、属性、选择器(伪类/伪元素、嵌套 &)、值函数 */
393
- export function cssUnsupported(bcd, target) {
394
- const out = [];
395
- const consider = (kind, key, token) => {
396
- const need = minVersion(bcd, key, target.id);
397
- if (need !== null && !supports(target.version, need)) out.push({ kind, key, token, need });
398
- };
399
- for (const name of Object.keys(bcd.css['at-rules']))
400
- consider('at-rule', `css.at-rules.${name}`, name);
401
- for (const name of Object.keys(bcd.css.properties))
402
- if (!name.startsWith('-')) consider('property', `css.properties.${name}`, name);
403
- for (const [name, node] of Object.entries(bcd.css.selectors)) {
404
- if (name === 'nesting') consider('nesting', 'css.selectors.nesting', '&');
405
- else if (lastUrlSegment(node.__compat).startsWith(':'))
406
- consider('selector', `css.selectors.${name}`, lastUrlSegment(node.__compat).toLowerCase());
407
- }
408
- (function walk(obj, key) {
409
- for (const [name, node] of Object.entries(obj)) {
410
- if (name === '__compat' || typeof node !== 'object') continue;
411
- // 文档页名与条目名一致的才是函数(如 color-mix);<color> 这类数据类型本身的页名是 color_value
412
- if (node.__compat && lastUrlSegment(node.__compat) === name)
413
- consider('function', `${key}.${name}`, name);
414
- walk(node, `${key}.${name}`);
415
- }
416
- })(bcd.css.types, 'css.types');
417
- return out;
418
- }
419
-
420
- // 带浏览器前缀的同名属性(-webkit-user-select 之于 user-select)也算兜底写法
421
- const unprefixed = (prop) => prop.toLowerCase().replace(/^-(webkit|moz|ms|o)-/, '');
422
- const pseudoTokens = (selector) =>
423
- (
424
- selector
425
- .replace(/\\./g, '')
426
- .replace(/"[^"]*"|'[^']*'/g, '')
427
- .replace(/\[[^\]]*\]/g, '')
428
- .match(/::?[a-z][a-z-]*/gi) || []
429
- ).map((t) => t.toLowerCase());
430
- const valueFunctions = (value) =>
431
- [
432
- ...value
433
- .replace(/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/g, '""')
434
- .matchAll(/(?<![\w-])([a-z][a-z0-9-]*)\(/gi),
435
- ].map((m) => m[1].toLowerCase());
436
-
437
- /**
438
- * 在样式里找出这些不支持的条目。写在 @supports 里、条件提到了该写法的,以及同一规则里前面已有同名属性兜底的,算渐进增强,不计。
439
- * 返回 { found: Map(key -> { feature, nodes }), lengthOf, totalBytes, unparsable }
440
- */
441
- export function detectCss(styles, postcss, unsupported) {
442
- const index = {
443
- 'at-rule': new Map(),
444
- property: new Map(),
445
- selector: new Map(),
446
- function: new Map(),
447
- nesting: new Map(),
448
- };
449
- for (const f of unsupported) index[f.kind].set(f.token, f);
450
- const found = new Map();
451
- const unparsable = [];
452
- let totalBytes = 0;
453
- const guarded = (node, token) => {
454
- for (let p = node.parent; p; p = p.parent)
455
- if (
456
- p.type === 'atrule' &&
457
- p.name.toLowerCase() === 'supports' &&
458
- p.params.toLowerCase().includes(token)
459
- )
460
- return true;
461
- if (node.type !== 'decl') return false;
462
- const siblings = node.parent?.nodes || [];
463
- return siblings
464
- .slice(0, siblings.indexOf(node))
465
- .some((s) => s.type === 'decl' && unprefixed(s.prop) === unprefixed(node.prop));
466
- };
467
- const hit = (feature, node, token) => {
468
- if (!feature || guarded(node, token)) return;
469
- const entry = found.get(feature.key) || { feature, nodes: [] };
470
- entry.nodes.push(node);
471
- found.set(feature.key, entry);
472
- };
473
- for (const { file, code } of styles) {
474
- let root;
475
- try {
476
- root = postcss.parse(code, { from: file });
477
- } catch (e) {
478
- unparsable.push({
479
- file,
480
- error: String(e?.message || e)
481
- .split('\n')[0]
482
- .slice(0, 160),
483
- });
484
- continue;
485
- }
486
- totalBytes += root.toString().length;
487
- root.walk((node) => {
488
- if (node.type === 'atrule')
489
- hit(index['at-rule'].get(node.name.toLowerCase()), node, `@${node.name.toLowerCase()}`);
490
- else if (node.type === 'rule') {
491
- for (const t of pseudoTokens(node.selector)) hit(index.selector.get(t), node, t);
492
- if (node.parent?.type === 'rule') hit(index.nesting.get('&'), node, '&');
493
- } else if (node.type === 'decl') {
494
- if (!node.prop.startsWith('--'))
495
- hit(index.property.get(node.prop.toLowerCase()), node, node.prop.toLowerCase());
496
- for (const fn of new Set(valueFunctions(node.value)))
497
- hit(index.function.get(fn), node, `${fn}(`);
498
- }
499
- });
500
- }
501
- const lengths = new Map();
502
- const lengthOf = (n) =>
503
- lengths.has(n) ? lengths.get(n) : lengths.set(n, n.toString().length).get(n);
504
- return { found, lengthOf, totalBytes, unparsable };
505
- }
506
-
507
- /** 版本 version 下会被丢弃的样式占比:只算最外层被丢弃的节点,嵌在里面的不重复计算 */
508
- function droppedShare(css, version, keys = [...css.found.keys()]) {
509
- const nodes = new Set();
510
- for (const k of keys) {
511
- const { feature, nodes: list } = css.found.get(k);
512
- if (!supports(version, feature.need)) list.forEach((n) => nodes.add(n));
513
- }
514
- let bytes = 0;
515
- for (const n of nodes) {
516
- let covered = false;
517
- for (let p = n.parent; p && !covered; p = p.parent) covered = nodes.has(p);
518
- if (!covered) bytes += css.lengthOf(n);
519
- }
520
- return css.totalBytes ? bytes / css.totalBytes : 0;
521
- }
522
-
523
- /** 丢弃超过这个比例算「样式大面积缺失」 */
524
- const CSS_MAJOR_LOSS = 0.1;
525
-
526
- // ---------- MDN 数据 ----------
527
-
528
- function readJson(file) {
529
- try {
530
- return JSON.parse(fs.readFileSync(file, 'utf8'));
531
- } catch {
532
- return null;
533
- }
534
- }
535
-
536
- /** 优先 --bcd;否则用缓存,没有或损坏就现装一份(先装到临时目录再改名,中途失败不会留下半装的缓存) */
537
- function loadBcd(project, bcdArg) {
538
- if (bcdArg) {
539
- const data = readJson(path.join(path.resolve(project, bcdArg), 'data.json'));
540
- if (!data) fail(`--bcd 指定的目录里没有可读的 @mdn/browser-compat-data data.json:${bcdArg}`);
541
- return data;
542
- }
543
- const cacheDir = path.join(project, 'node_modules', '.cache', 'browser-compat', 'bcd');
544
- const dataFile = (d) => path.join(d, 'node_modules', '@mdn', 'browser-compat-data', 'data.json');
545
- const cached = readJson(dataFile(cacheDir));
546
- if (cached) return cached;
547
- const tmpDir = `${cacheDir}.tmp-${process.pid}`;
548
- try {
549
- fs.rmSync(tmpDir, { recursive: true, force: true });
550
- fs.mkdirSync(tmpDir, { recursive: true });
551
- fs.writeFileSync(
552
- path.join(tmpDir, 'package.json'),
553
- '{"name":"browser-compat-bcd-cache","private":true}\n',
554
- );
555
- // 用项目自己的 npm 源(.npmrc),沙箱里不一定能直连公网 npm;锁大版本,数据结构变化时由维护者升级
556
- const registry = execFileSync('npm', ['config', 'get', 'registry'], {
557
- cwd: project,
558
- encoding: 'utf8',
559
- }).trim();
560
- execFileSync(
561
- 'npm',
562
- [
563
- 'install',
564
- '@mdn/browser-compat-data@8',
565
- '--no-save',
566
- '--no-package-lock',
567
- '--no-audit',
568
- '--no-fund',
569
- '--ignore-scripts',
570
- `--registry=${registry}`,
571
- ],
572
- { cwd: tmpDir, stdio: ['ignore', 'ignore', 'pipe'], timeout: 120000 },
573
- );
574
- const fresh = readJson(dataFile(tmpDir));
575
- if (!fresh) throw new Error('安装后读不到 data.json');
576
- fs.rmSync(cacheDir, { recursive: true, force: true });
577
- try {
578
- fs.renameSync(tmpDir, cacheDir);
579
- } catch {
580
- /* 并发时别的进程先放好了缓存,直接用这次装的数据 */
581
- }
582
- return fresh;
583
- } catch (e) {
584
- const msg =
585
- String(e?.stderr || e?.message || e)
586
- .split('\n')
587
- .find((l) => l.trim()) || '';
588
- return fail(
589
- `无法获取 MDN 浏览器兼容数据(需要访问 npm 源安装 @mdn/browser-compat-data):${msg.slice(0, 200)}`,
590
- );
591
- } finally {
592
- fs.rmSync(tmpDir, { recursive: true, force: true });
593
- }
594
- }
595
-
596
- // ---------- 主流程 ----------
597
-
598
- async function main() {
599
- const argv = process.argv.slice(2);
600
- const opt = (k) => {
601
- const i = argv.indexOf(`--${k}`);
602
- return i >= 0 ? argv[i + 1] : undefined;
603
- };
604
- const project = process.cwd();
605
- const dir = path.resolve(project, opt('dir') || 'dist');
606
- const target = parseBrowser(opt('browser'));
607
- if (target?.error) fail(target.error);
608
- if (!target)
609
- fail(
610
- `无法识别浏览器:${opt('browser') ?? '(未提供 --browser)'}。请传完整 UA,或简写:chrome 93 / android 93 / ios 15.4 / safari 16 / firefox 92 / edge 90 / samsung 14`,
611
- );
612
- if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory())
613
- fail(`产物目录不存在:${path.relative(project, dir) || dir}。先构建,再用 --dir 指向产物目录`);
614
-
615
- let vite;
616
- let postcss;
617
- try {
618
- const req = createRequire(path.join(project, 'package.json'));
619
- const viteEntry = req.resolve('vite');
620
- vite = await import(pathToFileURL(viteEntry).href);
621
- const pc = await import(pathToFileURL(createRequire(viteEntry).resolve('postcss')).href);
622
- postcss = pc.default ?? pc;
623
- } catch {
624
- /* 下面统一报错 */
625
- }
626
- if (typeof vite?.parseAst !== 'function' || typeof postcss?.parse !== 'function')
627
- fail(
628
- '当前项目的 vite 不提供 parseAst / postcss(Vite 版本过旧;已验证 Vite 7、8 可用),本脚本无法验证',
629
- );
630
-
631
- const bcd = loadBcd(project, opt('bcd'));
632
- const { scripts, styles } = collect(dir);
633
- if (!scripts.length)
634
- fail(`产物目录 ${path.relative(project, dir)} 下没有找到 JS,确认构建是否成功、--dir 是否指对`);
635
-
636
- // 不支持 ES 模块的浏览器根本不会执行 Vite 产物
637
- const esm = minVersion(bcd, 'javascript.statements.import', target.id);
638
- const js = detectJs(scripts, vite.parseAst);
639
- const keysOf = (map) => [...map.keys()];
640
- const missing = [
641
- ...new Set([...keysOf(js.hits), ...keysOf(js.loadTime), ...keysOf(js.runtime)]),
642
- ].filter((k) => minVersion(bcd, k, target.id) === null);
643
- if (esm === null || missing.length)
644
- fail(
645
- `无法判断:MDN 数据(${bcd.__meta?.version ?? '未知版本'})里没有 ${target.id} 的这些条目:${[esm === null ? 'javascript.statements.import' : '', ...missing].filter(Boolean).join('、')}`,
646
- );
647
- const issues = (map) =>
648
- keysOf(map)
649
- .map((key) => ({ key, need: minVersion(bcd, key, target.id), locs: map.get(key) }))
650
- .filter((i) => !supports(target.version, i.need))
651
- .sort((a, b) => (a.need === false ? -1 : b.need === false ? 1 : cmpVersion(b.need, a.need)));
652
- const parseIssues = issues(js.hits);
653
- if (!supports(target.version, esm))
654
- parseIssues.unshift({ key: 'javascript.statements.import', need: esm, locs: [] });
655
- const loadIssues = issues(js.loadTime);
656
- const runtimeIssues = issues(js.runtime);
657
- // 从能运行 Vite 产物的最低版本(ES 模块)开始收集不支持的样式,才能算出「从哪个版本起样式基本可用」
658
- const cssFloor = typeof esm === 'string' ? esm : target.version;
659
- const css = detectCss(styles, postcss, cssUnsupported(bcd, { id: target.id, version: cssFloor }));
660
- const cssShare = droppedShare(css, target.version);
661
-
662
- const rel = (f) => {
663
- const [file, frag] = f.split('#');
664
- const r = path.relative(project, file);
665
- return (r.startsWith('..') ? file : r) + (frag ? `#${frag}` : '');
666
- };
667
- const codeOf = new Map(scripts.map((s) => [s.file, s.code]));
668
- const snippet = (f, pos) =>
669
- (codeOf.get(f) || '').slice(Math.max(0, pos - 40), pos + 80).replace(/\s+/g, ' ');
670
- const ver = (need) => (need === false ? '(该浏览器任何版本都不支持)' : `≥ ${need}`);
671
- const pct = (x) => `${x > 0 && x < 0.01 ? '<1' : Math.round(x * 100)}%`;
672
- const printIssues = (list) => {
673
- for (const i of list) {
674
- console.log(` ✗ ${describe(bcd, i.key)}(${i.key}):需要 ${target.id} ${ver(i.need)}`);
675
- for (const l of i.locs.slice(0, 3))
676
- console.log(` ${rel(l.file)} …${snippet(l.file, l.pos)}…`);
677
- if (i.locs.length > 3) console.log(` ……另有 ${i.locs.length - 3} 个文件`);
678
- }
679
- };
680
-
681
- console.log(`目标浏览器:${target.label}(${target.id} ${target.version})`);
682
- console.log(
683
- `数据:MDN @mdn/browser-compat-data ${bcd.__meta?.version ?? ''};扫描 ${scripts.length} 段脚本、${styles.length} 份样式(${rel(dir) || '.'})`,
684
- );
685
-
686
- console.log('\n【JS】');
687
- if (!supports(target.version, esm))
688
- console.log(' 该浏览器不支持 ES 模块(<script type="module">),Vite 产物整体不会执行');
689
- const syntaxIssues = parseIssues.filter((i) => i.locs.length);
690
- if (syntaxIssues.length) {
691
- console.log(` 有 ${syntaxIssues.length} 种语法在该浏览器上无法解析,所在脚本会整份加载失败:`);
692
- printIssues(syntaxIssues);
693
- } else if (supports(target.version, esm)) console.log(' 没有该浏览器无法解析的语法');
694
- if (loadIssues.length) {
695
- console.log(' 在模块顶层构造的正则(RegExp(…),脚本一加载就执行,所在脚本整份失败):');
696
- printIssues(loadIssues);
697
- }
698
- if (runtimeIssues.length) {
699
- console.log(
700
- ' 函数里构造的正则(RegExp(…),执行到这里时报错;如果这个函数在页面加载过程中就被调用,仍会白屏):',
701
- );
702
- printIssues(runtimeIssues);
703
- }
704
- if (js.tryRegExp.length)
705
- console.log(
706
- ` · ${js.tryRegExp.length} 处写在 try 块里的 RegExp 用到了新正则语法,按特性检测处理,未计入结论(${[...new Set(js.tryRegExp.map((t) => rel(t.file)))].slice(0, 3).join('、')})`,
707
- );
708
- if (js.unparsable.length) {
709
- console.log(` ⚠ ${js.unparsable.length} 段脚本无法解析,没有参与判断:`);
710
- for (const u of js.unparsable.slice(0, 5)) console.log(` - ${rel(u.file)}:${u.error}`);
711
- }
712
- const jsNeeds = [...js.hits.keys()]
713
- .map((k) => minVersion(bcd, k, target.id))
714
- .filter((v) => typeof v === 'string');
715
- if (jsNeeds.length) console.log(` JS 能解析所需的最低版本:${jsNeeds.sort(cmpVersion).pop()}`);
716
-
717
- console.log('\n【CSS】');
718
- if (!styles.length) console.log(' 产物里没有样式');
719
- else if (![...css.found.values()].some((e) => !supports(target.version, e.feature.need)))
720
- console.log(' 没有该浏览器不支持的样式写法');
721
- else {
722
- console.log(` 该浏览器上会被丢弃的样式:约 ${pct(cssShare)}(按字节估算)`);
723
- const all = [...css.found.values()];
724
- const entries = all
725
- .filter((e) => !supports(target.version, e.feature.need))
726
- .map((e) => ({ ...e, share: droppedShare(css, target.version, [e.feature.key]) }));
727
- entries.sort((a, b) => b.share - a.share || b.nodes.length - a.nodes.length);
728
- for (const e of entries.slice(0, 12)) {
729
- const f = e.feature;
730
- const effect =
731
- f.kind === 'at-rule'
732
- ? `${e.nodes.length} 个 @${f.token} 块整块丢弃,约占样式 ${pct(e.share)}`
733
- : f.kind === 'selector' || f.kind === 'nesting'
734
- ? `${e.nodes.length} 条规则整条失效`
735
- : `${e.nodes.length} 条声明失效`;
736
- const name =
737
- f.kind === 'at-rule' ? `@${f.token}` : f.kind === 'function' ? `${f.token}()` : f.token;
738
- console.log(
739
- ` ${e.share >= CSS_MAJOR_LOSS ? '✗' : '·'} ${name}(${f.key}):需要 ${target.id} ${ver(f.need)};${effect}`,
740
- );
741
- }
742
- if (entries.length > 12) console.log(` · 另有 ${entries.length - 12} 项影响较小`);
743
- // 样式基本可用 / 全部生效分别需要的版本:从 ES 模块的最低版本起,在检测到的条目的最低版本里逐个试
744
- const candidates = [
745
- ...new Set([cssFloor, ...all.map((e) => e.feature.need).filter((v) => v !== false)]),
746
- ].sort(cmpVersion);
747
- const basic = candidates.find((v) => droppedShare(css, v) < CSS_MAJOR_LOSS);
748
- const never = all.filter((e) => e.feature.need === false).map((e) => e.feature.token);
749
- const full = candidates.at(-1);
750
- console.log(
751
- ` 样式基本可用(丢弃 < ${pct(CSS_MAJOR_LOSS)}):${basic ? `${target.id} ${basic} 起${cmpVersion(target.version, basic) >= 0 ? ',当前版本已满足' : ''}` : '更高版本也达不到'}`,
752
- );
753
- console.log(
754
- ` 全部生效:${target.id} ${full} 起` +
755
- (never.length
756
- ? `(不含 ${target.id} 任何版本都不支持的 ${never.length} 项:${never.slice(0, 5).join('、')},影响见上方)`
757
- : ''),
758
- );
759
- }
760
- if (css.unparsable.length)
761
- for (const u of css.unparsable)
762
- console.log(` ⚠ 样式无法解析,没有参与判断:${rel(u.file)}:${u.error}`);
763
-
764
- let code;
765
- if (parseIssues.length || loadIssues.length) {
766
- code = 1;
767
- console.log(
768
- `\n结论:不兼容 —— ${parseIssues.length ? '有 JS 语法在该浏览器上无法解析' : '有正则在脚本加载时就会报错'}`,
769
- );
770
- } else if (runtimeIssues.length || cssShare >= CSS_MAJOR_LOSS) {
771
- code = 3;
772
- const why = [
773
- runtimeIssues.length ? '有正则执行到时会报错(若在页面加载时执行仍会白屏)' : '',
774
- cssShare >= CSS_MAJOR_LOSS ? `约 ${pct(cssShare)} 的样式会被丢弃` : '',
775
- ].filter(Boolean);
776
- console.log(`\n结论:JS 都能解析,但${why.join(',')};这些打包配置修不了`);
777
- } else if (js.unparsable.length) {
778
- code = 2;
779
- console.log('\n结论:无法完整判断 —— 有脚本解析失败(见上方 ⚠),其余部分没有发现问题');
780
- } else {
781
- code = 0;
782
- console.log('\n结论:兼容 —— JS 都能解析,样式没有大面积缺失');
783
- }
784
- return code;
785
- }
786
-
787
- if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
788
- try {
789
- process.exitCode = await main();
790
- } catch (e) {
791
- // 任何意外都按「无法判断」处理(退出码 2),不能和「不兼容」(1)混在一起
792
- console.error(
793
- e instanceof UsageError
794
- ? e.message
795
- : `检查脚本内部错误,本次无法判断:${String(e?.message || e).slice(0, 300)}`,
796
- );
797
- process.exitCode = 2;
798
- }
799
- }