dsh-visualkit 0.0.0-stage → 0.0.2
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.
- package/CHANGELOG.md +74 -0
- package/LICENSE +26 -0
- package/README.md +443 -2
- package/THIRD_PARTY.md +55 -0
- package/cordis.patch.yml +29 -0
- package/data/assets/README.md +38 -0
- package/data/assets/icons/brands/bilibili.svg +1 -0
- package/data/assets/icons/brands/discord.svg +1 -0
- package/data/assets/icons/brands/neteasecloudmusic.svg +1 -0
- package/data/assets/icons/brands/qq.svg +1 -0
- package/data/assets/icons/brands/sinaweibo.svg +1 -0
- package/data/assets/icons/brands/steam.svg +1 -0
- package/data/assets/icons/brands/telegram.svg +1 -0
- package/data/assets/icons/brands/tiktok.svg +1 -0
- package/data/assets/icons/brands/twitch.svg +1 -0
- package/data/assets/icons/brands/wechat.svg +1 -0
- package/data/assets/icons/brands/x.svg +1 -0
- package/data/assets/icons/brands/youtube.svg +1 -0
- package/data/assets/icons/lucide/bell.svg +1 -0
- package/data/assets/icons/lucide/calendar.svg +1 -0
- package/data/assets/icons/lucide/clock.svg +1 -0
- package/data/assets/icons/lucide/gamepad-2.svg +1 -0
- package/data/assets/icons/lucide/gift.svg +1 -0
- package/data/assets/icons/lucide/heart.svg +1 -0
- package/data/assets/icons/lucide/mic.svg +1 -0
- package/data/assets/icons/lucide/play.svg +1 -0
- package/data/assets/icons/lucide/star.svg +1 -0
- package/data/assets/icons/lucide/video.svg +1 -0
- package/data/assets/icons/phosphor/LICENSE +21 -0
- package/data/assets/icons/phosphor/atom-duotone.svg +1 -0
- package/data/assets/icons/phosphor/binary-duotone.svg +1 -0
- package/data/assets/icons/phosphor/blueprint-duotone.svg +1 -0
- package/data/assets/icons/phosphor/circuitry-duotone.svg +1 -0
- package/data/assets/icons/phosphor/cloud-arrow-up-duotone.svg +1 -0
- package/data/assets/icons/phosphor/code-duotone.svg +1 -0
- package/data/assets/icons/phosphor/compass-duotone.svg +1 -0
- package/data/assets/icons/phosphor/cpu-duotone.svg +1 -0
- package/data/assets/icons/phosphor/cube-duotone.svg +1 -0
- package/data/assets/icons/phosphor/database-duotone.svg +1 -0
- package/data/assets/icons/phosphor/flask-duotone.svg +1 -0
- package/data/assets/icons/phosphor/frame-corners-duotone.svg +1 -0
- package/data/assets/icons/phosphor/function-duotone.svg +1 -0
- package/data/assets/icons/phosphor/hard-drives-duotone.svg +1 -0
- package/data/assets/icons/phosphor/hexagon-duotone.svg +1 -0
- package/data/assets/icons/phosphor/infinity-duotone.svg +1 -0
- package/data/assets/icons/phosphor/lightning-duotone.svg +1 -0
- package/data/assets/icons/phosphor/magic-wand-duotone.svg +1 -0
- package/data/assets/icons/phosphor/moon-stars-duotone.svg +1 -0
- package/data/assets/icons/phosphor/paint-brush-duotone.svg +1 -0
- package/data/assets/icons/phosphor/pen-nib-duotone.svg +1 -0
- package/data/assets/icons/phosphor/planet-duotone.svg +1 -0
- package/data/assets/icons/phosphor/rocket-launch-duotone.svg +1 -0
- package/data/assets/icons/phosphor/ruler-duotone.svg +1 -0
- package/data/assets/icons/phosphor/selection-plus-duotone.svg +1 -0
- package/data/assets/icons/phosphor/shapes-duotone.svg +1 -0
- package/data/assets/icons/phosphor/sparkle-duotone.svg +1 -0
- package/data/assets/icons/phosphor/sun-horizon-duotone.svg +1 -0
- package/data/assets/icons/phosphor/terminal-window-duotone.svg +1 -0
- package/data/assets/icons/phosphor/triangle-duotone.svg +1 -0
- package/data/assets/icons/phosphor/waveform-duotone.svg +1 -0
- package/data/assets/icons/tabler/alarm.svg +1 -0
- package/data/assets/icons/tabler/badge-ad.svg +1 -0
- package/data/assets/icons/tabler/bell.svg +1 -0
- package/data/assets/icons/tabler/bolt.svg +1 -0
- package/data/assets/icons/tabler/brand-bilibili.svg +1 -0
- package/data/assets/icons/tabler/brand-discord.svg +1 -0
- package/data/assets/icons/tabler/brand-qq.svg +1 -0
- package/data/assets/icons/tabler/brand-twitch.svg +1 -0
- package/data/assets/icons/tabler/brand-wechat.svg +1 -0
- package/data/assets/icons/tabler/brand-weibo.svg +1 -0
- package/data/assets/icons/tabler/brand-youtube.svg +1 -0
- package/data/assets/icons/tabler/broadcast.svg +1 -0
- package/data/assets/icons/tabler/calendar-event.svg +1 -0
- package/data/assets/icons/tabler/calendar-week.svg +1 -0
- package/data/assets/icons/tabler/clock-hour-3.svg +1 -0
- package/data/assets/icons/tabler/coin.svg +1 -0
- package/data/assets/icons/tabler/crown.svg +1 -0
- package/data/assets/icons/tabler/currency-yen.svg +1 -0
- package/data/assets/icons/tabler/device-gamepad-2.svg +1 -0
- package/data/assets/icons/tabler/diamond.svg +1 -0
- package/data/assets/icons/tabler/flame.svg +1 -0
- package/data/assets/icons/tabler/gift.svg +1 -0
- package/data/assets/icons/tabler/headphones.svg +1 -0
- package/data/assets/icons/tabler/heart-filled.svg +1 -0
- package/data/assets/icons/tabler/heart.svg +1 -0
- package/data/assets/icons/tabler/live-view.svg +1 -0
- package/data/assets/icons/tabler/microphone.svg +1 -0
- package/data/assets/icons/tabler/mood-smile.svg +1 -0
- package/data/assets/icons/tabler/mood-tongue.svg +1 -0
- package/data/assets/icons/tabler/movie.svg +1 -0
- package/data/assets/icons/tabler/player-play.svg +1 -0
- package/data/assets/icons/tabler/playlist.svg +1 -0
- package/data/assets/icons/tabler/rocket.svg +1 -0
- package/data/assets/icons/tabler/sparkles.svg +1 -0
- package/data/assets/icons/tabler/star-filled.svg +1 -0
- package/data/assets/icons/tabler/star.svg +1 -0
- package/data/assets/icons/tabler/tag.svg +1 -0
- package/data/assets/icons/tabler/thumb-up.svg +1 -0
- package/data/assets/icons/tabler/ticket.svg +1 -0
- package/data/assets/icons/tabler/video.svg +1 -0
- package/data/assets/illustrations/openpeeps/LICENSE +8 -0
- package/data/assets/illustrations/openpeeps/peep-10.svg +29 -0
- package/data/assets/illustrations/openpeeps/peep-100.svg +32 -0
- package/data/assets/illustrations/openpeeps/peep-101.svg +25 -0
- package/data/assets/illustrations/openpeeps/peep-102.svg +28 -0
- package/data/assets/illustrations/openpeeps/peep-103.svg +32 -0
- package/data/assets/illustrations/openpeeps/peep-105.svg +25 -0
- package/data/assets/illustrations/openpeeps/peep-12.svg +29 -0
- package/data/assets/illustrations/openpeeps/peep-13.svg +27 -0
- package/data/assets/illustrations/openpeeps/peep-16.svg +27 -0
- package/data/assets/illustrations/openpeeps/peep-17.svg +31 -0
- package/data/assets/illustrations/openpeeps/peep-22.svg +29 -0
- package/data/assets/illustrations/openpeeps/peep-25.svg +29 -0
- package/data/assets/illustrations/openpeeps/peep-26.svg +29 -0
- package/data/assets/illustrations/openpeeps/peep-27.svg +29 -0
- package/data/assets/illustrations/openpeeps/peep-30.svg +29 -0
- package/data/assets/illustrations/openpeeps/peep-31.svg +27 -0
- package/data/assets/illustrations/openpeeps/peep-34.svg +29 -0
- package/data/assets/illustrations/openpeeps/peep-38.svg +29 -0
- package/data/assets/illustrations/openpeeps/peep-39.svg +31 -0
- package/data/assets/illustrations/openpeeps/peep-4.svg +29 -0
- package/data/assets/illustrations/openpeeps/peep-41.svg +29 -0
- package/data/assets/illustrations/openpeeps/peep-44.svg +29 -0
- package/data/assets/illustrations/openpeeps/peep-47.svg +26 -0
- package/data/assets/illustrations/openpeeps/peep-48.svg +29 -0
- package/data/assets/illustrations/openpeeps/peep-50.svg +31 -0
- package/data/assets/illustrations/openpeeps/peep-51.svg +29 -0
- package/data/assets/illustrations/openpeeps/peep-53.svg +27 -0
- package/data/assets/illustrations/openpeeps/peep-54.svg +26 -0
- package/data/assets/illustrations/openpeeps/peep-55.svg +29 -0
- package/data/assets/illustrations/openpeeps/peep-56.svg +27 -0
- package/data/assets/illustrations/openpeeps/peep-58.svg +29 -0
- package/data/assets/illustrations/openpeeps/peep-61.svg +27 -0
- package/data/assets/illustrations/openpeeps/peep-64.svg +27 -0
- package/data/assets/illustrations/openpeeps/peep-68.svg +27 -0
- package/data/assets/illustrations/openpeeps/peep-69.svg +27 -0
- package/data/assets/illustrations/openpeeps/peep-7.svg +31 -0
- package/data/assets/illustrations/openpeeps/peep-72.svg +29 -0
- package/data/assets/illustrations/openpeeps/peep-76.svg +31 -0
- package/data/assets/illustrations/openpeeps/peep-77.svg +27 -0
- package/data/assets/illustrations/openpeeps/peep-80.svg +27 -0
- package/data/assets/illustrations/openpeeps/peep-81.svg +29 -0
- package/data/assets/illustrations/openpeeps/peep-83.svg +29 -0
- package/data/assets/manifest.json +1 -0
- package/data/assets/parts//342/206/223/346/212/225/345/275/261.png +0 -0
- package/data/assets/parts//344/270/211/350/247/222/345/217/221/345/244/2711.png +0 -0
- package/data/assets/parts//344/270/211/350/247/222/345/217/221/345/244/2712.png +0 -0
- package/data/assets/parts//344/270/211/350/247/222/345/217/221/345/244/2713.png +0 -0
- package/data/assets/parts//344/270/211/350/247/222/345/217/221/345/244/2714.png +0 -0
- package/data/assets/parts//345/217/214/351/251/254/345/260/276.png +0 -0
- package/data/assets/parts//345/217/214/351/251/254/345/260/2761.png +0 -0
- package/data/assets/parts//345/217/2211.png +0 -0
- package/data/assets/parts//345/217/2212.png +0 -0
- package/data/assets/parts//345/217/2213.png +0 -0
- package/data/assets/parts//345/217/221/345/234/210.png +0 -0
- package/data/assets/parts//345/217/221/345/244/2711.png +0 -0
- package/data/assets/parts//345/217/221/345/244/2712.png +0 -0
- package/data/assets/parts//345/217/243/346/260/264/345/267/276/345/211/215.png +0 -0
- package/data/assets/parts//345/217/243/346/260/264/345/267/276/345/220/216.png +0 -0
- package/data/assets/parts//345/217/263/344/270/212/347/234/274/347/235/221.png +0 -0
- package/data/assets/parts//345/217/263/346/211/213.png +0 -0
- package/data/assets/parts//345/217/263/346/211/2131.png +0 -0
- package/data/assets/parts//345/217/263/347/234/274/347/220/203.png +0 -0
- package/data/assets/parts//345/217/263/347/234/274/347/231/275.png +0 -0
- package/data/assets/parts//345/217/263/350/205/277.png +0 -0
- package/data/assets/parts//345/221/206/346/257/233.png +0 -0
- package/data/assets/parts//345/230/264/345/267/264.png +0 -0
- package/data/assets/parts//345/260/276/345/267/264.png +0 -0
- package/data/assets/parts//345/267/246/344/270/212/347/234/274/347/235/221.png +0 -0
- package/data/assets/parts//345/267/246/345/217/214/347/234/274/347/232/256.png +0 -0
- package/data/assets/parts//345/267/246/346/211/213.png +0 -0
- package/data/assets/parts//345/267/246/346/211/2131.png +0 -0
- package/data/assets/parts//345/267/246/347/234/274/347/220/203.png +0 -0
- package/data/assets/parts//345/267/246/347/234/274/347/231/275.png +0 -0
- package/data/assets/parts//345/267/246/350/205/277.png +0 -0
- package/data/assets/parts//346/212/225/345/275/261.png +0 -0
- package/data/assets/parts//347/234/211/346/257/233.png +0 -0
- package/data/assets/parts//347/234/274/347/275/251.png +0 -0
- package/data/assets/parts//350/200/263/346/234/2651.png +0 -0
- package/data/assets/parts//350/200/263/346/234/2652.png +0 -0
- package/data/assets/parts//350/204/221/345/243/263.png +0 -0
- package/data/assets/parts//350/241/243/346/234/215/345/211/215.png +0 -0
- package/data/assets/parts//350/241/243/346/234/215/345/220/216.png +0 -0
- package/data/assets/parts//350/241/243/346/234/215/351/273/221.png +0 -0
- package/data/assets/parts//350/241/243/346/234/215/351/273/2211.png +0 -0
- package/data/assets/parts//350/272/253/344/275/223.png +0 -0
- package/data/assets/parts//351/223/201/346/235/277.png +0 -0
- package/data/assets/parts//351/273/221/347/234/274/345/234/210.png +0 -0
- package/data/assets/parts//351/273/221/347/234/274/345/234/2102.png +0 -0
- package/data/assets/patterns/checker.svg +1 -0
- package/data/assets/patterns/circles-overlap.svg +1 -0
- package/data/assets/patterns/crosshatch.svg +1 -0
- package/data/assets/patterns/diagonal.svg +1 -0
- package/data/assets/patterns/dots.svg +1 -0
- package/data/assets/patterns/grid.svg +1 -0
- package/data/assets/patterns/halftone.svg +1 -0
- package/data/assets/patterns/plus.svg +1 -0
- package/data/assets/patterns/scales.svg +1 -0
- package/data/assets/patterns/triangles.svg +1 -0
- package/data/assets/patterns/waves.svg +1 -0
- package/data/assets/patterns/zigzag.svg +1 -0
- package/data/assets/stickers/openmoji/1F375.svg +12 -0
- package/data/assets/stickers/openmoji/1F9E8.svg +21 -0
- package/data/assets/stickers/twemoji/1f389.svg +1 -0
- package/data/assets/stickers/twemoji/1f3a4.svg +1 -0
- package/data/assets/stickers/twemoji/1f3a7.svg +1 -0
- package/data/assets/stickers/twemoji/1f3ae.svg +1 -0
- package/data/assets/stickers/twemoji/1f44b.svg +1 -0
- package/data/assets/stickers/twemoji/1f44d.svg +1 -0
- package/data/assets/stickers/twemoji/1f494.svg +1 -0
- package/data/assets/stickers/twemoji/1f4a2.svg +1 -0
- package/data/assets/stickers/twemoji/1f4a8.svg +1 -0
- package/data/assets/stickers/twemoji/1f4b5.svg +1 -0
- package/data/assets/stickers/twemoji/1f4d6.svg +1 -0
- package/data/assets/stickers/twemoji/1f525.svg +1 -0
- package/data/assets/stickers/twemoji/1f600.svg +1 -0
- package/data/assets/stickers/twemoji/1f602.svg +1 -0
- package/data/assets/stickers/twemoji/1f60d.svg +1 -0
- package/data/assets/stickers/twemoji/1f62d.svg +1 -0
- package/data/assets/stickers/twemoji/1f680.svg +1 -0
- package/data/assets/stickers/twemoji/1f979.svg +1 -0
- package/data/assets/stickers/twemoji/1f97a.svg +1 -0
- package/data/assets/stickers/twemoji/26a1.svg +1 -0
- package/data/assets/stickers/twemoji/2705.svg +1 -0
- package/data/assets/stickers/twemoji/274c.svg +1 -0
- package/data/assets/stickers/twemoji/2764.svg +1 -0
- package/data/assets/stickers/twemoji/2b50.svg +1 -0
- package/data/assets/stickers/twemoji_1f525.svg +1 -0
- package/data/assets/stickers/twemoji_1f600.svg +1 -0
- package/data/assets/stickers/twemoji_1f979.svg +1 -0
- package/data/assets/stickers/twemoji_2764.svg +1 -0
- package/data/assets/textures/grain-dark.png +0 -0
- package/data/assets/textures/grain-light.png +0 -0
- package/data/assets/textures/grain-warm.png +0 -0
- package/data/lucide-icons.json +1 -0
- package/data/packs/tabler.icons.json +1 -0
- package/icon.svg +26 -0
- package/lib/assets.js +228 -0
- package/lib/audit.js +284 -0
- package/lib/contrast-render.js +213 -0
- package/lib/css-rewrite.js +379 -0
- package/lib/detect.js +701 -0
- package/lib/diff.js +153 -0
- package/lib/emit.js +557 -0
- package/lib/html-context.js +315 -0
- package/lib/icon-render.js +107 -0
- package/lib/icondb.js +402 -0
- package/lib/image-normalize.js +140 -0
- package/lib/index.js +1289 -0
- package/lib/layouts/common.js +48 -0
- package/lib/layouts/image.js +1010 -0
- package/lib/layouts/index.js +10 -0
- package/lib/layouts/intent.js +66 -0
- package/lib/layouts/metrics-text.js +91 -0
- package/lib/layouts/png.js +75 -0
- package/lib/layouts/rules-contrast.js +575 -0
- package/lib/layouts/rules-punct.js +486 -0
- package/lib/layouts/rules-structure.js +182 -0
- package/lib/layouts/source.js +1961 -0
- package/lib/layouts/svg.js +1204 -0
- package/lib/morphcore.js +139 -0
- package/lib/preview.js +225 -0
- package/lib/render-html.js +255 -0
- package/lib/render.js +486 -0
- package/lib/rules.js +267 -0
- package/lib/shadow.js +83 -0
- package/lib/skip.js +56 -0
- package/lib/style.js +688 -0
- package/lib/svg.js +729 -0
- package/lib/taste-image.js +323 -0
- package/lib/taste.js +219 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +92 -4
- package/vendor/morphicons-adapters.iife.js +903 -0
- package/vendor/morphicons.iife.js +2441 -0
- package/vendor/resvg/LICENSE +373 -0
- package/vendor/resvg/index.d.ts +98 -0
- package/vendor/resvg/index.js +23 -0
- package/vendor/resvg/js-binding.d.ts +52 -0
- package/vendor/resvg/js-binding.js +1 -0
- package/vendor/resvg/package.json +138 -0
- package/vendor/resvg/resvgjs.win32-x64-msvc.node +0 -0
|
@@ -0,0 +1,1961 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* layouts/source.js — layout_check action=ui:UI 源码的排版体检(规则 S1–S9)。
|
|
3
|
+
*
|
|
4
|
+
* 目标:在「看着还行」和「读起来累」之间给出可机械判定的差异,并附具体修法。
|
|
5
|
+
* 输出契约见 ./common.js:这里只产出 finding + metrics,评分/分级交给 finalize。
|
|
6
|
+
*
|
|
7
|
+
* 扫描范围:单文件或目录;扩展名 .html/.htm/.css/.vue/.svelte/.jsx/.tsx/.astro/.js/.mjs/.ts。
|
|
8
|
+
* 跳过 node_modules/.git/dist/build/vendor/coverage/test/tests/__tests__/fixtures/spec
|
|
9
|
+
* 与所有点目录;每文件读上限 2MB;总文件上限 600。
|
|
10
|
+
*
|
|
11
|
+
* 判据与边界(为什么这样切):
|
|
12
|
+
* - 只看「显式声明」:font-size / line-height / letter-spacing / text-align /
|
|
13
|
+
* max-width(width) / font-family。继承值不追踪(源码体检不是浏览器),
|
|
14
|
+
* 所以 clean 样本里正文规则必须显式写 line-height,否则会按「未声明」提示。
|
|
15
|
+
* - 字号只解析 px / rem(×16) / em(按父级=正文近似) / pt / %;clamp()/calc()/vw 与
|
|
16
|
+
* CSS 变量以外的动态值一律跳过而不是猜(宁可漏报,不误报)。CSS 变量
|
|
17
|
+
* (--fs-body 之类) 做一层简单求值,这是现代 CSS 最常见的写法。JS 内联样式对象
|
|
18
|
+
* (fontSize: 16)按 px 处理。
|
|
19
|
+
* - 中文项目与英文项目的阈值不同(行长/字距/对齐都有中文特例),CJK 判定取
|
|
20
|
+
* 「去注释后任一文件含中文字符」;单条规则再次要求「同一文件含中文」时才命中。
|
|
21
|
+
* - 正文(bodySize)= 出现次数最多的字号;并列时取最接近 16px 的(典型正文),
|
|
22
|
+
* 再并列取较小值——保证同输入结果稳定、不依赖遍历顺序。
|
|
23
|
+
* - 每条规则最多保留 4 条 finding(位置热点比穷举有用,也避免报告被刷屏);
|
|
24
|
+
* 没解析到任何字号时给 info S0,空目录给 info S0,明确说明「没有样本」而不是
|
|
25
|
+
* 假装 0 分干净。
|
|
26
|
+
*/
|
|
27
|
+
import { open, readFile, readdir, stat } from "node:fs/promises";
|
|
28
|
+
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
29
|
+
import { finding, finalize } from "./common.js";
|
|
30
|
+
import { auditPunct } from "./rules-punct.js";
|
|
31
|
+
import { auditContrast, parseColor } from "./rules-contrast.js";
|
|
32
|
+
import { auditHeadingOrder, auditTouchTargets } from "./rules-structure.js";
|
|
33
|
+
import { applyAllow, readIntent } from "./intent.js";
|
|
34
|
+
import { isSkippableDir, TEST_FILE_RE } from "../skip.js";
|
|
35
|
+
|
|
36
|
+
/** 参与扫描的源码扩展名(含 JS 系是为了 CSS-in-JS / 内联样式字符串)。 */
|
|
37
|
+
const EXTS = [".html", ".htm", ".css", ".vue", ".svelte", ".jsx", ".tsx", ".astro", ".js", ".mjs", ".ts"];
|
|
38
|
+
/** 依赖 / 构建产物 / 测试样本目录不体检(fixture 里故意写的坏排版不算项目问题)。 */
|
|
39
|
+
// 目录/文件跳过规则统一在 lib/skip.js(与 taste_check taste 共用一份)。
|
|
40
|
+
const PER_FILE_CAP = 2 * 1024 * 1024;
|
|
41
|
+
const MAX_FILES = 600;
|
|
42
|
+
/**
|
|
43
|
+
* 全局 findings 上限。
|
|
44
|
+
* 每条规则已按文件封顶,但大项目文件多,总量仍会涨:实测 Echo(600 文件)
|
|
45
|
+
* 返回 254 条,JSON 约 50 KB —— 每次调用都吃这么多上下文,而模型只看前 10 条。
|
|
46
|
+
*/
|
|
47
|
+
const MAX_FINDINGS = 60;
|
|
48
|
+
/** 每条规则最多报几条:热点定位比穷举有用,score 也会被 100 封顶。 */
|
|
49
|
+
const PER_RULE_CAP = 4;
|
|
50
|
+
/**
|
|
51
|
+
* 行长候选的最小宽度:低于这个值多半是按钮 / 徽标 / 图标容器,不是正文栏,
|
|
52
|
+
* 拿来算「每行多少字」会误报。240px @16px 约 15 字,已经是很窄的正文栏。
|
|
53
|
+
*/
|
|
54
|
+
const MIN_MEASURE_WIDTH = 240;
|
|
55
|
+
/**
|
|
56
|
+
* 正文栏选择器特征(整数宽度 + 字号一起声明时豁免):只有这类规则才当作「文本栏」看。
|
|
57
|
+
* 页面容器(.sheet{max-width:1120px})不带字号,不能拿正文尺寸去除——那是假误报。
|
|
58
|
+
*/
|
|
59
|
+
const MEASURE_SELECTOR_RE = /(?:^|[\s,>+~.])(?:p|article|blockquote|dd|li|section|main|header|footer|intro|lede|prose|content|body|copy|text|desc|summary|excerpt)(?:$|[\s,.:#\[.>+~])/i;
|
|
60
|
+
// TEST_FILE_RE 来自 lib/skip.js(与 taste_check taste 共用)。
|
|
61
|
+
const CJK_RE = /[\u4e00-\u9fa5]/;
|
|
62
|
+
const CJK_ALL_RE = /[\u4e00-\u9fa5]/g;
|
|
63
|
+
/** 本文件关心的声明;其余盒模型/颜色属性一律不解析。 */
|
|
64
|
+
const KNOWN_PROPS = new Set([
|
|
65
|
+
"font-size",
|
|
66
|
+
"line-height",
|
|
67
|
+
"letter-spacing",
|
|
68
|
+
"text-align",
|
|
69
|
+
"max-width",
|
|
70
|
+
"width",
|
|
71
|
+
"font-family",
|
|
72
|
+
"text-transform",
|
|
73
|
+
// 对比度检测需要前景/背景色;height 用于触摸目标尺寸
|
|
74
|
+
"color",
|
|
75
|
+
"background-color",
|
|
76
|
+
"background",
|
|
77
|
+
"height",
|
|
78
|
+
// font 简写:`font: 14px/1.6 system-ui` 写字的项目很多,只解 font-size 会漏
|
|
79
|
+
"font",
|
|
80
|
+
// 字重:S2 的最小字号判定需要「字号 × 字重」,光看字号会放过 12px/400 这种糊的组合
|
|
81
|
+
"font-weight",
|
|
82
|
+
// S10 混排字号基线:需要知道容器是不是 flex、交叉轴怎么对齐、行内怎么对齐
|
|
83
|
+
"display",
|
|
84
|
+
"align-items",
|
|
85
|
+
"vertical-align",
|
|
86
|
+
// S11 阴影层级:要读 box-shadow 才能看出档位与光源方向
|
|
87
|
+
"box-shadow",
|
|
88
|
+
// S13 组件状态矩阵:交互态的样式**几乎全靠这些属性**,不收它们会漏掉整条规则——
|
|
89
|
+
// 实测:`.btn:focus-visible{outline:2px solid …}` 因 outline 不在表里而被整条丢弃,
|
|
90
|
+
// 于是 S13 认为「这个组件没有 focus 态」,把写得完全正确的代码判成错。
|
|
91
|
+
// S14 负外边距:要靠 margin 才能看出「用负值硬拉」这种补丁
|
|
92
|
+
"margin",
|
|
93
|
+
"margin-top",
|
|
94
|
+
"margin-bottom",
|
|
95
|
+
"margin-left",
|
|
96
|
+
"margin-right",
|
|
97
|
+
"outline",
|
|
98
|
+
"outline-offset",
|
|
99
|
+
"opacity",
|
|
100
|
+
"transform",
|
|
101
|
+
"filter",
|
|
102
|
+
"cursor",
|
|
103
|
+
"border"
|
|
104
|
+
]);
|
|
105
|
+
|
|
106
|
+
/* ------------------------------ 基础工具 ------------------------------ */
|
|
107
|
+
|
|
108
|
+
function extOf(file) {
|
|
109
|
+
const index = file.lastIndexOf(".");
|
|
110
|
+
return index === -1 ? "" : file.slice(index).toLowerCase();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** 文件按语法类别走不同提取器:html(含模板)、css、js(CSS-in-JS / 字符串文案)。 */
|
|
114
|
+
function kindOf(file) {
|
|
115
|
+
const ext = extOf(file);
|
|
116
|
+
if (ext === ".css") return "css";
|
|
117
|
+
if (ext === ".html" || ext === ".htm" || ext === ".vue" || ext === ".svelte" || ext === ".astro") return "html";
|
|
118
|
+
return "js";
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** 数值保留 2 位小数;非有限值收敛成 0,保证输出可 JSON 无损往返。 */
|
|
122
|
+
function r2(value) {
|
|
123
|
+
return Number.isFinite(value) ? Math.round(value * 100) / 100 : 0;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* 每文件预计算行首下标(一次 O(n)),以后定位只需二分 O(log 行数)。
|
|
128
|
+
* 不用「每个块都从头数换行」:一个大 CSS(2MB、上千条规则)会退化成 O(n×块数),
|
|
129
|
+
* 工具 timeout 会在体检大项目时被拖死。
|
|
130
|
+
*/
|
|
131
|
+
function buildLineStarts(text) {
|
|
132
|
+
const starts = [0];
|
|
133
|
+
for (let i = 0; i < text.length; i += 1) if (text.charCodeAt(i) === 10) starts.push(i + 1);
|
|
134
|
+
return starts;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** 下标 → 1 基行号(二分查已排序的行首下标)。 */
|
|
138
|
+
function lineAt(starts, index) {
|
|
139
|
+
let low = 0;
|
|
140
|
+
let high = starts.length - 1;
|
|
141
|
+
while (low < high) {
|
|
142
|
+
const middle = (low + high + 1) >> 1;
|
|
143
|
+
if (starts[middle] <= index) low = middle;
|
|
144
|
+
else high = middle - 1;
|
|
145
|
+
}
|
|
146
|
+
return low + 1;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** 文件的行首下标缓存(同一文件只算一次)。 */
|
|
150
|
+
function lineStartsOf(file) {
|
|
151
|
+
if (file.lineStarts === undefined) file.lineStarts = buildLineStarts(file.stripped);
|
|
152
|
+
return file.lineStarts;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** 注释替换成等长空白(保留换行)—— 下标/行号仍与原文对齐,注释里的示例文案不会误报。 */
|
|
156
|
+
function blankOut(match) {
|
|
157
|
+
return match.replace(/[^\n]/g, " ");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function stripComments(text, kind) {
|
|
161
|
+
let out = text.replace(/\/\*[\s\S]*?\*\//g, blankOut).replace(/<!--[\s\S]*?-->/g, blankOut);
|
|
162
|
+
if (kind === "js") {
|
|
163
|
+
// 只处理 JS 系的行注释;`http://` 这类带冒号的协议前缀不动。
|
|
164
|
+
// CSS 的 // 不是注释,HTML 里 // 可能是协议相对 URL,都不能剥。
|
|
165
|
+
out = out.replace(/(^|[^:\\])\/\/[^\n]*/g, (match, prefix) => prefix + " ".repeat(match.length - prefix.length));
|
|
166
|
+
}
|
|
167
|
+
return out;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function escapeRegExp(text) {
|
|
171
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* 取众数。并列时的取舍顺序:
|
|
176
|
+
* 1) 用户指定了 prefer(正文按 16px)→ 取更接近 prefer 的;
|
|
177
|
+
* 2) tie = "larger" 时取较大值(标题档位),否则取较小值。
|
|
178
|
+
* 显式排序保证同一输入永远得到同一结果。
|
|
179
|
+
*/
|
|
180
|
+
function modeOf(values, { prefer = 0, tie = "smaller" } = {}) {
|
|
181
|
+
if (values.length === 0) return 0;
|
|
182
|
+
const counts = new Map();
|
|
183
|
+
for (const value of values) {
|
|
184
|
+
const key = r2(value);
|
|
185
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
186
|
+
}
|
|
187
|
+
let best = 0;
|
|
188
|
+
let bestCount = -1;
|
|
189
|
+
let bestDistance = Number.POSITIVE_INFINITY;
|
|
190
|
+
for (const [value, count] of counts) {
|
|
191
|
+
const distance = prefer > 0 ? Math.abs(value - prefer) : 0;
|
|
192
|
+
const better =
|
|
193
|
+
count > bestCount ||
|
|
194
|
+
(count === bestCount && distance < bestDistance) ||
|
|
195
|
+
(count === bestCount && distance === bestDistance && (tie === "larger" ? value > best : value < best));
|
|
196
|
+
if (better) {
|
|
197
|
+
best = value;
|
|
198
|
+
bestCount = count;
|
|
199
|
+
bestDistance = distance;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return best;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function uniqueSorted(values) {
|
|
206
|
+
return [...new Set(values.map(r2))].sort((a, b) => a - b);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function cjkCount(text) {
|
|
210
|
+
const matches = text.match(CJK_ALL_RE);
|
|
211
|
+
return matches === null ? 0 : matches.length;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/* ------------------------------ 文件采集 ------------------------------ */
|
|
215
|
+
|
|
216
|
+
/** 读文件(上限 2MB;超限只读前 2MB,仍计入 filesScanned,不虚报扫描数)。 */
|
|
217
|
+
async function readCapped(file) {
|
|
218
|
+
let info;
|
|
219
|
+
try {
|
|
220
|
+
info = await stat(file);
|
|
221
|
+
} catch {
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
if (!info.isFile()) return null;
|
|
225
|
+
if (info.size <= PER_FILE_CAP) {
|
|
226
|
+
try {
|
|
227
|
+
return await readFile(file, "utf8");
|
|
228
|
+
} catch {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
let handle;
|
|
233
|
+
try {
|
|
234
|
+
handle = await open(file, "r");
|
|
235
|
+
const buf = Buffer.alloc(PER_FILE_CAP);
|
|
236
|
+
const { bytesRead } = await handle.read(buf, 0, PER_FILE_CAP, 0);
|
|
237
|
+
return buf.subarray(0, bytesRead).toString("utf8");
|
|
238
|
+
} catch {
|
|
239
|
+
return null;
|
|
240
|
+
} finally {
|
|
241
|
+
if (handle !== undefined) await handle.close();
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function makeFile(abs, rel, raw) {
|
|
246
|
+
const kind = kindOf(abs);
|
|
247
|
+
return { path: abs, rel, raw, stripped: stripComments(raw, kind), kind };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* root(单文件/目录)→ { files:[{path,rel,raw,stripped,kind}], root, truncated }。
|
|
252
|
+
* 目录广度优先、每层按文件名排序:同输入的文件顺序稳定(确定性测试依赖这点)。
|
|
253
|
+
* 路径不存在抛中文错误(工具层还有一层英文 not found 兜底,这里自己也要能说人话)。
|
|
254
|
+
*/
|
|
255
|
+
async function collect(root) {
|
|
256
|
+
const abs = resolve(root);
|
|
257
|
+
let info;
|
|
258
|
+
try {
|
|
259
|
+
info = await stat(abs);
|
|
260
|
+
} catch {
|
|
261
|
+
throw new Error(`路径不存在:${abs}(root 应为 HTML/CSS 文件或目录)`);
|
|
262
|
+
}
|
|
263
|
+
if (info.isFile()) {
|
|
264
|
+
if (!EXTS.includes(extOf(abs))) return { files: [], root: abs, truncated: false };
|
|
265
|
+
const raw = await readCapped(abs);
|
|
266
|
+
const files = raw === null ? [] : [makeFile(abs, basename(abs), raw)];
|
|
267
|
+
return { files, root: abs, truncated: false };
|
|
268
|
+
}
|
|
269
|
+
if (!info.isDirectory()) throw new Error(`不是文件或目录:${abs}`);
|
|
270
|
+
const files = [];
|
|
271
|
+
const queue = [abs];
|
|
272
|
+
while (queue.length > 0 && files.length < MAX_FILES) {
|
|
273
|
+
const dir = queue.shift();
|
|
274
|
+
let entries;
|
|
275
|
+
try {
|
|
276
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
277
|
+
} catch {
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
281
|
+
for (const entry of entries) {
|
|
282
|
+
if (files.length >= MAX_FILES) break;
|
|
283
|
+
const full = join(dir, entry.name);
|
|
284
|
+
if (entry.isDirectory()) {
|
|
285
|
+
if (!isSkippableDir(entry.name) && !entry.name.startsWith(".")) queue.push(full);
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
if (!entry.isFile()) continue;
|
|
289
|
+
if (!EXTS.includes(extOf(entry.name))) continue;
|
|
290
|
+
if (entry.name.endsWith(".min.js") || entry.name.endsWith(".map")) continue;
|
|
291
|
+
const raw = await readCapped(full);
|
|
292
|
+
if (raw === null) continue;
|
|
293
|
+
files.push(makeFile(full, relative(abs, full).split(sep).join("/"), raw));
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return { files, root: abs, truncated: files.length >= MAX_FILES };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/* ------------------------------ CSS 解析 ------------------------------ */
|
|
300
|
+
|
|
301
|
+
/** 声明名归一:fontSize → font-size、maxWidth → max-width(JS 内联样式对象也用这套)。 */
|
|
302
|
+
function normalizeProp(name) {
|
|
303
|
+
return name.replace(/[A-Z]/g, (char) => `-${char.toLowerCase()}`).toLowerCase();
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* 从一段声明体里解析属性;值(同时兼容 CSS `a: b;` 与 JS 对象 `a: b,`)。
|
|
308
|
+
* JS 对象同一行多个属性时,值截到下一个「名字:」之前;CSS 的 font-family 逗号栈
|
|
309
|
+
* 不会命中这个模式(逗号后没有冒号),所以不会被截断。
|
|
310
|
+
*/
|
|
311
|
+
function parseDecls(body) {
|
|
312
|
+
const out = [];
|
|
313
|
+
// 值的结束条件:`;` / `}` / 换行,或「逗号 + 下一个属性名:」。最后一条是为了
|
|
314
|
+
// 同时支持 JS 对象写法 `{ fontSize: 16, lineHeight: 1.2 }`(同一行多属性)
|
|
315
|
+
// 而不会把 font-family 的逗号栈(「A, B, sans-serif」后面没有冒号)截断。
|
|
316
|
+
const re = /(?:^|[;{}\n,])\s*([A-Za-z][A-Za-z0-9-]*)\s*:\s*([^;{}\n]+?)(?=;|}|\n|,\s*[A-Za-z][A-Za-z0-9-]*\s*:|$)/g;
|
|
317
|
+
let match;
|
|
318
|
+
while ((match = re.exec(body)) !== null) {
|
|
319
|
+
const prop = normalizeProp(match[1]);
|
|
320
|
+
if (!KNOWN_PROPS.has(prop)) continue;
|
|
321
|
+
const value = match[2].trim();
|
|
322
|
+
if (value === "") continue;
|
|
323
|
+
out.push({ prop, value, index: match.index });
|
|
324
|
+
}
|
|
325
|
+
return out;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* 抽出所有「叶子」{} 块(CSS 规则 / JS 对象字面量)。
|
|
330
|
+
* 有子块的外层(@media、函数体)不解析,避免把内部声明重复统计一遍。
|
|
331
|
+
* 内联 style="..." 单独补一轮(它不在花括号里)。
|
|
332
|
+
*/
|
|
333
|
+
function parseRules(file) {
|
|
334
|
+
const rules = [];
|
|
335
|
+
const text = file.stripped;
|
|
336
|
+
const lineStarts = lineStartsOf(file);
|
|
337
|
+
const stack = [];
|
|
338
|
+
const blocks = [];
|
|
339
|
+
// 顶层选择器的起点 = 上一条规则结束处(不能写死 0:大 CSS 会退化成 O(n²) 切片)
|
|
340
|
+
let topCursor = 0;
|
|
341
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
342
|
+
const char = text[i];
|
|
343
|
+
if (char === "{") {
|
|
344
|
+
if (stack.length > 0) stack[stack.length - 1].hasChild = true;
|
|
345
|
+
stack.push({ open: i, selectorStart: stack.length > 0 ? stack[stack.length - 1].open + 1 : topCursor, hasChild: false });
|
|
346
|
+
} else if (char === "}") {
|
|
347
|
+
const open = stack.pop();
|
|
348
|
+
if (open === undefined) continue;
|
|
349
|
+
if (stack.length === 0) topCursor = i + 1;
|
|
350
|
+
const selector = text
|
|
351
|
+
.slice(open.selectorStart, open.open)
|
|
352
|
+
.split(/[;{}]/)
|
|
353
|
+
.pop()
|
|
354
|
+
.trim()
|
|
355
|
+
.replace(/\s+/g, " ");
|
|
356
|
+
blocks.push({ selector, body: text.slice(open.open + 1, i), bodyStart: open.open + 1, hasChild: open.hasChild });
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
for (const block of blocks) {
|
|
360
|
+
if (block.hasChild) continue;
|
|
361
|
+
const decls = parseDecls(block.body);
|
|
362
|
+
if (decls.length > 0) rules.push({ file, selector: block.selector, line: lineAt(lineStarts, block.bodyStart), decls });
|
|
363
|
+
}
|
|
364
|
+
const inlineRe = /style\s*=\s*["']([^"']*)["']/g;
|
|
365
|
+
let inline;
|
|
366
|
+
while ((inline = inlineRe.exec(text)) !== null) {
|
|
367
|
+
const decls = parseDecls(inline[1]);
|
|
368
|
+
if (decls.length > 0) rules.push({ file, selector: "inline", line: lineAt(lineStarts, inline.index), decls });
|
|
369
|
+
}
|
|
370
|
+
return rules;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** 取某个属性的第一条声明(同规则内重复声明取首条;!important 不影响本体检)。 */
|
|
374
|
+
function firstDecl(rule, prop) {
|
|
375
|
+
return rule.decls.find((decl) => decl.prop === prop);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Tailwind 字号 → px。命名类按官方默认值;任意值直接取 (px/rem/em)。
|
|
380
|
+
* 依据 Tailwind v3 默认 scale:xs .75rem / sm .875rem / base 1rem / lg 1.125rem /
|
|
381
|
+
* xl 1.25rem / 2xl 1.5rem / 3xl 1.875rem / 4xl 2.25rem / 5xl 3rem(按 16px 根字号换算)。
|
|
382
|
+
*/
|
|
383
|
+
const TAILWIND_NAMED_SIZE = {
|
|
384
|
+
xs: 12,
|
|
385
|
+
sm: 14,
|
|
386
|
+
base: 16,
|
|
387
|
+
lg: 18,
|
|
388
|
+
xl: 20,
|
|
389
|
+
"2xl": 24,
|
|
390
|
+
"3xl": 30,
|
|
391
|
+
"4xl": 36,
|
|
392
|
+
"5xl": 48
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* 从 JS 系文件里抽 Tailwind 字号类,变成与 CSS 声明等价的合成规则。
|
|
397
|
+
*
|
|
398
|
+
* 为什么必须有(实测教训 2026-11-07,Flow-Pilot):那个项目 100% 用
|
|
399
|
+
* `text-[11px]` / `text-[13.5px]` / `text-[19px]` 写字号,CSS 里只有 3 个值。
|
|
400
|
+
* 不解析 Tailwind 就会得出「字号只有 1 档、无层级」的**反向错误结论**;
|
|
401
|
+
* 真实情况是 **10 档、带 0.5px 步进**——恰好是工具最该抓的东西。
|
|
402
|
+
*/
|
|
403
|
+
function tailwindSizeRules(file) {
|
|
404
|
+
const out = [];
|
|
405
|
+
const lineStarts = lineStartsOf(file);
|
|
406
|
+
const push = (raw, index) => {
|
|
407
|
+
out.push({
|
|
408
|
+
file,
|
|
409
|
+
selector: "tailwind",
|
|
410
|
+
line: lineAt(lineStarts, index),
|
|
411
|
+
decls: [{ prop: "font-size", value: raw, index }]
|
|
412
|
+
});
|
|
413
|
+
};
|
|
414
|
+
for (const m of file.stripped.matchAll(/\btext-\[(\d+(?:\.\d+)?)(px|rem|em)\]/g)) {
|
|
415
|
+
push(`${m[1]}${m[2]}`, m.index ?? 0);
|
|
416
|
+
}
|
|
417
|
+
for (const m of file.stripped.matchAll(/\btext-(xs|sm|base|lg|xl|2xl|3xl|4xl|5xl)\b/g)) {
|
|
418
|
+
const px = TAILWIND_NAMED_SIZE[m[1]];
|
|
419
|
+
if (px !== undefined) push(`${px}px`, m.index ?? 0);
|
|
420
|
+
}
|
|
421
|
+
return out;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/* ------------------------------ 数值解析 ------------------------------ */
|
|
425
|
+
|
|
426
|
+
/** 取声明的单位(用于把 em 延后到「正文已知」后再换算)。 */
|
|
427
|
+
function unitOf(raw) {
|
|
428
|
+
const value = String(raw).trim().replace(/!important/gi, "").trim();
|
|
429
|
+
const match = /^([+-]?(?:\d+\.?\d*|\.\d+))(px|rem|em|pt|%)?$/.exec(value);
|
|
430
|
+
return match === null ? "" : (match[2] ?? "");
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* 字号值 → px。
|
|
435
|
+
* 边界:px/rem(×16)/pt(×4/3)/%(相对 parent);em 用 parent 近似(项目里 parent 取正文,
|
|
436
|
+
* 没解析到正文时取 16)。var(--x) 支持一层查表;clamp()/calc()/vw 等动态值返回 null
|
|
437
|
+
* (跳过而不是猜一个数,避免把响应式字号算成本地固定字号)。
|
|
438
|
+
*/
|
|
439
|
+
function parseSizeValue(raw, parentSize, varMap) {
|
|
440
|
+
const value = String(raw).trim().replace(/!important/gi, "").trim();
|
|
441
|
+
const reference = parentSize > 0 ? parentSize : 16;
|
|
442
|
+
const varMatch = /^var\(\s*--([A-Za-z0-9_-]+)\s*(?:,\s*([^)]*))?\)$/.exec(value);
|
|
443
|
+
if (varMatch !== null) {
|
|
444
|
+
if (varMap !== undefined && varMap.has(varMatch[1])) return varMap.get(varMatch[1]);
|
|
445
|
+
return parseSizeValue(varMatch[2] ?? "", reference, varMap);
|
|
446
|
+
}
|
|
447
|
+
const match = /^([+-]?(?:\d+\.?\d*|\.\d+))(px|rem|em|pt|%)?$/.exec(value);
|
|
448
|
+
if (match === null) return null;
|
|
449
|
+
const number = Number(match[1]);
|
|
450
|
+
if (!Number.isFinite(number) || number <= 0) return null;
|
|
451
|
+
const unit = match[2] ?? "";
|
|
452
|
+
if (unit === "px" || unit === "") return r2(number);
|
|
453
|
+
if (unit === "rem") return r2(number * 16);
|
|
454
|
+
if (unit === "em") return r2(number * reference);
|
|
455
|
+
if (unit === "pt") return r2((number * 4) / 3);
|
|
456
|
+
if (unit === "%") return r2((number / 100) * reference);
|
|
457
|
+
return null;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** 宽度值 → px(只认 px/rem/em;百分数/vw 无法折算确定像素,跳过)。 */
|
|
461
|
+
function parseWidth(raw, bodySize, varMap) {
|
|
462
|
+
const value = String(raw).trim().replace(/!important/gi, "").trim();
|
|
463
|
+
const reference = bodySize > 0 ? bodySize : 16;
|
|
464
|
+
const varMatch = /^var\(\s*--([A-Za-z0-9_-]+)\s*(?:,\s*([^)]*))?\)$/.exec(value);
|
|
465
|
+
if (varMatch !== null) {
|
|
466
|
+
if (varMap !== undefined && varMap.has(varMatch[1])) return varMap.get(varMatch[1]);
|
|
467
|
+
return parseWidth(varMatch[2] ?? "", bodySize, varMap);
|
|
468
|
+
}
|
|
469
|
+
const match = /^([+-]?(?:\d+\.?\d*|\.\d+))(px|rem|em)?$/.exec(value);
|
|
470
|
+
if (match === null) return null;
|
|
471
|
+
const number = Number(match[1]);
|
|
472
|
+
if (!Number.isFinite(number) || number <= 0) return null;
|
|
473
|
+
const unit = match[2] ?? "px";
|
|
474
|
+
if (unit === "px") return number;
|
|
475
|
+
if (unit === "rem") return number * 16;
|
|
476
|
+
if (unit === "em") return number * reference;
|
|
477
|
+
return null;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* line-height → 与字号的比例。
|
|
482
|
+
* 无单位数字本身就是倍数;em 相对元素自身字号(比例=数值);px/pt/rem/% 折算。
|
|
483
|
+
* normal/inherit 等关键字与离谱值(不在 0.5–6 倍内,多为误解析)返回 null。
|
|
484
|
+
*/
|
|
485
|
+
function resolveLineHeight(raw, fontSize, bodySize) {
|
|
486
|
+
const value = String(raw).trim().replace(/!important/gi, "").trim();
|
|
487
|
+
if (/^(normal|inherit|initial|unset|revert|revert-layer|auto)$/i.test(value)) return null;
|
|
488
|
+
const match = /^([+-]?(?:\d+\.?\d*|\.\d+))(px|rem|em|pt|%)?$/.exec(value);
|
|
489
|
+
if (match === null) return null;
|
|
490
|
+
const number = Number(match[1]);
|
|
491
|
+
if (!Number.isFinite(number) || number < 0) return null;
|
|
492
|
+
const unit = match[2] ?? "";
|
|
493
|
+
const reference = fontSize > 0 ? fontSize : bodySize > 0 ? bodySize : 16;
|
|
494
|
+
let ratio;
|
|
495
|
+
if (unit === "" || unit === "em") ratio = number;
|
|
496
|
+
else if (unit === "px") ratio = number / reference;
|
|
497
|
+
else if (unit === "pt") ratio = ((number * 4) / 3) / reference;
|
|
498
|
+
else if (unit === "rem") ratio = (number * 16) / reference;
|
|
499
|
+
else if (unit === "%") ratio = number / 100;
|
|
500
|
+
else return null;
|
|
501
|
+
if (!(ratio > 0.5 && ratio < 6)) return null;
|
|
502
|
+
return r2(ratio);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* font-family 栈归一。系统栈(-apple-system / BlinkMacSystemFont / 纯 system-ui+generic)
|
|
507
|
+
* 算同一个「系统默认」——它们表达的是「用系统字体」,不是第 N 个设计字体。
|
|
508
|
+
* 返回 null 表示 inherit/initial 这类非具体字体栈。
|
|
509
|
+
*/
|
|
510
|
+
function normalizeFamily(raw) {
|
|
511
|
+
const value = String(raw).trim().replace(/!important/gi, "").trim();
|
|
512
|
+
if (value === "" || /^(inherit|initial|unset|revert|revert-layer)$/i.test(value)) return null;
|
|
513
|
+
const stack = value
|
|
514
|
+
.split(",")
|
|
515
|
+
.map((part) => part.trim().replace(/^["']|[\"']$/g, "").toLowerCase())
|
|
516
|
+
.filter((part) => part !== "");
|
|
517
|
+
if (stack.length === 0) return null;
|
|
518
|
+
if (stack.some((part) => part === "-apple-system" || part === "blinkmacsystemfont")) return "system";
|
|
519
|
+
const generic = new Set(["system-ui", "sans-serif", "serif", "monospace", "ui-sans-serif", "ui-serif", "ui-monospace", "cursive", "fantasy", "emoji", "math", "fangsong"]);
|
|
520
|
+
if (stack[0] === "system-ui" && stack.slice(1).every((part) => generic.has(part))) return "system";
|
|
521
|
+
return stack.join(",");
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* letter-spacing 是否「拉宽到破坏中文」:>0.05em 或折合 >1px(正文语境由调用方判定)。
|
|
526
|
+
* 负值(收紧)不算——中文正文里更常见的问题是加宽。
|
|
527
|
+
*/
|
|
528
|
+
function letterSpacingHit(raw, fontSize) {
|
|
529
|
+
const value = String(raw).trim().replace(/!important/gi, "").trim();
|
|
530
|
+
if (value === "" || /^(normal|inherit|initial|unset)$/i.test(value)) return null;
|
|
531
|
+
const match = /^([+-]?(?:\d+\.?\d*|\.\d+))(px|rem|em|pt)?$/.exec(value);
|
|
532
|
+
if (match === null) return null;
|
|
533
|
+
const number = Number(match[1]);
|
|
534
|
+
if (!Number.isFinite(number) || number <= 0) return null;
|
|
535
|
+
const unit = match[2] ?? "px";
|
|
536
|
+
const reference = fontSize > 0 ? fontSize : 16;
|
|
537
|
+
const px = unit === "px" ? number : unit === "rem" ? number * 16 : unit === "em" ? number * reference : (number * 4) / 3;
|
|
538
|
+
if (unit === "em" && number > 0.05) return { px: r2(px) };
|
|
539
|
+
if (px > 1) return { px: r2(px) };
|
|
540
|
+
return null;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/* ------------------------------ 文案提取 ------------------------------ */
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* 可见文案近似提取(用于混排空格 / 长段落判定):
|
|
547
|
+
* html:去掉 style/script 后剥标签,按行切成文本段(属性里的英文名不算文案);
|
|
548
|
+
* js:只取字符串字面量(不把标识符当文案);
|
|
549
|
+
* css:只取 content:"..."。
|
|
550
|
+
*/
|
|
551
|
+
/**
|
|
552
|
+
* 去掉标签,只留下**文本节点**(保留换行以维持行号)。
|
|
553
|
+
*
|
|
554
|
+
* 不能用 `/<[^>]*>/g`:属性值里带 `>`(`:class="a > b"`、`title="宽度 > 80"`)时,
|
|
555
|
+
* 正则会提前在 `>` 处截断,把后面的属性文本漏成“文案”。
|
|
556
|
+
* 实测(2026-11-08,Echo 项目):`aria-label="合成引擎"`、`12" depth="3" class="ech`
|
|
557
|
+
* 这类残片被当成中文文案里的直引号报了出来。
|
|
558
|
+
*/
|
|
559
|
+
function stripTagsKeepLines(src) {
|
|
560
|
+
let out = "";
|
|
561
|
+
let inTag = false;
|
|
562
|
+
let quote = null;
|
|
563
|
+
for (let i = 0; i < src.length; i += 1) {
|
|
564
|
+
const ch = src[i];
|
|
565
|
+
const nl = ch === "\n";
|
|
566
|
+
if (inTag === false) {
|
|
567
|
+
if (ch === "<") {
|
|
568
|
+
inTag = true;
|
|
569
|
+
quote = null;
|
|
570
|
+
} else out += ch;
|
|
571
|
+
continue;
|
|
572
|
+
}
|
|
573
|
+
// 标签本身**不产字符**,只保留它里的换行:浏览器渲染 `词</b>:` 就是 `词:`,
|
|
574
|
+
// 若把标签换成空格,会凭空造出“汉字与全角标点之间有多余空格”的假报
|
|
575
|
+
// (实测:Colibri 的 `<b>组合上下文</b>:` 一口气报了 3 条 punct-space-before-cjk-punct,
|
|
576
|
+
// 全是剥标签时自己加的空格,原文没这个问题)。
|
|
577
|
+
if (quote !== null) {
|
|
578
|
+
if (ch === quote) quote = null;
|
|
579
|
+
if (nl) out += "\n";
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
582
|
+
if (ch === '"' || ch === "'") {
|
|
583
|
+
quote = ch;
|
|
584
|
+
continue;
|
|
585
|
+
}
|
|
586
|
+
if (ch === ">") {
|
|
587
|
+
inTag = false;
|
|
588
|
+
continue;
|
|
589
|
+
}
|
|
590
|
+
if (nl) out += "\n";
|
|
591
|
+
}
|
|
592
|
+
return out;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function textSegments(file) {
|
|
596
|
+
const segments = [];
|
|
597
|
+
const text = file.stripped;
|
|
598
|
+
if (file.kind === "html") {
|
|
599
|
+
const plain = stripTagsKeepLines(
|
|
600
|
+
text.replace(/<style[\s\S]*?<\/style>/gi, "\n").replace(/<script[\s\S]*?<\/script>/gi, "\n")
|
|
601
|
+
);
|
|
602
|
+
for (const part of plain.split(/\n/)) {
|
|
603
|
+
// 连空格收成一个:上面把 `<script>`/`{{}}`/`<code>` 挖空时留下的是一长串空格,
|
|
604
|
+
// 不收就会凭空造出“汉字与全角标点之间有多余空格”的假报
|
|
605
|
+
// (实测 Echo:punct-paren-space×10 + punct-space-before-cjk-punct×5 全是这个)。
|
|
606
|
+
// 浏览器本来就把连续空白折叠成一个,收空格与渲染一致。
|
|
607
|
+
const segment = part.replace(/&[a-z#0-9]+;/gi, " ").replace(/[ \t]{2,}/g, " ").trim();
|
|
608
|
+
if (segment !== "") segments.push(segment);
|
|
609
|
+
}
|
|
610
|
+
} else if (file.kind === "js") {
|
|
611
|
+
const re = /["'`]([^"'`\n]{2,})["'`]/g;
|
|
612
|
+
let match;
|
|
613
|
+
while ((match = re.exec(text)) !== null) segments.push(match[1]);
|
|
614
|
+
} else {
|
|
615
|
+
const re = /content\s*:\s*["']([^"']*)["']/gi;
|
|
616
|
+
let match;
|
|
617
|
+
while ((match = re.exec(text)) !== null) segments.push(match[1]);
|
|
618
|
+
}
|
|
619
|
+
return segments;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/** 文本段提取结果缓存(S6 中心对齐检查会对同一文件查多次)。 */
|
|
623
|
+
function segmentsOf(file) {
|
|
624
|
+
if (file.segments === undefined) file.segments = textSegments(file);
|
|
625
|
+
return file.segments;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function hasLongCJK(file) {
|
|
629
|
+
return segmentsOf(file).some((segment) => cjkCount(segment) > 40);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/** 选择器 → 可用于在模板里找元素的 token(class / id / 标签名)。 */
|
|
633
|
+
function selectorTokens(selector) {
|
|
634
|
+
const tokens = [];
|
|
635
|
+
for (const match of selector.matchAll(/\.([A-Za-z_-][A-Za-z0-9_-]*)/g)) tokens.push({ kind: "class", value: match[1] });
|
|
636
|
+
for (const match of selector.matchAll(/#([A-Za-z_-][A-Za-z0-9_-]*)/g)) tokens.push({ kind: "id", value: match[1] });
|
|
637
|
+
for (const match of selector.matchAll(/(?:^|[\s,>+~])([a-z][a-z0-9]*)/g)) tokens.push({ kind: "tag", value: match[1] });
|
|
638
|
+
return tokens;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function markupHasToken(text, token) {
|
|
642
|
+
const value = escapeRegExp(token.value);
|
|
643
|
+
if (token.kind === "tag") return new RegExp(`<${value}[\\s>/]`, "i").test(text);
|
|
644
|
+
const attribute = token.kind === "class" ? "(?:class|className)" : "id";
|
|
645
|
+
return new RegExp(`${attribute}\\s*=\\s*["'{][^"'}\\n]*\\b${value}\\b`, "i").test(text);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/** text-align:center 规则命中的「长中文段落」文件(同文件或 class/id/标签链接到的模板)。 */
|
|
649
|
+
function centerTargets(rule, files) {
|
|
650
|
+
// 控件里的居中不是错误:`<button style="text-align:center">` 是分段控件/按钮的标准写法。
|
|
651
|
+
// 实测(Colibri 三档灵敏度切换器):三条 `button` 规则被报成“长段落居中”——
|
|
652
|
+
// 规则只看了“这个文件里有长文 + 这条规则居中”,没看**居中的是什么元素**。
|
|
653
|
+
if (/(?:^|[\s,>+~])(?:button|input|select|textarea|label|summary)\b/i.test(rule.selector)) return [];
|
|
654
|
+
if (/(?:^|[\s,>+~.])(?:btn|button|chip|pill|tag|badge|empty|loading|spinner|hint|tip|meta|label)(?:$|[\s,.:#[>+~])/i.test(rule.selector)) return [];
|
|
655
|
+
// 真的该管的只有**正文容器**:不猜,只认白名单。
|
|
656
|
+
// 之前用“这个文件里有 >40 个中文字符”当条件,于是 `.wb-funnel-step`(漏斗小卡)、
|
|
657
|
+
// `.loading`(加载占位)、`.empty`(空状态)全被判成“长段落居中”——它们本来就是
|
|
658
|
+
// 短标签居中,是正确写法。宁可不报,也不要为了让一条规则“看得见”而误伤常态。
|
|
659
|
+
if (/(?:^|[\s,>+~.])(?:p|article|blockquote|figcaption|\w*(?:prose|paragraph|intro|lead|desc|description|copy|note|body)\w*)(?:$|[\s,.:#[>+~])/i.test(rule.selector) === false) return [];
|
|
660
|
+
if (rule.file.kind === "html") return hasLongCJK(rule.file) ? [rule.file] : [];
|
|
661
|
+
const tokens = selectorTokens(rule.selector);
|
|
662
|
+
if (tokens.length === 0) return [];
|
|
663
|
+
return files.filter((file) => (file.kind === "html" || file.kind === "js") && hasLongCJK(file) && tokens.some((token) => markupHasToken(file.stripped, token)));
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/** 混排空格计数:汉字与拉丁字母/数字紧贴。纯数字紧贴(2026年、第3章)不算——
|
|
667
|
+
* 中文排版规范只要求汉字与拉丁字母之间加空格,数字紧贴是正常写法,不加过滤会
|
|
668
|
+
* 把每篇带年份/编号的中文页面都误报一遍。 */
|
|
669
|
+
function mixedSpacingHits(file) {
|
|
670
|
+
const hits = [];
|
|
671
|
+
const re = /[\u4e00-\u9fa5][A-Za-z0-9]|[A-Za-z0-9][\u4e00-\u9fa5]/g;
|
|
672
|
+
for (const segment of segmentsOf(file)) {
|
|
673
|
+
let match;
|
|
674
|
+
re.lastIndex = 0;
|
|
675
|
+
while ((match = re.exec(segment)) !== null) {
|
|
676
|
+
const latin = match[0].match(/[A-Za-z0-9]/);
|
|
677
|
+
if (latin === null || /[0-9]/.test(latin[0])) continue;
|
|
678
|
+
hits.push({ text: match[0], segment });
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
return hits;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/* ------------------------------ 发现项收集 ------------------------------ */
|
|
685
|
+
|
|
686
|
+
/** 同类去重 + 每规则封顶的收集器(保证报告短、可复现)。 */
|
|
687
|
+
function createReporter() {
|
|
688
|
+
const items = [];
|
|
689
|
+
const seen = new Set();
|
|
690
|
+
const perRule = new Map();
|
|
691
|
+
return {
|
|
692
|
+
items,
|
|
693
|
+
push(entry) {
|
|
694
|
+
const count = perRule.get(entry.rule) ?? 0;
|
|
695
|
+
if (count >= PER_RULE_CAP) return;
|
|
696
|
+
// 去重不用带 where:同一句话被报两次(一次带行号一次不带)也是噪声。
|
|
697
|
+
const key = `${entry.rule}|${entry.severity}|${entry.message}`;
|
|
698
|
+
if (seen.has(key)) return;
|
|
699
|
+
seen.add(key);
|
|
700
|
+
perRule.set(entry.rule, count + 1);
|
|
701
|
+
items.push(entry);
|
|
702
|
+
}
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/** 规则位置:相对路径:行号(单文件 root 用文件名)。 */
|
|
707
|
+
function at(file, line) {
|
|
708
|
+
return `${file.rel}:${line}`;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* 应用外壳(app shell)识别:桌面/Web 应用的「工具栏 + 栏位」界面,
|
|
713
|
+
* 不是文档/落地页。两种形态对排版的要求不同,混用规则会误报:
|
|
714
|
+
* - S1「字号梯级 1.6×」:文档要有 hero 标题,应用界面就是 11–16px 紧梯级;
|
|
715
|
+
* - S9「标题层级」:组件用 h3/h4 是为了字号,不是为了文档大纲。
|
|
716
|
+
*
|
|
717
|
+
* 判据(两条同时成立才算,宁严不宽):
|
|
718
|
+
* ① 根元素声明了满高(height:100% / 100vh / 100dvh)或 overflow:hidden;
|
|
719
|
+
* ② 存在 display:flex/grid 的布局容器(应用一定用栅格/弹性布局)。
|
|
720
|
+
* 实测:在线文档参照稿命中;本插件预览页 / 视频站参照稿(整页滚动)不命中。
|
|
721
|
+
*/
|
|
722
|
+
function detectAppShell(rules) {
|
|
723
|
+
let fullHeightRoot = false;
|
|
724
|
+
let hasLayout = false;
|
|
725
|
+
for (const rule of rules) {
|
|
726
|
+
const sel = rule.selector.toLowerCase();
|
|
727
|
+
const isRoot = /(^|[\s,])(html|body|#root|#app|:root)(\s|$|,)/.test(sel) || /^(html|body)$/.test(sel.trim());
|
|
728
|
+
for (const prop of ["height", "overflow"]) {
|
|
729
|
+
const decl = rule.decls.find((d) => d.prop === prop);
|
|
730
|
+
if (decl === undefined) continue;
|
|
731
|
+
const v = String(decl.value).trim().toLowerCase();
|
|
732
|
+
if (isRoot && ((prop === "height" && /^(100%|100vh|100dvh|100svh)$/.test(v)) || (prop === "overflow" && v === "hidden"))) {
|
|
733
|
+
fullHeightRoot = true;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
const display = rule.decls.find((d) => d.prop === "display");
|
|
737
|
+
if (display !== undefined && /^(flex|inline-flex|grid|inline-grid)$/.test(String(display.value).trim().toLowerCase())) hasLayout = true;
|
|
738
|
+
}
|
|
739
|
+
return fullHeightRoot && hasLayout;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/* ------------------------------ 主流程 ------------------------------ */
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* 扫 UI 源码做排版体检。
|
|
746
|
+
* @param {string} root 绝对路径(单文件或目录);相对路径由工具层解析
|
|
747
|
+
* @returns {Promise<object>} finalize 报告(mode/target/score/grade/counts/metrics/findings)
|
|
748
|
+
*/
|
|
749
|
+
export async function auditSource(root) {
|
|
750
|
+
if (typeof root !== "string" || root.trim() === "") throw new Error("auditSource 需要 root(HTML/CSS 文件或目录)");
|
|
751
|
+
const { files, root: absolute, truncated } = await collect(root);
|
|
752
|
+
const report = createReporter();
|
|
753
|
+
const target = absolute;
|
|
754
|
+
|
|
755
|
+
/* 空扫描:不能当成「0 分 = 排版没问题」。给一条 info 明确说「没有样本」,
|
|
756
|
+
* 分数只有 info 权重(2),grade 仍是 clean,但报告里有解释。 */
|
|
757
|
+
if (files.length === 0) {
|
|
758
|
+
report.push(
|
|
759
|
+
finding({
|
|
760
|
+
severity: "info",
|
|
761
|
+
rule: "S0",
|
|
762
|
+
message: `未扫到可体检的 UI 源码(支持 ${EXTS.join(" / ")}),没有样本可评估`,
|
|
763
|
+
fix: "确认 root 指向 HTML/CSS 源码目录;若只有构建产物,请指向前端源码或改用 action=svg / image",
|
|
764
|
+
where: target
|
|
765
|
+
})
|
|
766
|
+
);
|
|
767
|
+
return finalize({
|
|
768
|
+
mode: "ui",
|
|
769
|
+
target,
|
|
770
|
+
metrics: {
|
|
771
|
+
filesScanned: 0,
|
|
772
|
+
fontSizes: [],
|
|
773
|
+
bodySize: 0,
|
|
774
|
+
scaleRatio: 0,
|
|
775
|
+
familyCount: 0,
|
|
776
|
+
lineHeightRatios: [],
|
|
777
|
+
violations: report.items.length,
|
|
778
|
+
truncated,
|
|
779
|
+
hasCJK: false,
|
|
780
|
+
// 空扫描:rulesRun=0 / measured=[] 是给调用方的信号——
|
|
781
|
+
// 「clean 但什么都没测」必须能和「测了且过了」区分开。
|
|
782
|
+
rulesRun: 0,
|
|
783
|
+
measured: []
|
|
784
|
+
},
|
|
785
|
+
findings: report.items
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
const hasCJK = files.some((file) => CJK_RE.test(file.stripped));
|
|
790
|
+
const rules = [];
|
|
791
|
+
for (const file of files) {
|
|
792
|
+
rules.push(...parseRules(file));
|
|
793
|
+
// JS 系文件额外抽 Tailwind 字号类(组件化项目的主字号通常只在这里)
|
|
794
|
+
if (file.kind === "js" && !TEST_FILE_RE.test(file.path ?? "")) rules.push(...tailwindSizeRules(file));
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/* --- CSS 变量简单求值(--fs-body: 16px 之类)--- */
|
|
798
|
+
const varMap = new Map();
|
|
799
|
+
for (const file of files) {
|
|
800
|
+
const re = /--([A-Za-z0-9_-]+)\s*:\s*([^;}\n]+)/g;
|
|
801
|
+
let match;
|
|
802
|
+
while ((match = re.exec(file.stripped)) !== null) {
|
|
803
|
+
const size = parseSizeValue(match[2], 16, varMap);
|
|
804
|
+
if (size !== null) varMap.set(match[1], size);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/* --- 字号:两遍解析(em 需要先知道正文近似值)--- */
|
|
809
|
+
/**
|
|
810
|
+
* `font` 简写取值:`font: 14px/1.6 system-ui` → { size: "14px", lineHeight: "1.6" }。
|
|
811
|
+
* CSS 里 font 简写非常常见(尤其重置样式和各项目的 body),只解 font-size 会漏掉它们。
|
|
812
|
+
* 实测(dsh-visualkit 自己的预览页):`body{font:14px/1.6 …}` 没被读到,
|
|
813
|
+
* 正文尺寸退回众数 12px(来自 .meta/.side/.hint)→ S4 把 640/12=53 字报成超长,假误报。
|
|
814
|
+
*/
|
|
815
|
+
/** 字重归一:bold/bolder→700、normal→400、数字直接取。提取不到返回 undefined。 */
|
|
816
|
+
function parseWeight(raw) {
|
|
817
|
+
const text = String(raw ?? "").trim().toLowerCase();
|
|
818
|
+
if (text === "") return undefined;
|
|
819
|
+
if (text === "normal") return 400;
|
|
820
|
+
if (text === "bold" || text === "bolder") return 700;
|
|
821
|
+
if (text === "lighter") return 300;
|
|
822
|
+
const n = Number(text);
|
|
823
|
+
return Number.isFinite(n) && n > 0 && n <= 1000 ? n : undefined;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
function parseFontShorthand(raw) {
|
|
827
|
+
if (typeof raw !== "string" || raw.trim() === "") return null;
|
|
828
|
+
// **字号必须带单位**,后面可跟 /行高。
|
|
829
|
+
// 实测 bug:之前单位是可选的,于是 `font: 400 14px/1.5 Arial` 先匹配到字重
|
|
830
|
+
// `400`(无单位)→ size 为 null → 整个简写被丢弃 → bodySize 退化成“众数 12px”,
|
|
831
|
+
// 进而把 16/12 报成 S1 梯级断裂(假误报)。字重、行高都不带单位,字号一定带。
|
|
832
|
+
const m = raw.match(/(?:^|\s)(\d*\.?\d+)(px|rem|em|pt|%)(?:\s*\/\s*(\d*\.?\d+)(px|rem|em|pt|%)?)?/i);
|
|
833
|
+
if (m === null) return null;
|
|
834
|
+
const size = `${m[1]}${m[2]}`;
|
|
835
|
+
const lineHeight = m[3] === undefined ? null : `${m[3]}${m[4] ?? ""}`;
|
|
836
|
+
return { size, lineHeight };
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
const sizeEntries = [];
|
|
840
|
+
for (const rule of rules) {
|
|
841
|
+
const weight = parseWeight(firstDecl(rule, "font-weight")?.value);
|
|
842
|
+
const decl = firstDecl(rule, "font-size");
|
|
843
|
+
if (decl !== undefined) sizeEntries.push({ rule, raw: decl.value, size: null, weight });
|
|
844
|
+
else {
|
|
845
|
+
// 没有 font-size 时看 font 简写(也顺带补上行高)
|
|
846
|
+
const shorthand = parseFontShorthand(firstDecl(rule, "font")?.value);
|
|
847
|
+
if (shorthand !== null) {
|
|
848
|
+
rule.decls.push({ prop: "font-size", value: shorthand.size, index: firstDecl(rule, "font")?.index ?? 0 });
|
|
849
|
+
if (shorthand.lineHeight !== null && firstDecl(rule, "line-height") === undefined) {
|
|
850
|
+
rule.decls.push({ prop: "line-height", value: shorthand.lineHeight, index: firstDecl(rule, "font")?.index ?? 0 });
|
|
851
|
+
}
|
|
852
|
+
sizeEntries.push({ rule, raw: shorthand.size, size: null, weight });
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
const provisionalSizes = [];
|
|
857
|
+
for (const entry of sizeEntries) {
|
|
858
|
+
if (unitOf(entry.raw) === "em") continue;
|
|
859
|
+
const size = parseSizeValue(entry.raw, 16, varMap);
|
|
860
|
+
if (size !== null) provisionalSizes.push(size);
|
|
861
|
+
}
|
|
862
|
+
const provisionalBody = modeOf(provisionalSizes, { prefer: 16 });
|
|
863
|
+
for (const entry of sizeEntries) entry.size = parseSizeValue(entry.raw, provisionalBody || 16, varMap);
|
|
864
|
+
for (const entry of sizeEntries) entry.rule.size = entry.size;
|
|
865
|
+
const sizeRecords = sizeEntries.filter((entry) => entry.size !== null);
|
|
866
|
+
const sizes = sizeRecords.map((entry) => entry.size);
|
|
867
|
+
const fontSizes = uniqueSorted(sizes);
|
|
868
|
+
// 正文 = 出现次数最多的字号;完全平票(每个字号出现次数相同)时才用 16px 作参考。
|
|
869
|
+
// 曾试图改成「中位数/下三分位」——实测证明是多余的:Flow-Pilot 的 text-[12px]
|
|
870
|
+
// 出现 98 次本身就是众数,无需平票规则;而中位数会把 [16,26] 这种干净两级
|
|
871
|
+
// 梯级的正文错选成 26。保持「众数优先,平票靠 16」最简单也最稳。
|
|
872
|
+
// 正文尺寸:**优先取 body / html / :root 上声明的 font-size**,其次才是众数。
|
|
873
|
+
// 实测误报(dsh-visualkit 自己的预览页):body 声明 14px,但 .meta/.side/.hint
|
|
874
|
+
// 共 3 条 12px,众数就把 12 当成了正文 → S4 算出 640/12 = 53 字/行(假误报),
|
|
875
|
+
// 而真实是 640/14 = 46 字(达标)。
|
|
876
|
+
const PAGE_SIZE_RE = /(?:^|[\s,>+~])(?:body|html|:root)(?:$|[\s,.:#[>+~])/i;
|
|
877
|
+
const pageSizeRecord = sizeRecords.find((entry) => PAGE_SIZE_RE.test(entry.rule.selector));
|
|
878
|
+
const bodySize = pageSizeRecord !== undefined ? pageSizeRecord.size : modeOf(sizes, { prefer: 16 });
|
|
879
|
+
const maxSize = sizes.length > 0 ? Math.max(...sizes) : 0;
|
|
880
|
+
const scaleRatio = bodySize > 0 ? r2(maxSize / bodySize) : 0;
|
|
881
|
+
// 应用外壳还是文档页?S1/S9/S4 与 heading-first 都要用它分档
|
|
882
|
+
const appShell = detectAppShell(rules);
|
|
883
|
+
const bodyRecord = sizeRecords.find((entry) => entry.size === bodySize) ?? sizeRecords[0];
|
|
884
|
+
|
|
885
|
+
/* --- 行高 --- */
|
|
886
|
+
for (const rule of rules) {
|
|
887
|
+
const decl = firstDecl(rule, "line-height");
|
|
888
|
+
if (decl !== undefined) rule.lineHeight = resolveLineHeight(decl.value, rule.size ?? 0, bodySize);
|
|
889
|
+
}
|
|
890
|
+
// 标题不参与行高判定:显示字的紧凑行高(1.1–1.3)是正确做法,而不是“正文太挤”。
|
|
891
|
+
// 实测误报(dsh-visualkit 自己的预览页):h1 1.25 / h2 1.3 被 S3 判成 error——
|
|
892
|
+
// 那两条规则本就该这么写。正文行高只看非标题规则。
|
|
893
|
+
const HEADING_SELECTOR_RE = /(?:^|[\s,>+~])(?:h[1-6]|\.[\w-]*(?:title|heading|headline|display)[\w-]*)(?:$|[\s,.:#[>+~])/i;
|
|
894
|
+
const ratioRecords = rules
|
|
895
|
+
.filter((rule) => typeof rule.lineHeight === "number" && rule.lineHeight > 0)
|
|
896
|
+
.filter((rule) => !HEADING_SELECTOR_RE.test(rule.selector))
|
|
897
|
+
.map((rule) => ({
|
|
898
|
+
rule,
|
|
899
|
+
ratio: rule.lineHeight,
|
|
900
|
+
isBody:
|
|
901
|
+
(rule.size !== undefined && rule.size !== null && bodySize > 0 && Math.abs(rule.size - bodySize) < 0.01) ||
|
|
902
|
+
/(?:^|[\s,.])(?:body|html|:root)(?:$|[\s,.:#[\]]|\.)/.test(rule.selector)
|
|
903
|
+
}));
|
|
904
|
+
const bodyRatioRecords = ratioRecords.filter((record) => record.isBody);
|
|
905
|
+
const ratioCandidates = bodyRatioRecords.length > 0 ? bodyRatioRecords : ratioRecords;
|
|
906
|
+
const lineHeightRatios = uniqueSorted(ratioRecords.map((record) => record.ratio));
|
|
907
|
+
|
|
908
|
+
/* --- 字体家族 --- */
|
|
909
|
+
const families = new Set();
|
|
910
|
+
for (const rule of rules) {
|
|
911
|
+
const decl = firstDecl(rule, "font-family");
|
|
912
|
+
if (decl === undefined) continue;
|
|
913
|
+
const family = normalizeFamily(decl.value);
|
|
914
|
+
if (family !== null) families.add(family);
|
|
915
|
+
}
|
|
916
|
+
const familyCount = families.size;
|
|
917
|
+
|
|
918
|
+
/* --- S1 字号梯级 --- */
|
|
919
|
+
if (sizeRecords.length === 0) {
|
|
920
|
+
report.push(
|
|
921
|
+
finding({
|
|
922
|
+
severity: "info",
|
|
923
|
+
rule: "S0",
|
|
924
|
+
message: `${files.length} 个文件里没有解析到 font-size(px/rem/em),无法评估字号系统`,
|
|
925
|
+
fix: "确认扫描的是样式源码(CSS / <style> / styled-components);字体只写在设计稿里时请以代码声明为准",
|
|
926
|
+
where: files[0]?.rel
|
|
927
|
+
})
|
|
928
|
+
);
|
|
929
|
+
} else {
|
|
930
|
+
// 应用外壳(工具栏 + 栏位)没有 hero 标题,11–16px 紧梯级是常态;
|
|
931
|
+
// 文档/落地页才需要 1.6× 以上的标题梯度。按外壳与否分档,避免把正确设计判成错。
|
|
932
|
+
if (scaleRatio > 0 && scaleRatio < 1.6) {
|
|
933
|
+
report.push(
|
|
934
|
+
finding({
|
|
935
|
+
severity: appShell ? "info" : "error",
|
|
936
|
+
rule: "S1",
|
|
937
|
+
message: appShell
|
|
938
|
+
? `字号梯级偏平:最大 ${maxSize}px / 正文 ${bodySize}px = ${scaleRatio}× —— 应用外壳通常如此(无 hero 标题),仅作参考`
|
|
939
|
+
: `字号梯级断裂:最大 ${maxSize}px / 正文 ${bodySize}px = ${scaleRatio}×(<1.6)`,
|
|
940
|
+
fix: appShell
|
|
941
|
+
? "应用界面靠字重/颜色区分层级是正常的;若确实缺视觉层次,把主标题提到 1.5× 以上即可"
|
|
942
|
+
: "建立 1.6 倍左右的字号梯级(如 16 / 25 / 40 / 64),正文与标题至少差 1.6 倍;避免 12px 正文 + 14px 标题的平铺方案",
|
|
943
|
+
where: bodyRecord === undefined ? undefined : at(bodyRecord.rule.file, bodyRecord.rule.line)
|
|
944
|
+
})
|
|
945
|
+
);
|
|
946
|
+
}
|
|
947
|
+
if (fontSizes.length > 6) {
|
|
948
|
+
report.push(
|
|
949
|
+
finding({
|
|
950
|
+
severity: "warn",
|
|
951
|
+
rule: "S1",
|
|
952
|
+
message: `字号档位过多:${fontSizes.length} 档(${fontSizes.join(" / ")}px)`,
|
|
953
|
+
fix: "收敛到 4–6 档:正文 + 2~3 级标题 + 辅助文字;删掉 1px 级别的微调档位",
|
|
954
|
+
where: sizeRecords[0] === undefined ? undefined : sizeRecords[0].rule.file.rel
|
|
955
|
+
})
|
|
956
|
+
);
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
/* --- S2 最小字号(**字号 × 字重**)---
|
|
961
|
+
* 只看字号是错的。实测(2026-11-07,本机 resvg):同样是中文正文,
|
|
962
|
+
* 12px/400 → 只有 5.5% 的笔画像素是实心的(94.5% 是半透明灰 = 糊)
|
|
963
|
+
* 12px/600 → 29.3%
|
|
964
|
+
* 字重 600 的 12px 比字重 400 的 20px 还清。旧规则把 12px 一律放行、只卡 11px,
|
|
965
|
+
* 于是“12px/400 糊成一片”从未被发现。
|
|
966
|
+
* 现改为「有效字号 = 字号 × 字重系数」:400→1.0、500→1.1、600+→1.2。
|
|
967
|
+
* 阈值:有效 <11 error(基本不可读)、<13 warn(辅文偏糊)。
|
|
968
|
+
*/
|
|
969
|
+
const WEIGHT_FACTOR = (weight) => (weight >= 600 ? 1.2 : weight >= 500 ? 1.1 : 1);
|
|
970
|
+
if (sizeRecords.length > 0) {
|
|
971
|
+
const effOf = (entry) => entry.size * WEIGHT_FACTOR(entry.weight ?? 400);
|
|
972
|
+
const minRecord = sizeRecords.reduce((best, entry) => (effOf(entry) < effOf(best) ? entry : best), sizeRecords[0]);
|
|
973
|
+
const eff = Math.round(effOf(minRecord) * 10) / 10;
|
|
974
|
+
const effNote = `${minRecord.size}px/${minRecord.weight ?? 400}(有效 ${eff}px)`;
|
|
975
|
+
if (eff < 11) {
|
|
976
|
+
report.push(
|
|
977
|
+
finding({
|
|
978
|
+
severity: "error",
|
|
979
|
+
rule: "S2",
|
|
980
|
+
message: `最小字号过小:${effNote},中文在 1x 屏上基本不可读`,
|
|
981
|
+
fix: "正文用 14px/600 或 16px/400;要用 12px 就必须把字重提到 600(实测实心笔画 5.5% → 29.3%)",
|
|
982
|
+
where: at(minRecord.rule.file, minRecord.rule.line)
|
|
983
|
+
})
|
|
984
|
+
);
|
|
985
|
+
} else if (eff < 13) {
|
|
986
|
+
// 11–13 有效 px:能读但偏糊。真正该拦的是「小字 + 常规字重」这个组合。
|
|
987
|
+
report.push(
|
|
988
|
+
finding({
|
|
989
|
+
severity: "warn",
|
|
990
|
+
rule: "S2",
|
|
991
|
+
message: `最小字号偏糊:${effNote}——小字配常规字重时中文笔画会大量半透明`,
|
|
992
|
+
fix: `把这个 ${minRecord.size}px 的字重提到 600(有效 ${Math.round(minRecord.size * 1.2 * 10) / 10}px,实测清晰度×5),或字号提到 14px`,
|
|
993
|
+
where: at(minRecord.rule.file, minRecord.rule.line)
|
|
994
|
+
})
|
|
995
|
+
);
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
/* --- S3 行高 --- */
|
|
1000
|
+
if (ratioCandidates.length > 0) {
|
|
1001
|
+
const reported = new Set();
|
|
1002
|
+
for (const record of ratioCandidates) {
|
|
1003
|
+
if (reported.has(record.ratio)) continue;
|
|
1004
|
+
let severity = null;
|
|
1005
|
+
if (record.ratio < 1.35) severity = "error";
|
|
1006
|
+
else if (record.ratio < 1.5) severity = "warn";
|
|
1007
|
+
else if (record.ratio > 2.2) severity = "warn";
|
|
1008
|
+
if (severity === null) continue;
|
|
1009
|
+
reported.add(record.ratio);
|
|
1010
|
+
report.push(
|
|
1011
|
+
finding({
|
|
1012
|
+
severity,
|
|
1013
|
+
rule: "S3",
|
|
1014
|
+
message:
|
|
1015
|
+
record.ratio < 1.5
|
|
1016
|
+
? `正文行高过紧:line-height / font-size = ${record.ratio}(建议 1.5–1.8,中文尤其需要)`
|
|
1017
|
+
: `正文行高过松:line-height / font-size = ${record.ratio}(>2.2 会散架)`,
|
|
1018
|
+
fix: "正文行高设 1.6(中文 1.6–1.8);标题 1.2–1.35 即可,不要全站一个行高",
|
|
1019
|
+
where: at(record.rule.file, record.rule.line)
|
|
1020
|
+
})
|
|
1021
|
+
);
|
|
1022
|
+
}
|
|
1023
|
+
} else {
|
|
1024
|
+
// 文件里声明了字号却完全没有 line-height:浏览器默认行高(约 1.2)对中文偏紧,提示而非扣分
|
|
1025
|
+
const filesWithSize = new Set(sizeRecords.map((entry) => entry.rule.file.path));
|
|
1026
|
+
const filesWithLineHeight = new Set(ratioRecords.map((record) => record.rule.file.path));
|
|
1027
|
+
for (const file of files) {
|
|
1028
|
+
if (!filesWithSize.has(file.path) || filesWithLineHeight.has(file.path)) continue;
|
|
1029
|
+
report.push(
|
|
1030
|
+
finding({
|
|
1031
|
+
severity: "info",
|
|
1032
|
+
rule: "S3",
|
|
1033
|
+
message: "未声明行高,依赖浏览器默认(中文通常需要 1.5–1.8)",
|
|
1034
|
+
fix: "给正文规则显式写 line-height: 1.6;标题可用 1.2–1.35",
|
|
1035
|
+
where: file.rel
|
|
1036
|
+
})
|
|
1037
|
+
);
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
/* --- S4 行长 --- */
|
|
1042
|
+
const measureRecords = [];
|
|
1043
|
+
for (const rule of rules) {
|
|
1044
|
+
const maxWidthDecl = firstDecl(rule, "max-width");
|
|
1045
|
+
const widthDecl = firstDecl(rule, "width");
|
|
1046
|
+
const decl = maxWidthDecl ?? widthDecl;
|
|
1047
|
+
if (decl === undefined) continue;
|
|
1048
|
+
const width = parseWidth(decl.value, bodySize, varMap);
|
|
1049
|
+
if (width === null || width < MIN_MEASURE_WIDTH) continue;
|
|
1050
|
+
// 只判「正文栏」:页面容器(.sheet/.page/.container 的 max-width:1120px)不是栏宽,
|
|
1051
|
+
// 拿 bodySize 硬除会把 1120px 算成 100 字/行。所以要么规则自带字号(作者自己声明了
|
|
1052
|
+
// 这层是文字),要么选择器看起来就是正文段落——否则跳过。
|
|
1053
|
+
const ownSize = rule.size !== undefined && rule.size !== null && rule.size > 0;
|
|
1054
|
+
// 根选择器上的 **width** 是视口宽度,不是正文栏宽——实测拿 body{width:1366px}
|
|
1055
|
+
// 除以 13px 报出「105 字/行」的假结果。根元素只认 max-width(那是作者明确在限宽)。
|
|
1056
|
+
// 逗号分组选择器要拆开判:`html,body{width:1366px}` 整串匹配不上根选择器
|
|
1057
|
+
// (S13 踩过同一个坑)。全部片段都是根选择器才算根。
|
|
1058
|
+
const selParts = rule.selector.split(",").map((x) => x.trim()).filter((x) => x !== "");
|
|
1059
|
+
const isRootSel = selParts.length > 0 && selParts.every((x) => /^(html|body|:root|#root|#app)$/i.test(x));
|
|
1060
|
+
if (isRootSel && maxWidthDecl === undefined) continue;
|
|
1061
|
+
if (!ownSize && !MEASURE_SELECTOR_RE.test(rule.selector)) continue;
|
|
1062
|
+
const fontSize = ownSize ? rule.size : bodySize;
|
|
1063
|
+
if (!(fontSize > 0)) continue;
|
|
1064
|
+
const chars = hasCJK ? r2(width / fontSize) : r2(width / (0.55 * fontSize));
|
|
1065
|
+
measureRecords.push({ rule, width, isMax: maxWidthDecl !== undefined, fontSize, chars });
|
|
1066
|
+
}
|
|
1067
|
+
if (measureRecords.length > 0) {
|
|
1068
|
+
// 中文可读行长 30–45 字;卡在 >48 才报(46em@14px 这类编辑型正文栏不算错)。
|
|
1069
|
+
const tooLong = measureRecords.filter((record) => (hasCJK ? record.chars > 48 : record.chars > 90));
|
|
1070
|
+
if (tooLong.length > 0) {
|
|
1071
|
+
const worst = tooLong.reduce((best, record) => (record.chars > best.chars ? record : best), tooLong[0]);
|
|
1072
|
+
report.push(
|
|
1073
|
+
finding({
|
|
1074
|
+
severity: "warn",
|
|
1075
|
+
rule: "S4",
|
|
1076
|
+
message: hasCJK
|
|
1077
|
+
? `行长失控:${worst.width}px / ${worst.fontSize}px ≈ ${worst.chars} 字/行(中文建议 ≤48)`
|
|
1078
|
+
: `行长失控:${worst.width}px / ${worst.fontSize}px ≈ ${worst.chars} 字符/行(英文建议 ≤90)`,
|
|
1079
|
+
fix: hasCJK
|
|
1080
|
+
? "正文栏宽收到 30–40 字(约 480–640px @16px):max-width: 40em 或 640px"
|
|
1081
|
+
: "正文栏宽收到 60–75 字符:max-width: 65ch 左右(约 640–760px @16px)",
|
|
1082
|
+
where: at(worst.rule.file, worst.rule.line)
|
|
1083
|
+
})
|
|
1084
|
+
);
|
|
1085
|
+
}
|
|
1086
|
+
// 过短:只对 max-width 的正文栏判(width 多半是按钮/卡片,不算正文),且只提示
|
|
1087
|
+
const tooShort = measureRecords.filter((record) => record.isMax && record.chars < 12);
|
|
1088
|
+
if (hasCJK && tooShort.length > 0) {
|
|
1089
|
+
const worst = tooShort.reduce((best, record) => (record.chars < best.chars ? record : best), tooShort[0]);
|
|
1090
|
+
report.push(
|
|
1091
|
+
finding({
|
|
1092
|
+
severity: "info",
|
|
1093
|
+
rule: "S4",
|
|
1094
|
+
message: `行长过短:max-width ${worst.width}px / ${worst.fontSize}px ≈ ${worst.chars} 字/行(<12),中文频繁换行`,
|
|
1095
|
+
fix: "正文栏宽 30–40 字更易读;短栏改为卡片/侧栏,不要让正文挤在窄栏里",
|
|
1096
|
+
where: at(worst.rule.file, worst.rule.line)
|
|
1097
|
+
})
|
|
1098
|
+
);
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
/* --- S5 字体家族过多 --- */
|
|
1103
|
+
if (familyCount > 3) {
|
|
1104
|
+
report.push(
|
|
1105
|
+
finding({
|
|
1106
|
+
severity: "warn",
|
|
1107
|
+
rule: "S5",
|
|
1108
|
+
message: `字体家族过多:去重后 ${familyCount} 个(系统栈算 1 个)`,
|
|
1109
|
+
fix: "收敛到 2 个:一个正文字体 + 一个等宽/展示字体;西文回退栈合并成同一套",
|
|
1110
|
+
where: files[0]?.rel
|
|
1111
|
+
})
|
|
1112
|
+
);
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
/* --- S6 对齐 --- */
|
|
1116
|
+
const alignRules = [];
|
|
1117
|
+
for (const rule of rules) {
|
|
1118
|
+
const decl = firstDecl(rule, "text-align");
|
|
1119
|
+
if (decl === undefined) continue;
|
|
1120
|
+
alignRules.push({ rule, value: decl.value.replace(/!important/gi, "").trim().toLowerCase() });
|
|
1121
|
+
}
|
|
1122
|
+
const justifyRules = alignRules.filter((entry) => entry.value === "justify");
|
|
1123
|
+
if (hasCJK) {
|
|
1124
|
+
for (const entry of justifyRules.slice(0, PER_RULE_CAP)) {
|
|
1125
|
+
report.push(
|
|
1126
|
+
finding({
|
|
1127
|
+
severity: "warn",
|
|
1128
|
+
rule: "S6",
|
|
1129
|
+
message: "中文段落用了 text-align: justify:中文没有词间空格,两端对齐会在标点/英文处撑出大空隙",
|
|
1130
|
+
fix: "中文正文用 text-align: left(或 start);确需两端对齐时开启 text-justify: inter-ideograph 并只用于纯中文长文",
|
|
1131
|
+
where: at(entry.rule.file, entry.rule.line)
|
|
1132
|
+
})
|
|
1133
|
+
);
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
const centerRules = alignRules.filter((entry) => entry.value === "center");
|
|
1137
|
+
for (const entry of centerRules) {
|
|
1138
|
+
const targets = centerTargets(entry.rule, files);
|
|
1139
|
+
if (targets.length === 0) continue;
|
|
1140
|
+
report.push(
|
|
1141
|
+
finding({
|
|
1142
|
+
severity: "warn",
|
|
1143
|
+
rule: "S6",
|
|
1144
|
+
message: `长段落居中:${targets[0].rel} 有超过 40 个中文字符的文本被 text-align:center(每行起点不一致,难读)`,
|
|
1145
|
+
fix: "长段落左对齐;居中只用于标题、引语、单行文案(≤40 字)",
|
|
1146
|
+
where: at(entry.rule.file, entry.rule.line)
|
|
1147
|
+
})
|
|
1148
|
+
);
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
/* --- S7 字距 --- */
|
|
1152
|
+
for (const rule of rules) {
|
|
1153
|
+
const decl = firstDecl(rule, "letter-spacing");
|
|
1154
|
+
if (decl === undefined) continue;
|
|
1155
|
+
const fontSize = rule.size ?? (bodySize > 0 ? bodySize : 16);
|
|
1156
|
+
// 只查中文正文语境:<12px 的微标签、uppercase、等宽字体上的 tracking 是编辑型设计
|
|
1157
|
+
// 的常规手段(.rail .brand{font-size:12px;letter-spacing:.16em;text-transform:uppercase}),
|
|
1158
|
+
// 不该按「字距破坏中文」算。
|
|
1159
|
+
if (!(fontSize >= 12)) continue;
|
|
1160
|
+
const transform = firstDecl(rule, "text-transform")?.value ?? "";
|
|
1161
|
+
if (/uppercase/i.test(transform)) continue;
|
|
1162
|
+
const familyDecl = firstDecl(rule, "font-family");
|
|
1163
|
+
const family = familyDecl === undefined ? "" : normalizeFamily(familyDecl.value) ?? "";
|
|
1164
|
+
if (/mono|consolas|courier/i.test(family)) continue;
|
|
1165
|
+
const hit = letterSpacingHit(decl.value, fontSize);
|
|
1166
|
+
if (hit === null) continue;
|
|
1167
|
+
if (!CJK_RE.test(rule.file.stripped)) continue;
|
|
1168
|
+
report.push(
|
|
1169
|
+
finding({
|
|
1170
|
+
severity: "warn",
|
|
1171
|
+
rule: "S7",
|
|
1172
|
+
message: `字距破坏中文:letter-spacing 约 ${hit.px}px(>1px / >0.05em)用在含中文的文件里`,
|
|
1173
|
+
fix: "中文正文不加大字距;确需呼吸感用 0.01–0.02em 或调行高,而不是拉开字间",
|
|
1174
|
+
where: at(rule.file, rule.line)
|
|
1175
|
+
})
|
|
1176
|
+
);
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
/* --- S8 中英混排空格 --- */
|
|
1180
|
+
let mixedTotal = 0;
|
|
1181
|
+
let mixedExample = null;
|
|
1182
|
+
let mixedWhere = null;
|
|
1183
|
+
for (const file of files) {
|
|
1184
|
+
if (TEST_FILE_RE.test(file.path ?? "")) continue; // 测试 fixture 不是界面文案
|
|
1185
|
+
const hits = mixedSpacingHits(file);
|
|
1186
|
+
if (hits.length === 0) continue;
|
|
1187
|
+
if (mixedExample === null) {
|
|
1188
|
+
mixedExample = hits[0].segment.slice(0, 48);
|
|
1189
|
+
mixedWhere = file.rel;
|
|
1190
|
+
}
|
|
1191
|
+
mixedTotal += hits.length;
|
|
1192
|
+
}
|
|
1193
|
+
if (mixedTotal >= 3) {
|
|
1194
|
+
report.push(
|
|
1195
|
+
finding({
|
|
1196
|
+
severity: "info",
|
|
1197
|
+
rule: "S8",
|
|
1198
|
+
message: `中英混排没空格:${mixedTotal} 处(如「${mixedExample}」)`,
|
|
1199
|
+
fix: "汉字与拉丁字母之间留一个空格(「使用 React 框架」);纯数字紧贴中文(2026年)不在此列",
|
|
1200
|
+
where: mixedWhere
|
|
1201
|
+
})
|
|
1202
|
+
);
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
/* --- S9 标题层级 --- */
|
|
1206
|
+
const headingRecords = [];
|
|
1207
|
+
for (const rule of rules) {
|
|
1208
|
+
if (rule.size === undefined || rule.size === null) continue;
|
|
1209
|
+
const levels = new Set();
|
|
1210
|
+
for (const match of rule.selector.matchAll(/(?:^|[\s,>+~])(h[1-6])(?=$|[\s,>+~.:#[\]]|\.)/g)) levels.add(Number(match[1].slice(1)));
|
|
1211
|
+
for (const level of levels) headingRecords.push({ level, size: rule.size, rule });
|
|
1212
|
+
}
|
|
1213
|
+
for (const file of files) {
|
|
1214
|
+
const re = /<h([1-6])\b[^>]*?style\s*=\s*["']([^"']*)["']/g;
|
|
1215
|
+
let match;
|
|
1216
|
+
while ((match = re.exec(file.stripped)) !== null) {
|
|
1217
|
+
const decl = parseDecls(match[2]).find((entry) => entry.prop === "font-size");
|
|
1218
|
+
if (decl === undefined) continue;
|
|
1219
|
+
const size = parseSizeValue(decl.value, bodySize || 16, varMap);
|
|
1220
|
+
if (size !== null) headingRecords.push({ level: Number(match[1]), size, rule: { file, line: lineAt(lineStartsOf(file), match.index), selector: `h${match[1]}` } });
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
if (headingRecords.length > 0 && bodySize > 0) {
|
|
1224
|
+
const headingSizes = new Map();
|
|
1225
|
+
const headingWheres = new Map();
|
|
1226
|
+
for (let level = 1; level <= 6; level += 1) {
|
|
1227
|
+
const records = headingRecords.filter((record) => record.level === level);
|
|
1228
|
+
if (records.length === 0) continue;
|
|
1229
|
+
const size = modeOf(records.map((record) => record.size), { tie: "larger" });
|
|
1230
|
+
headingSizes.set(level, size);
|
|
1231
|
+
headingWheres.set(level, records.find((record) => record.size === size) ?? records[0]);
|
|
1232
|
+
}
|
|
1233
|
+
for (const [level, size] of headingSizes) {
|
|
1234
|
+
const ratio = r2(size / bodySize);
|
|
1235
|
+
if (ratio >= 1.25) continue;
|
|
1236
|
+
const record = headingWheres.get(level);
|
|
1237
|
+
report.push(
|
|
1238
|
+
finding({
|
|
1239
|
+
// 应用外壳里 h3/h4 常被当作「组件小标题」用,字号贴近正文是正常的;
|
|
1240
|
+
// 文档页才需要 1.25× 以上的层级差。
|
|
1241
|
+
severity: appShell ? "info" : "error",
|
|
1242
|
+
rule: "S9",
|
|
1243
|
+
message: appShell
|
|
1244
|
+
? `组件小标题字号贴近正文:h${level} ${size}px / 正文 ${bodySize}px = ${ratio}× —— 应用外壳里 h3/h4 常作组件标题用,仅作参考`
|
|
1245
|
+
: `标题层级不足:h${level} ${size}px / 正文 ${bodySize}px = ${ratio}×(<1.25,标题和正文分不开)`,
|
|
1246
|
+
fix: appShell
|
|
1247
|
+
? "组件标题靠字重(600+)与颜色区分即可;若确实需要更强的层次,把它提到 1.4× 以上或加分隔线"
|
|
1248
|
+
: "h1 ≥ 2× 正文、h2 ≥ 1.5×、h3 ≥ 1.25×;正文 16px 时 h1 至少 32–40px",
|
|
1249
|
+
where: record === undefined ? undefined : at(record.rule.file, record.rule.line)
|
|
1250
|
+
})
|
|
1251
|
+
);
|
|
1252
|
+
}
|
|
1253
|
+
const known = [...headingSizes.entries()].sort((a, b) => a[0] - b[0]);
|
|
1254
|
+
for (let i = 0; i + 1 < known.length; i += 1) {
|
|
1255
|
+
const [shallowLevel, shallowSize] = known[i];
|
|
1256
|
+
const [deepLevel, deepSize] = known[i + 1];
|
|
1257
|
+
if (shallowSize >= deepSize) continue;
|
|
1258
|
+
report.push(
|
|
1259
|
+
finding({
|
|
1260
|
+
severity: "error",
|
|
1261
|
+
rule: "S9",
|
|
1262
|
+
message: `标题层级倒挂:h${shallowLevel} ${shallowSize}px 比 h${deepLevel} ${deepSize}px 还小`,
|
|
1263
|
+
fix: "字号随层级递减:h1 > h2 > h3 > h4…;层级靠字重/颜色区分而不是把 h1 做小",
|
|
1264
|
+
where: (() => {
|
|
1265
|
+
const record = headingWheres.get(shallowLevel);
|
|
1266
|
+
return record === undefined ? undefined : at(record.rule.file, record.rule.line);
|
|
1267
|
+
})()
|
|
1268
|
+
})
|
|
1269
|
+
);
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
/* --- S10 混排字号基线 ------------------------------------------------------------
|
|
1274
|
+
* 场景:仪表板里的「5.42 GiB」——数字 72px、单位 20px。这类排版最常见的错不是字号,
|
|
1275
|
+
* 而是**基线丢了**:容器写成 `display:flex; align-items:center`,浏览器会把小字
|
|
1276
|
+
* 垂直居中到大字旁边,而不是让两者坐在同一条基线上。肉眼看就是“单位飘在数字中间”。
|
|
1277
|
+
*
|
|
1278
|
+
* 为什么能静态判:CSS 里 `align-items: center` 与 `vertical-align: middle` 是明确的
|
|
1279
|
+
* “居中而非基线”信号,且我们能从选择器的包含关系认出父子(`.kpi` → `.kpi .unit`)。
|
|
1280
|
+
*
|
|
1281
|
+
* 阈值 1.8×:低于这个倍数的混排(22px 字标 + 16px 标签 = 1.375×)居中摆放是正常的,
|
|
1282
|
+
* 报它就是噪音。实测拿真实页面校准过。
|
|
1283
|
+
*/
|
|
1284
|
+
if (sizeRecords.length > 0) {
|
|
1285
|
+
const sizeOf = new Map();
|
|
1286
|
+
for (const record of sizeRecords) sizeOf.set(record.rule.selector.trim(), record);
|
|
1287
|
+
const isDescendant = (child, parent) =>
|
|
1288
|
+
child !== parent && new RegExp(`^${parent.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*[>+~]?\\s`).test(child);
|
|
1289
|
+
const reported = new Set();
|
|
1290
|
+
for (const rule of rules) {
|
|
1291
|
+
const display = firstDecl(rule, "display")?.value.trim().toLowerCase();
|
|
1292
|
+
const align = firstDecl(rule, "align-items")?.value.trim().toLowerCase();
|
|
1293
|
+
const isFlex = display === "flex" || display === "inline-flex";
|
|
1294
|
+
if (!isFlex || align !== "center") continue;
|
|
1295
|
+
const parentSel = rule.selector.trim();
|
|
1296
|
+
if (reported.has(parentSel)) continue;
|
|
1297
|
+
const members = [];
|
|
1298
|
+
const own = sizeOf.get(parentSel);
|
|
1299
|
+
if (own !== undefined) members.push({ size: own.size, record: own, sel: parentSel });
|
|
1300
|
+
for (const [sel, record] of sizeOf.entries()) {
|
|
1301
|
+
if (isDescendant(sel, parentSel)) members.push({ size: record.size, record, sel });
|
|
1302
|
+
}
|
|
1303
|
+
if (members.length < 2) continue;
|
|
1304
|
+
const sizes = members.map((m) => m.size);
|
|
1305
|
+
const maxSize = Math.max(...sizes);
|
|
1306
|
+
const minSize = Math.min(...sizes);
|
|
1307
|
+
if (minSize <= 0 || maxSize / minSize < 1.8) continue;
|
|
1308
|
+
reported.add(parentSel);
|
|
1309
|
+
const small = members.find((m) => m.size === minSize);
|
|
1310
|
+
// 小字要是自己声明了 vertical-align: baseline,那它已经处理过了,不报
|
|
1311
|
+
const sv = firstDecl(small.record.rule, "vertical-align")?.value.trim().toLowerCase();
|
|
1312
|
+
if (sv === "baseline") continue;
|
|
1313
|
+
report.push(
|
|
1314
|
+
finding({
|
|
1315
|
+
severity: "warn",
|
|
1316
|
+
rule: "S10",
|
|
1317
|
+
message:
|
|
1318
|
+
`混排字号基线丢失:${parentSel} 用 align-items:center 装 ${maxSize}px 与 ${minSize}px(${r2(maxSize / minSize)}×),` +
|
|
1319
|
+
`浏览器会垂直居中而不是基线对齐,小字会“飘”在大字中间`,
|
|
1320
|
+
fix: `把该容器改成 align-items: baseline(或给小字加 vertical-align: baseline)——数字与单位的底边必须坐在同一条线上;行内混排则用 vertical-align: baseline 而不是 middle`,
|
|
1321
|
+
where: at(rule.file, rule.line)
|
|
1322
|
+
})
|
|
1323
|
+
);
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
/* --- S11 阴影层级 ----------------------------------------------------------------
|
|
1328
|
+
* 为什么值得单独一条规则:阴影是「专业感 / AI 感」的分水岭,但它有两个**完全客观**
|
|
1329
|
+
* 的可判缺陷,肉眼能感觉到、却说不出哪里不对:
|
|
1330
|
+
* ① 光源方向不一致:有的投影往下(光从上方)、有的往上(光从下方)——物理上不成立;
|
|
1331
|
+
* ② 档位过多 / 相邻档位太近:5 档阴影里有两档肉眼分不出来,等于白写,还增加维护成本。
|
|
1332
|
+
* 这两条都不靠审美,靠几何与数值比对,所以适合做规则。
|
|
1333
|
+
*/
|
|
1334
|
+
{
|
|
1335
|
+
const shadows = [];
|
|
1336
|
+
for (const rule of rules) {
|
|
1337
|
+
const decl = firstDecl(rule, "box-shadow");
|
|
1338
|
+
if (decl === undefined) continue;
|
|
1339
|
+
const raw = String(decl.value).trim();
|
|
1340
|
+
if (raw === "" || /^(none|inherit|initial|unset)$/i.test(raw)) continue;
|
|
1341
|
+
// 顶层逗号切分(rgba(...) 里的逗号不能当分隔符)
|
|
1342
|
+
const parts = [];
|
|
1343
|
+
let depth = 0;
|
|
1344
|
+
let cur = "";
|
|
1345
|
+
for (const ch of raw) {
|
|
1346
|
+
if (ch === "(") depth += 1;
|
|
1347
|
+
else if (ch === ")") depth = Math.max(0, depth - 1);
|
|
1348
|
+
if (ch === "," && depth === 0) {
|
|
1349
|
+
parts.push(cur);
|
|
1350
|
+
cur = "";
|
|
1351
|
+
continue;
|
|
1352
|
+
}
|
|
1353
|
+
cur += ch;
|
|
1354
|
+
}
|
|
1355
|
+
if (cur.trim() !== "") parts.push(cur);
|
|
1356
|
+
const layers = [];
|
|
1357
|
+
for (const part of parts) {
|
|
1358
|
+
const inset = /\binset\b/i.test(part);
|
|
1359
|
+
const nums = (part.match(/-?\d*\.?\d+(?:px|rem|em)?/g) ?? [])
|
|
1360
|
+
.map((v) => (/rem$/.test(v) ? parseFloat(v) * 16 : /em$/.test(v) ? parseFloat(v) * (rule.size ?? 16) : parseFloat(v)))
|
|
1361
|
+
.filter((v) => Number.isFinite(v));
|
|
1362
|
+
if (nums.length < 2) continue;
|
|
1363
|
+
const alphaMatch = /rgba?\([^)]*?,\s*([\d.]+)\s*\)/.exec(part);
|
|
1364
|
+
layers.push({
|
|
1365
|
+
inset,
|
|
1366
|
+
dx: nums[0],
|
|
1367
|
+
dy: nums[1],
|
|
1368
|
+
blur: nums[2] ?? 0,
|
|
1369
|
+
alpha: alphaMatch === null ? 1 : Number(alphaMatch[1])
|
|
1370
|
+
});
|
|
1371
|
+
}
|
|
1372
|
+
if (layers.length === 0) continue;
|
|
1373
|
+
// 以「最大模糊半径」作为该声明的层级(感知上的高度)
|
|
1374
|
+
const outer = layers.filter((l) => !l.inset);
|
|
1375
|
+
if (outer.length === 0) continue;
|
|
1376
|
+
const dominant = outer.reduce((best, l) => (l.blur > best.blur ? l : best), outer[0]);
|
|
1377
|
+
shadows.push({ rule, level: Math.round(dominant.blur), dy: dominant.dy, layers: layers.length, alpha: dominant.alpha });
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
if (shadows.length >= 2) {
|
|
1381
|
+
// ① 光源方向:dy 的正负应一致(dy === 0 的居中光晕不参与判断)
|
|
1382
|
+
const dirs = new Set(shadows.filter((sh) => Math.abs(sh.dy) >= 1).map((sh) => Math.sign(sh.dy)));
|
|
1383
|
+
if (dirs.size > 1) {
|
|
1384
|
+
const down = shadows.filter((sh) => sh.dy >= 1)[0];
|
|
1385
|
+
const up = shadows.filter((sh) => sh.dy <= -1)[0];
|
|
1386
|
+
report.push(
|
|
1387
|
+
finding({
|
|
1388
|
+
severity: "error",
|
|
1389
|
+
rule: "S11",
|
|
1390
|
+
message:
|
|
1391
|
+
`光源方向不一致:有 ${down === undefined ? "?" : `${down.dy}px`} 向下投影,也有 ${up === undefined ? "?" : `${up.dy}px`} 向上投影——` +
|
|
1392
|
+
"同一页面只能有一个光源方向,否则高度关系是矛盾的",
|
|
1393
|
+
fix: "统一成一个光源方向(常规是光在上方 → 阴影全部 dy > 0);要做「内凹/被压入」的观感就用 inset,不要靠反向的 dy",
|
|
1394
|
+
where: at(up.rule.file, up.rule.line)
|
|
1395
|
+
})
|
|
1396
|
+
);
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
// ② 档位数量与档位间距
|
|
1400
|
+
const levels = [...new Set(shadows.map((sh) => sh.level))].sort((a, b) => a - b);
|
|
1401
|
+
if (levels.length > 4) {
|
|
1402
|
+
report.push(
|
|
1403
|
+
finding({
|
|
1404
|
+
severity: "warn",
|
|
1405
|
+
rule: "S11",
|
|
1406
|
+
message: `阴影档位过多:${levels.length} 档(${levels.join("/")}px)——高程体系通常 3–4 档就够`,
|
|
1407
|
+
fix: `收敛成 3–4 档,例如 1/4/12/24px(对应卡片、悬浮层、弹窗、模态);档位越多越难保持一致`,
|
|
1408
|
+
where: at(shadows[0].rule.file, shadows[0].rule.line)
|
|
1409
|
+
})
|
|
1410
|
+
);
|
|
1411
|
+
}
|
|
1412
|
+
const tooClose = [];
|
|
1413
|
+
for (let i = 1; i < levels.length; i += 1) {
|
|
1414
|
+
if (levels[i - 1] > 0 && levels[i] / levels[i - 1] < 1.3) tooClose.push(`${levels[i - 1]}/${levels[i]}`);
|
|
1415
|
+
}
|
|
1416
|
+
if (tooClose.length > 0) {
|
|
1417
|
+
report.push(
|
|
1418
|
+
finding({
|
|
1419
|
+
severity: "warn",
|
|
1420
|
+
rule: "S11",
|
|
1421
|
+
message: `相邻阴影档位太近:${tooClose.join("、")}px(倍数 <1.3)——肉眼分不出差别,等于白写一档`,
|
|
1422
|
+
fix: "档位之间至少差 1.5 倍(如 1 → 4 → 12 → 24),让每档在高程上都有明确语义",
|
|
1423
|
+
where: at(shadows[0].rule.file, shadows[0].rule.line)
|
|
1424
|
+
})
|
|
1425
|
+
);
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
/* --- S12 响应式断点一致性 ----------------------------------------------------------
|
|
1431
|
+
* 断点最容易出的两类问题都是**可静态算出来的**:
|
|
1432
|
+
* ① 同类断点近似但不相等(两个 max-width 差 1–3px)——几乎必然是笔误;
|
|
1433
|
+
* ② max-width: X 与 min-width: X 同时存在——在恰好 X 处两套规则同时生效,
|
|
1434
|
+
* 出现“哪个覆盖哪个”的不确定性(正确写法是 max-width: X-1 配 min-width: X)。
|
|
1435
|
+
*
|
|
1436
|
+
* **刻意不报**的情况:`max-width: 767px` + `min-width: 768px` 是标准的互补对,
|
|
1437
|
+
* 数值相邻但方向相反,完全正确——按“近似值”去报会误伤大量规范项目(Bootstrap 系)。
|
|
1438
|
+
* 所以近似检查只比「同方向」的断点。
|
|
1439
|
+
*/
|
|
1440
|
+
{
|
|
1441
|
+
const MEDIA_RE = /@media[^{]*/gi;
|
|
1442
|
+
const bpRe = /(min|max)-width\s*:\s*([\d.]+)\s*(px|em|rem)?/gi;
|
|
1443
|
+
const breakpoints = [];
|
|
1444
|
+
for (const file of files) {
|
|
1445
|
+
if (file.kind !== "css" && file.kind !== "html") continue;
|
|
1446
|
+
if (TEST_FILE_RE.test(file.path ?? "")) continue;
|
|
1447
|
+
const text = file.stripped;
|
|
1448
|
+
const toLine = (index) => text.slice(0, index).split("\n").length;
|
|
1449
|
+
for (const media of text.matchAll(MEDIA_RE)) {
|
|
1450
|
+
const condition = media[0];
|
|
1451
|
+
for (const bp of condition.matchAll(bpRe)) {
|
|
1452
|
+
const raw = Number(bp[2]);
|
|
1453
|
+
if (!Number.isFinite(raw) || raw <= 0) continue;
|
|
1454
|
+
const unit = (bp[3] ?? "px").toLowerCase();
|
|
1455
|
+
const px = unit === "px" ? raw : raw * 16; // em/rem 按 16px 折算(约定值)
|
|
1456
|
+
breakpoints.push({
|
|
1457
|
+
kind: bp[1].toLowerCase() === "min" ? "min" : "max",
|
|
1458
|
+
px: Math.round(px * 100) / 100,
|
|
1459
|
+
// at() 收的是**文件对象**(它读 file.rel),传路径字符串会输出 @undefined
|
|
1460
|
+
file,
|
|
1461
|
+
line: toLine(media.index ?? 0)
|
|
1462
|
+
});
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
if (breakpoints.length >= 2) {
|
|
1468
|
+
const vals = [...new Set(breakpoints.map((b) => b.px))].sort((a, b) => a - b);
|
|
1469
|
+
const at2 = (bp) => at(bp.file, bp.line);
|
|
1470
|
+
|
|
1471
|
+
// ① 同方向断点近似但不相等 → 几乎必然是笔误
|
|
1472
|
+
const nearMisses = [];
|
|
1473
|
+
for (const kind of ["max", "min"]) {
|
|
1474
|
+
const sameKind = [...new Set(breakpoints.filter((b) => b.kind === kind).map((b) => b.px))].sort((a, b) => a - b);
|
|
1475
|
+
for (let i = 1; i < sameKind.length; i += 1) {
|
|
1476
|
+
const gap = sameKind[i] - sameKind[i - 1];
|
|
1477
|
+
if (gap > 0 && gap <= 3) nearMisses.push(`${kind}-width ${sameKind[i - 1]}/${sameKind[i]}px`);
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
if (nearMisses.length > 0) {
|
|
1481
|
+
report.push(
|
|
1482
|
+
finding({
|
|
1483
|
+
severity: "warn",
|
|
1484
|
+
rule: "S12",
|
|
1485
|
+
message: `断点值近似但不相等:${nearMisses.join("、")}(同方向、差 ≤3px)——几乎必然是笔误,会让某一段宽度落在两套规则之间`,
|
|
1486
|
+
fix: "同一方向的断点值应统一到一份清单(如 640/768/1024/1280);相近的两档合并成同一个值,或拉开到 ≥16px",
|
|
1487
|
+
where: at2(breakpoints[0])
|
|
1488
|
+
})
|
|
1489
|
+
);
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
// ② max-width X 与 min-width X 同时存在 → 在 X 处重叠
|
|
1493
|
+
const overlaps = [];
|
|
1494
|
+
const maxSet = new Set(breakpoints.filter((b) => b.kind === "max").map((b) => b.px));
|
|
1495
|
+
const minSet = new Set(breakpoints.filter((b) => b.kind === "min").map((b) => b.px));
|
|
1496
|
+
for (const v of vals) if (maxSet.has(v) && minSet.has(v)) overlaps.push(`${v}px`);
|
|
1497
|
+
if (overlaps.length > 0) {
|
|
1498
|
+
const sample = breakpoints.find((b) => b.kind === "min" && overlaps.includes(`${b.px}px`)) ?? breakpoints[0];
|
|
1499
|
+
report.push(
|
|
1500
|
+
finding({
|
|
1501
|
+
severity: "warn",
|
|
1502
|
+
rule: "S12",
|
|
1503
|
+
message: `断点重叠:${overlaps.join("、")} 上 max-width 与 min-width 同时命中——恰好这一像素宽度会应用两套规则,叠加结果取决于书写顺序`,
|
|
1504
|
+
fix: "互补的一对断点要错开 1px:`@media (max-width: 767px)` 配 `@media (min-width: 768px)`;或统一改用 mobile-first 的单一 min-width 体系",
|
|
1505
|
+
where: at2(sample)
|
|
1506
|
+
})
|
|
1507
|
+
);
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
// ③ 值过多 / 方向混用 → info(不是错,但值得知道)
|
|
1511
|
+
if (vals.length > 5) {
|
|
1512
|
+
report.push(
|
|
1513
|
+
finding({
|
|
1514
|
+
severity: "info",
|
|
1515
|
+
rule: "S12",
|
|
1516
|
+
message: `断点档位偏多:${vals.length} 档(${vals.join("/")}px)——响应式体系通常 3–5 档就够`,
|
|
1517
|
+
fix: "把断点收敛到 3–5 档并与设计系统对齐;档位越多,每档都要单独验证,维护成本指数上升",
|
|
1518
|
+
where: at2(breakpoints[0])
|
|
1519
|
+
})
|
|
1520
|
+
);
|
|
1521
|
+
}
|
|
1522
|
+
// 只有「两个方向都成体系(各 ≥2 档)」才算混用。
|
|
1523
|
+
// 一 max 一 min 的互补对是标准写法(Bootstrap 系),报它就是纯噪音。
|
|
1524
|
+
if (maxSet.size >= 2 && minSet.size >= 2) {
|
|
1525
|
+
report.push(
|
|
1526
|
+
finding({
|
|
1527
|
+
severity: "info",
|
|
1528
|
+
rule: "S12",
|
|
1529
|
+
message: `断点方向混用:${maxSet.size} 档 max-width + ${minSet.size} 档 min-width——两套方向各成体系,排查覆盖关系时要同时想两个方向`,
|
|
1530
|
+
fix: "统一到一种方向(推荐 mobile-first 的 min-width);已有代码要混用时,至少保证同一组件内方向一致",
|
|
1531
|
+
where: at2(breakpoints[0])
|
|
1532
|
+
})
|
|
1533
|
+
);
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
/* --- S13 组件状态矩阵 --------------------------------------------------------------
|
|
1539
|
+
* 现有 `taste_check` 的 no-states 只回答「页面上有没有交互态」,回答不了
|
|
1540
|
+
* 「四个组件里有两个缺 disabled」这种**逐组件**的问题。
|
|
1541
|
+
*
|
|
1542
|
+
* 做成一�矩阵:行 = 组件(基选择器),列 = hover / focus / active / disabled。
|
|
1543
|
+
* 判据只有一条硬的:**有 hover 却没有 focus 的组件一律报**——键盘用户完全看不到
|
|
1544
|
+
* 焦点落在哪,这是 WCAG 2.4.7(Focus Visible, AA)。其余状态缺失只进矩阵总览,
|
|
1545
|
+
* 因为按钮不一定需要 disabled、卡片不一定需要 active,逐条判会变成噪音。
|
|
1546
|
+
*/
|
|
1547
|
+
{
|
|
1548
|
+
const STATE_DEFS = [
|
|
1549
|
+
["hover", /:hover\b/g],
|
|
1550
|
+
["focus", /:focus(-visible|-within)?\b/g],
|
|
1551
|
+
["active", /:active\b/g],
|
|
1552
|
+
["disabled", /:disabled\b|\[disabled\]|\.is-disabled\b/g]
|
|
1553
|
+
];
|
|
1554
|
+
const matrix = new Map(); // base 选择器 -> { states:Set, file, line }
|
|
1555
|
+
// 逗号分组选择器必须拆开逐条算:`.fw:focus-visible,.rail-item:focus-visible,…{…}`
|
|
1556
|
+
// 是极常见写法,整串当一个选择器会算出个假 base `.fw,.rail-item,…`,
|
|
1557
|
+
// 于是这几个组件被误判成「没有 focus」(实测:本插件自己的参照稿当场被误报 4 个)。
|
|
1558
|
+
const splitSelectors = (selector) => {
|
|
1559
|
+
const out = [];
|
|
1560
|
+
let depth = 0;
|
|
1561
|
+
let cur = "";
|
|
1562
|
+
for (const ch of selector) {
|
|
1563
|
+
if (ch === "(") depth += 1;
|
|
1564
|
+
else if (ch === ")") depth = Math.max(0, depth - 1);
|
|
1565
|
+
if (ch === "," && depth === 0) {
|
|
1566
|
+
out.push(cur);
|
|
1567
|
+
cur = "";
|
|
1568
|
+
continue;
|
|
1569
|
+
}
|
|
1570
|
+
cur += ch;
|
|
1571
|
+
}
|
|
1572
|
+
if (cur.trim() !== "") out.push(cur);
|
|
1573
|
+
return out.map((x) => x.trim()).filter((x) => x !== "");
|
|
1574
|
+
};
|
|
1575
|
+
|
|
1576
|
+
for (const rule of rules) {
|
|
1577
|
+
for (const sel of splitSelectors(rule.selector)) {
|
|
1578
|
+
const hit = STATE_DEFS.filter(([, re]) => {
|
|
1579
|
+
re.lastIndex = 0;
|
|
1580
|
+
return re.test(sel);
|
|
1581
|
+
});
|
|
1582
|
+
if (hit.length === 0) continue;
|
|
1583
|
+
// 【数据属性绑定的组合式钩子不是组件】
|
|
1584
|
+
// [data-motion~="lift"] 这类选择器来自本插件的交互原语层:
|
|
1585
|
+
// hover / focus 本来就是两条独立可选项(挂 "lift focus" 才都有),
|
|
1586
|
+
// 按 base 归组必然得出「有 hover 没 focus」——这个报必然成立、也无从修,
|
|
1587
|
+
// 只会教用户忽略 S13。真组件的 focus 缺失由用户的页面选择器报出。
|
|
1588
|
+
// 只豁免本插件自己的 `[data-motion…]`:其它 data-* 选择器在真实项目里常就是组件
|
|
1589
|
+
// (Radix/shadcn 的 [data-slot] / [data-state]),一律跳过会把真缺口盖掉。
|
|
1590
|
+
if (/\[data-motion\s*[~*^$|]?=/.test(sel)) continue;
|
|
1591
|
+
let base = sel;
|
|
1592
|
+
for (const [, re] of STATE_DEFS) {
|
|
1593
|
+
re.lastIndex = 0;
|
|
1594
|
+
base = base.replace(re, "");
|
|
1595
|
+
}
|
|
1596
|
+
// 函数式伪类也要剥掉:`.tab:focus:not(:focus-visible)` 不处理会得到假组件
|
|
1597
|
+
// `.tab:not()`,把同一个组件拆成两行(实测踩过)。
|
|
1598
|
+
base = base
|
|
1599
|
+
.replace(/:not\([^)]*\)/g, "")
|
|
1600
|
+
.replace(/:is\([^)]*\)/g, "")
|
|
1601
|
+
.replace(/:where\([^)]*\)/g, "")
|
|
1602
|
+
.replace(/\s+/g, " ")
|
|
1603
|
+
.trim();
|
|
1604
|
+
if (base === "") continue;
|
|
1605
|
+
const entry = matrix.get(base) ?? { states: new Set(), file: rule.file, line: rule.line };
|
|
1606
|
+
for (const [name] of hit) entry.states.add(name);
|
|
1607
|
+
if (matrix.has(base) === false) matrix.set(base, entry);
|
|
1608
|
+
else for (const [name] of hit) matrix.get(base).states.add(name);
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
if (matrix.size > 0) {
|
|
1613
|
+
// ① 有 hover 却没有 focus —— 键盘焦点不可见(WCAG 2.4.7 AA)
|
|
1614
|
+
const noFocus = [];
|
|
1615
|
+
for (const [base, entry] of matrix.entries()) {
|
|
1616
|
+
if (entry.states.has("hover") && entry.states.has("focus") === false) noFocus.push(base);
|
|
1617
|
+
}
|
|
1618
|
+
if (noFocus.length > 0) {
|
|
1619
|
+
const first = matrix.get(noFocus[0]);
|
|
1620
|
+
report.push(
|
|
1621
|
+
finding({
|
|
1622
|
+
severity: "warn",
|
|
1623
|
+
rule: "S13",
|
|
1624
|
+
message:
|
|
1625
|
+
`组件有 hover 但无 focus:${noFocus.slice(0, 6).join("、")}${noFocus.length > 6 ? ` 等 ${noFocus.length} 个` : ""}` +
|
|
1626
|
+
`——鼠标有反馈、键盘没反馈,键盘用户看不到焦点落在哪(WCAG 2.4.7 Focus Visible, AA)`,
|
|
1627
|
+
fix: "给这些选择器补 `:focus-visible { outline: 2px solid <主色>; outline-offset: 2px }`;用 :focus-visible 而不是 :focus,避免鼠标点击时也弹焦点环",
|
|
1628
|
+
where: at(first.file, first.line)
|
|
1629
|
+
})
|
|
1630
|
+
);
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
// ② 矩阵总览:只有 ≥2 个组件时才给,单组件页面没必要
|
|
1634
|
+
if (matrix.size >= 2) {
|
|
1635
|
+
const order = ["hover", "focus", "active", "disabled"];
|
|
1636
|
+
const rows = [...matrix.entries()]
|
|
1637
|
+
.sort((a1, b1) => b1[1].states.size - a1[1].states.size)
|
|
1638
|
+
.slice(0, 8)
|
|
1639
|
+
.map(([base, entry]) => `${base} ${order.map((st) => (entry.states.has(st) ? "✓" : "·")).join("")}`);
|
|
1640
|
+
report.push(
|
|
1641
|
+
finding({
|
|
1642
|
+
severity: "info",
|
|
1643
|
+
rule: "S13",
|
|
1644
|
+
message: `组件状态矩阵(${matrix.size} 个组件,列序 hover/focus/active/disabled):${rows.join(" | ")}`,
|
|
1645
|
+
fix: "对照矩阵看哪一列缺得多:focus 列缺 => 键盘不可用(必须补);disabled 列缺 => 该组件可能不需要禁用态,属于正常",
|
|
1646
|
+
where: at([...matrix.values()][0].file, [...matrix.values()][0].line)
|
|
1647
|
+
})
|
|
1648
|
+
);
|
|
1649
|
+
|
|
1650
|
+
// ③ 组件间覆盖不均:最多与最少相差 ≥2 个状态
|
|
1651
|
+
const counts = [...matrix.values()].map((e2) => e2.states.size);
|
|
1652
|
+
const maxN = Math.max(...counts);
|
|
1653
|
+
const minN = Math.min(...counts);
|
|
1654
|
+
if (maxN - minN >= 2) {
|
|
1655
|
+
const weakest = [...matrix.entries()].filter(([, e3]) => e3.states.size === minN).map(([b2]) => b2);
|
|
1656
|
+
report.push(
|
|
1657
|
+
finding({
|
|
1658
|
+
severity: "info",
|
|
1659
|
+
rule: "S13",
|
|
1660
|
+
message: `状态覆盖不均:最完整的组件有 ${maxN} 个状态,${weakest.slice(0, 5).join("、")} 只有 ${minN} 个——同类控件之间不一致会让用户对“能点/能按”的预期错乱`,
|
|
1661
|
+
fix: "同一种控件(按钮 / 输入 / 卡片)用同一套状态矩阵;确实不需要的状态(如卡片的 disabled)在 .design-intent.json 里声明,而不是默默少写",
|
|
1662
|
+
where: at([...matrix.values()][0].file, [...matrix.values()][0].line)
|
|
1663
|
+
})
|
|
1664
|
+
);
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
/* --- S14 负外边距 ---------------------------------------------------------------
|
|
1671
|
+
* 来自真实缺陷:做在线文档参照实现时为了让表格行对齐原图,我给 `.table` 加了
|
|
1672
|
+
* `margin-top:-12px`。像素差确实好看了,但**「最近」的下划线直接贴到了「名称」上**,
|
|
1673
|
+
* 两块内容糊成一片——静态体检全绿,只有人眼看得出来。
|
|
1674
|
+
*
|
|
1675
|
+
* 负外边距本身是合法 CSS,但用在「把两块内容硬拉开/拉近」时几乎总是补丁:
|
|
1676
|
+
* 它会破坏间距层级(块间距 < 块内间距),而且换主题/换字号就崩。
|
|
1677
|
+
* 所以这里报的是**提醒**而不是错误:确认是刻意为之(如居中微调、重叠头像)就声明豁免。
|
|
1678
|
+
*/
|
|
1679
|
+
{
|
|
1680
|
+
const negatives = [];
|
|
1681
|
+
for (const rule of rules) {
|
|
1682
|
+
for (const prop of ["margin", "margin-top", "margin-bottom", "margin-left", "margin-right"]) {
|
|
1683
|
+
const decl = firstDecl(rule, prop);
|
|
1684
|
+
if (decl === undefined) continue;
|
|
1685
|
+
const raw = String(decl.value).trim();
|
|
1686
|
+
// 只看单值负数的简单情形:`-12px`、`0 -8px`、`-1rem`
|
|
1687
|
+
const tokens = raw.split(/\s+/).filter((t) => t !== "");
|
|
1688
|
+
const negTokens = tokens.filter((t) => /^-\d*\.?\d+(px|rem|em|%)?$/.test(t) && Number.parseFloat(t) !== 0);
|
|
1689
|
+
if (negTokens.length === 0 && !/^-\d/.test(raw)) continue;
|
|
1690
|
+
negatives.push({ rule, prop, value: raw });
|
|
1691
|
+
break;
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
if (negatives.length > 0) {
|
|
1695
|
+
const first = negatives[0];
|
|
1696
|
+
report.push(
|
|
1697
|
+
finding({
|
|
1698
|
+
severity: "warn",
|
|
1699
|
+
rule: "S14",
|
|
1700
|
+
message:
|
|
1701
|
+
`用了负外边距:${negatives.slice(0, 4).map((n) => `${n.rule.selector.trim()} { ${n.prop}: ${n.value} }`).join(";")}` +
|
|
1702
|
+
`${negatives.length > 4 ? ` 等 ${negatives.length} 处` : ""}——负 margin 会压掉块间距,典型症状是两块内容视觉上粘在一起`,
|
|
1703
|
+
fix: `先确认不是补丁:想调间距就用 padding / gap / 真实的间距变量;想做重叠层(如头像叠角标)用 transform: translateY() 或 grid 叠层——负 margin 会连带影响布局参与方,换字号就崩`,
|
|
1704
|
+
where: at(first.rule.file, first.rule.line)
|
|
1705
|
+
})
|
|
1706
|
+
);
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
/* --- 标点与常见排版约定(punct-*,来自 typography-audit 的 12 条) ------------------- */
|
|
1711
|
+
// 只在「可见文案」上判:textSegments 已剥掉标签/脚本,但 <code>/<pre> 里的引号与 -- 不算错,
|
|
1712
|
+
// 所以这里再剔一次代码块(业界 gotcha:报代码块内的标点是典型误报)。
|
|
1713
|
+
const punctSegments = [];
|
|
1714
|
+
for (const file of files) {
|
|
1715
|
+
// 标点规则只能对「真的会被渲染的文案」生效:
|
|
1716
|
+
// - JS/TS 里的字符串字面量是代码(工具描述、枚举、日志),不是界面文案;
|
|
1717
|
+
// 实测拿它判会把插件自己的描述文本判成「中文用了半角冒号」。
|
|
1718
|
+
// - CSS 只取 content:"..."(那就是真文案)。
|
|
1719
|
+
// 所以只要 html 系(含 vue/svelte/astro)与 css 两类。
|
|
1720
|
+
if (file.kind !== "html" && file.kind !== "css") continue;
|
|
1721
|
+
if (TEST_FILE_RE.test(file.path ?? "")) continue; // 测试 fixture 不是界面文案
|
|
1722
|
+
// 再把 <code>/<pre>/<kbd>/<samp> 整段挖空(等长空白保留行号):
|
|
1723
|
+
// 代码块里的直引号与 -- 是正确的,报它就是误报。
|
|
1724
|
+
// 注意:必须把 segments 缓存置 undefined。{...file} 会把之前 S8 算过的
|
|
1725
|
+
// 旧 segments 一并拷过来,导致下面的清洗完全不生效(实测踩过)。
|
|
1726
|
+
// 代码块/脚本/插值要从**文案串里拿掉**,不能换成空格:
|
|
1727
|
+
// `({{ x }})` 换成空格就变成了 `( )`,于是凭空多出一条“全角括号内侧有多余空格”
|
|
1728
|
+
// (实测 Echo:punct-paren-space×10 全是这么来的)。
|
|
1729
|
+
const blank = (m) => m.replace(/[^\n]/g, "");
|
|
1730
|
+
const cleaned =
|
|
1731
|
+
file.kind === "html"
|
|
1732
|
+
? {
|
|
1733
|
+
...file,
|
|
1734
|
+
segments: undefined,
|
|
1735
|
+
stripped: file.stripped
|
|
1736
|
+
.replace(/<(code|pre|kbd|samp)\b[^>]*>[\s\S]*?<\/\1>/gi, blank)
|
|
1737
|
+
// script 块是**代码**,不是界面文案:实测 55 个文件的 Vue 项目里,
|
|
1738
|
+
// `status === "queued" ? "排队中…"` 这类 JS 字符串字面量一口气报出 27 条
|
|
1739
|
+
// punct-straight-quote(共 38 条),把真的排版问题全冲掉了。
|
|
1740
|
+
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, blank)
|
|
1741
|
+
// 插值表达式 {{ … }} 同理:里面是表达式与字面量,不是正文标点。
|
|
1742
|
+
.replace(/\{\{[\s\S]*?\}\}/g, blank)
|
|
1743
|
+
}
|
|
1744
|
+
: file;
|
|
1745
|
+
// 模板文件(vue/svelte):插值被拿掉后,`{{ x }}` 前后的空格会留在汉字与标点之间,
|
|
1746
|
+
// 变成“汉字与全角标点之间有多余空格”的假报(实测 Echo:paren-space×9 + space-before×4)。
|
|
1747
|
+
// 含插值的段落单独标记,只对它关掉两条空格敏感规则,引号/破折号等照旧判。
|
|
1748
|
+
const templateTainted = /\{\{|\{%/.test(file.stripped);
|
|
1749
|
+
for (const segment of segmentsOf(cleaned)) {
|
|
1750
|
+
if (segment.trim() === "") continue;
|
|
1751
|
+
// 代码泄漏护栏:被渲染的正文不可能出现这些运算符。
|
|
1752
|
+
// 实测 Vue 项目里 `active?.status === "queued" ? "排队中…" : "处理中…"`
|
|
1753
|
+
// 会以整段(含引号)当成“文案”送到标点规则前,一口气报出 27 条假错误,
|
|
1754
|
+
// 把真的排版问题全淹了。这里做内容层面的兼底(上面还有 script/{{}} 清洗)。
|
|
1755
|
+
if (/(===|!==|\?\?|&&|\|\||=>|\?\.|\$\{|`|[?]\s*["'`]|["'`]\s*[?])/.test(segment)) continue;
|
|
1756
|
+
punctSegments.push({ text: segment, where: file.rel, template: templateTainted });
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1759
|
+
if (punctSegments.length > 0) {
|
|
1760
|
+
// 模板段落不算“空格问题”:那两条规则的输入已经被插值移除污染过
|
|
1761
|
+
const SPACE_RULES = new Set(["punct-space-before-cjk-punct", "punct-paren-space"]);
|
|
1762
|
+
const plainSegs = punctSegments.filter((s) => s.template !== true);
|
|
1763
|
+
const tplSegs = punctSegments.filter((s) => s.template === true);
|
|
1764
|
+
report.items.push(...auditPunct(plainSegs, { hasCJK }));
|
|
1765
|
+
if (tplSegs.length > 0) {
|
|
1766
|
+
report.items.push(...auditPunct(tplSegs, { hasCJK }).filter((f) => SPACE_RULES.has(f.rule) === false));
|
|
1767
|
+
}
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
/* --- 对比度(WCAG AA/AAA + APCA) ---------------------------------------- */
|
|
1771
|
+
// 前景色取自规则的 color;背景取「最近的 background-color」——
|
|
1772
|
+
// 源码层无法算继承链,所以用「全文最常见的背景色」当默认底色(通常是 body),
|
|
1773
|
+
// 规则自己写了 background-color 就用它。算不出来就跳过(不猜)。
|
|
1774
|
+
// 页面底色只能从 **body / html / :root / #root** 的规则里取,不能取「全文最常见的背景色」。
|
|
1775
|
+
// 实测误报(dsh-visualkit 自身):`.stage{color:#3b82f6}` 被配上了 `button.primary{background:#3b82f6}`
|
|
1776
|
+
// —— 因为它自己的背景色恰好是全文唯一的 background 声明,于是算出 1.00:1 的假报。
|
|
1777
|
+
const PAGE_BG_RE = /(?:^|[\s,>+~])(?:html|body|:root|#root|#app)(?:$|[\s,.:#[>+~])/i;
|
|
1778
|
+
let defaultBg;
|
|
1779
|
+
for (const rule of rules) {
|
|
1780
|
+
if (!PAGE_BG_RE.test(rule.selector)) continue;
|
|
1781
|
+
const decl = firstDecl(rule, "background-color") ?? firstDecl(rule, "background");
|
|
1782
|
+
if (decl === undefined) continue;
|
|
1783
|
+
defaultBg = decl.value.replace(/!important/gi, "").trim();
|
|
1784
|
+
break;
|
|
1785
|
+
}
|
|
1786
|
+
const contrastPairs = [];
|
|
1787
|
+
for (const rule of rules) {
|
|
1788
|
+
const colorDecl = firstDecl(rule, "color");
|
|
1789
|
+
if (colorDecl === undefined) continue;
|
|
1790
|
+
const ownBg = firstDecl(rule, "background-color") ?? firstDecl(rule, "background");
|
|
1791
|
+
const bg = ownBg === undefined ? defaultBg : ownBg.value;
|
|
1792
|
+
if (bg === undefined) continue;
|
|
1793
|
+
// 半透明背景算不出实际对比度(不知道下层是什么):
|
|
1794
|
+
// 实测 rgba(255,255,255,.18) 会被当成纯白,跟白字算出 1:1 —— 纯误报。跳过。
|
|
1795
|
+
const bgColor = parseColor(bg);
|
|
1796
|
+
if (bgColor === null || (bgColor.a !== undefined && bgColor.a < 0.95)) continue;
|
|
1797
|
+
const bold = /^(bold|[6-9]00)$/.test((firstDecl(rule, "font-weight")?.value ?? "").trim()) || /font-weight\s*:\s*(bold|[6-9]00)/i.test(rule.declText ?? rule.selector);
|
|
1798
|
+
contrastPairs.push({
|
|
1799
|
+
fg: colorDecl.value,
|
|
1800
|
+
bg,
|
|
1801
|
+
fontSizePx: rule.size !== undefined && rule.size !== null && rule.size > 0 ? rule.size : bodySize > 0 ? bodySize : 16,
|
|
1802
|
+
bold,
|
|
1803
|
+
where: at(rule.file, rule.line)
|
|
1804
|
+
});
|
|
1805
|
+
}
|
|
1806
|
+
if (contrastPairs.length > 0) {
|
|
1807
|
+
const contrastFindings = auditContrast(contrastPairs);
|
|
1808
|
+
// CSS 变量是现代项目的常态,会把 contrast-invalid 刷成好几条 info。
|
|
1809
|
+
// 只留一条并说明「有多少条被跳过」,避免用小体积噪声把 score 抬高。
|
|
1810
|
+
const invalid = contrastFindings.filter((f) => f.rule === "contrast-invalid");
|
|
1811
|
+
const rest = contrastFindings.filter((f) => f.rule !== "contrast-invalid");
|
|
1812
|
+
report.items.push(...rest);
|
|
1813
|
+
if (invalid.length > 0) {
|
|
1814
|
+
report.items.push({
|
|
1815
|
+
...invalid[0],
|
|
1816
|
+
message: `${invalid.length} 处颜色无法解析(currentColor / inherit / CSS 变量 / transparent):这些对比度检查已跳过`,
|
|
1817
|
+
fix: "要精确检查对比度:把这些颜色换成具体色值,或在 .design-intent.json 里记录实际色板后手工核对"
|
|
1818
|
+
});
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
/* --- 标题顺序(h1→h3 跳级 / 首标题非 h1 / 多 h1) ----------------------- */
|
|
1823
|
+
// 按「文件 + 行号」排序当作文档顺序(CSS 源序近似 DOM 顺序,够用)。
|
|
1824
|
+
// 文档顺序必须从**实际标记**取,不能从 CSS 选择器取:
|
|
1825
|
+
// CSS 里只写了 h1..h6 并不代表页面就按顺序用。实测(2026-11-07,Flow-Pilot):
|
|
1826
|
+
// React 项目只在 index.css 里定义了 h5/h6 的字号,就被判成「首标题是 h5」——纯误报。
|
|
1827
|
+
// 所以只扫真标记:html(含 vue/svelte/astro)与 js 系(JSX/TSX 里的 <h1>)。
|
|
1828
|
+
const headingSequence = [];
|
|
1829
|
+
for (const file of files) {
|
|
1830
|
+
if (file.kind !== "html" && file.kind !== "js") continue;
|
|
1831
|
+
if (TEST_FILE_RE.test(file.path ?? "")) continue;
|
|
1832
|
+
const lineStarts = lineStartsOf(file);
|
|
1833
|
+
for (const match of file.stripped.matchAll(/<h([1-6])[\s>/]/g)) {
|
|
1834
|
+
headingSequence.push({ level: Number(match[1]), rel: file.rel, line: lineAt(lineStarts, match.index ?? 0) });
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1837
|
+
// **按文件分别评估**,不能跨文件合在一起:
|
|
1838
|
+
// 组件化 UI 里每个页面/组件各自用 h1/h2 是正常的(实测 Flow-Pilot:7 个页面各一个 h1,
|
|
1839
|
+
// 合起来就变成「5 个 h1」误报;每个页面从 h2 起也正常,因为外层 shell 才有 h1)。
|
|
1840
|
+
// 「首个标题是不是 h1」只在**真文档**(含 <html>/<body>)上判——组件里无法确定。
|
|
1841
|
+
const headingsByFile = new Map();
|
|
1842
|
+
for (const h of headingSequence) {
|
|
1843
|
+
if (!headingsByFile.has(h.rel)) headingsByFile.set(h.rel, []);
|
|
1844
|
+
headingsByFile.get(h.rel).push(h);
|
|
1845
|
+
}
|
|
1846
|
+
for (const [rel, list] of headingsByFile) {
|
|
1847
|
+
list.sort((a, b) => a.line - b.line);
|
|
1848
|
+
const source = files.find((f) => f.rel === rel);
|
|
1849
|
+
// 「首个标题必须是 h1」只在**真文档**上判,而且要**够得上文档大纲**:
|
|
1850
|
+
// - 应用外壳(工具栏+栏位)里 h3/h4 是组件标题,不是大纲,判它就是误报;
|
|
1851
|
+
// - 只有 1–2 个标题的页面谈不上大纲(实测在线文档参照稿就 2 个,被误报)。
|
|
1852
|
+
// 三个条件同时成立才判:文档标记 + 非应用外壳 + ≥3 个标题。
|
|
1853
|
+
const isDocument = source !== undefined && /<html\b|<body\b/i.test(source.stripped) && !appShell && list.length >= 3;
|
|
1854
|
+
const fileFindings = auditHeadingOrder(list.map((h) => ({ level: h.level, where: `${h.rel}:${h.line}` })));
|
|
1855
|
+
report.items.push(
|
|
1856
|
+
...fileFindings.filter((f) => (isDocument ? true : f.rule !== "structure-heading-first"))
|
|
1857
|
+
);
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
/* --- 触摸目标(WCAG 2.2 AA 24px / HIG 44px) ------------------------- */
|
|
1861
|
+
// 只有「同一规则里同时写了 width 与 height」且选择器像交互元素时才判,
|
|
1862
|
+
// 否则任意固定尺寸的容器都会被当成按钮。
|
|
1863
|
+
const INTERACTIVE_RE = /(?:^|[\s,>+~.#:])(button|a|input|select|textarea|label|summary)(?:$|[\s,.:#[>+~])/i;
|
|
1864
|
+
// 只看选择器名字不够:`.tiny{width:100px;height:20px}` 这个类到底是不是按钮,
|
|
1865
|
+
// 得看 HTML 里它长在哪个标签上。所以先把「交互标签上的 class / id」收集成白名单。
|
|
1866
|
+
const interactiveNames = new Set();
|
|
1867
|
+
for (const file of files) {
|
|
1868
|
+
if (file.kind !== "html") continue;
|
|
1869
|
+
for (const match of file.stripped.matchAll(/<(button|a|input|select|textarea|label|summary)\b((?:"[^"]*"|'[^']*'|[^>"'])*)>/gi)) {
|
|
1870
|
+
const attrs = match[2] ?? "";
|
|
1871
|
+
for (const attr of attrs.matchAll(/(?:class|id)\s*=\s*["']([^"']*)["']/gi)) {
|
|
1872
|
+
for (const name of attr[1].split(/\s+/)) if (name !== "") interactiveNames.add(name);
|
|
1873
|
+
}
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
const isInteractiveRule = (selector) => {
|
|
1877
|
+
// 只看**最后一个复合选择器**:`.risk-filter-btn .dot` 里量的是 6px 的小圆点,
|
|
1878
|
+
// 不是按钮热区;拿整串去匹配会把装饰子元素当成控件(实测 Colibri 报了 3 条)。
|
|
1879
|
+
const last = selector.split(/[\s>+~]+/).filter((x) => x !== "").pop() ?? selector;
|
|
1880
|
+
if (/::?(?:before|after|marker|placeholder|thumb|track)\b/i.test(last)) return false;
|
|
1881
|
+
if (INTERACTIVE_RE.test(last)) return true;
|
|
1882
|
+
for (const token of last.matchAll(/[.#]([A-Za-z_-][\w-]*)/g)) if (interactiveNames.has(token[1])) return true;
|
|
1883
|
+
return false;
|
|
1884
|
+
};
|
|
1885
|
+
// 量的是**元素热区**,不是它里面的图标:`.tb-max svg { width:12px;height:12px }`
|
|
1886
|
+
// 说的是图标尺寸,按钮的热区由 padding 决定。实测(Colibri 标题栏)这条把
|
|
1887
|
+
// 三个 32×28 的按钮按 12×12 报成“触摸目标过小”——报错了对象。
|
|
1888
|
+
const ICON_CHILD_RE = /(?:^|[\s>+~])(?:svg|img|i|span|path|use|picture)\s*$/i;
|
|
1889
|
+
const touchTargets = [];
|
|
1890
|
+
for (const rule of rules) {
|
|
1891
|
+
if (ICON_CHILD_RE.test(rule.selector.trim())) continue;
|
|
1892
|
+
if (!isInteractiveRule(rule.selector)) continue;
|
|
1893
|
+
const w = parseWidth(firstDecl(rule, "width")?.value ?? "", bodySize, varMap);
|
|
1894
|
+
const h = parseWidth(firstDecl(rule, "height")?.value ?? "", bodySize, varMap);
|
|
1895
|
+
if (w === null || h === null) continue;
|
|
1896
|
+
touchTargets.push({ width: w, height: h, label: rule.selector.slice(0, 24), where: at(rule.file, rule.line) });
|
|
1897
|
+
}
|
|
1898
|
+
if (touchTargets.length > 0) report.items.push(...auditTouchTargets(touchTargets));
|
|
1899
|
+
|
|
1900
|
+
/* --- 覆盖度:把「没测到」写清楚 ------------------------------------------------- */
|
|
1901
|
+
const measured = [];
|
|
1902
|
+
if (sizeRecords.length > 0) measured.push("字号与行高");
|
|
1903
|
+
if (measureRecords.length > 0) measured.push(`行长×${measureRecords.length}`);
|
|
1904
|
+
if (punctSegments.length > 0) measured.push(`文案段×${punctSegments.length}`);
|
|
1905
|
+
if (contrastPairs.length > 0) measured.push(`对比度×${contrastPairs.length}`);
|
|
1906
|
+
if (headingSequence.length > 0) measured.push(`标题×${headingSequence.length}`);
|
|
1907
|
+
if (touchTargets.length > 0) measured.push(`触摸目标×${touchTargets.length}`);
|
|
1908
|
+
const rulesRun = new Set();
|
|
1909
|
+
if (sizeRecords.length > 0) for (const r of ["S1", "S2", "S4", "S9"]) rulesRun.add(r);
|
|
1910
|
+
if (ratioRecords.length > 0 || sizeRecords.length > 0) rulesRun.add("S3");
|
|
1911
|
+
if (familyCount > 0) rulesRun.add("S5");
|
|
1912
|
+
if (alignRules.length > 0) rulesRun.add("S6");
|
|
1913
|
+
if (punctSegments.length > 0) rulesRun.add("punct");
|
|
1914
|
+
if (contrastPairs.length > 0) rulesRun.add("contrast");
|
|
1915
|
+
if (headingSequence.length > 0) rulesRun.add("structure-heading");
|
|
1916
|
+
if (touchTargets.length > 0) rulesRun.add("structure-touch");
|
|
1917
|
+
|
|
1918
|
+
/* --- 声明豁免:.design-intent.json(与 taste_check taste 同一套语义) ------------------- */
|
|
1919
|
+
// 实测动机(Flow-Pilot):h5/h6 被有意收敛到正文大小(CSS 里写了理由),
|
|
1920
|
+
// 体检只能看到「差 1.13×」并报 error。写了 allow 就不计分,但进 exempted 可追溯。
|
|
1921
|
+
const intent = readIntent(files.length === 1 && files[0].path === target ? dirname(target) : target);
|
|
1922
|
+
const applied = applyAllow(report.items, intent);
|
|
1923
|
+
// 全局封顶:先按 severity 排序再切,保证 error 不被 warn 挤掉
|
|
1924
|
+
const SEVERITY_RANK = { error: 0, warn: 1, info: 2 };
|
|
1925
|
+
const ranked = [...applied.kept].sort((a, b) => (SEVERITY_RANK[a.severity] ?? 3) - (SEVERITY_RANK[b.severity] ?? 3));
|
|
1926
|
+
const keptFindings = ranked.slice(0, MAX_FINDINGS);
|
|
1927
|
+
const findingsTruncated = ranked.length > MAX_FINDINGS;
|
|
1928
|
+
|
|
1929
|
+
return finalize({
|
|
1930
|
+
mode: "ui",
|
|
1931
|
+
target,
|
|
1932
|
+
metrics: {
|
|
1933
|
+
filesScanned: files.length,
|
|
1934
|
+
fontSizes,
|
|
1935
|
+
bodySize,
|
|
1936
|
+
scaleRatio,
|
|
1937
|
+
familyCount,
|
|
1938
|
+
lineHeightRatios,
|
|
1939
|
+
violations: ranked.length,
|
|
1940
|
+
findingsTotal: ranked.length,
|
|
1941
|
+
...(findingsTruncated ? { findingsTruncated: true } : {}),
|
|
1942
|
+
truncated,
|
|
1943
|
+
hasCJK,
|
|
1944
|
+
rulesRun: rulesRun.size,
|
|
1945
|
+
measured,
|
|
1946
|
+
...(applied.exempted.length > 0 ? { exempted: applied.exempted } : {})
|
|
1947
|
+
},
|
|
1948
|
+
findings: keptFindings,
|
|
1949
|
+
// finalize 只透传 extras,不能写成兄弟键
|
|
1950
|
+
...(applied.exempted.length > 0
|
|
1951
|
+
? {
|
|
1952
|
+
extras: {
|
|
1953
|
+
intent: {
|
|
1954
|
+
allow: applied.exempted,
|
|
1955
|
+
...(applied.exemptionReason !== undefined ? { reason: applied.exemptionReason } : {})
|
|
1956
|
+
}
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
1959
|
+
: {})
|
|
1960
|
+
});
|
|
1961
|
+
}
|