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,486 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* layouts/rules-punct.js — 中文标点 / 排版规则(12 条,rule id 统一 `punct-` 前缀)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么单独一个文件:layout_check 已有的 S1–S9 看的是「CSS 排版声明」、G1–G9 看的是
|
|
5
|
+
* 图形几何,标点是「文案层」的事——判据全是字符串规则,跟源码结构无关。所以本模块
|
|
6
|
+
* 只吃 source.js 已经剥好的文本段(textSegments():HTML 去标签、JS 只取字符串字面量、
|
|
7
|
+
* CSS 只取 content),不自己读文件、不知道 <code>/<pre>/注释/字符串边界在哪。
|
|
8
|
+
*
|
|
9
|
+
* 契约(Lead 侧接线):
|
|
10
|
+
* auditPunct(segments, options = {}) → Finding[]
|
|
11
|
+
* segments: Array<{ text: string, where: string }>,where 形如 "src/a.html:42";
|
|
12
|
+
* 传纯字符串数组也兼容(当 { text, where: "" } 处理,此时 finding 无 where)。
|
|
13
|
+
* options: { hasCJK?: boolean } —— 调用方对整份目标的 CJK 判定,缺省按 true 处理。
|
|
14
|
+
*
|
|
15
|
+
* 判据与阈值(总原则:宁可漏报,不要误报;每条豁免都写在下面):
|
|
16
|
+
* punct-straight-quote error 中文语境出现直引号 " 或 '。豁免:' 两侧都是英文字母时
|
|
17
|
+
* 按撇号处理(it's / don't / rock'n'roll);" 前一个字符是数字时按英制英寸
|
|
18
|
+
* (5" 屏幕)处理。英文语境(hasCJK === false)降为 info。
|
|
19
|
+
* punct-double-hyphen warn 出现 `--`。豁免:-- 后紧跟字母/下划线(CSS 自定义属性
|
|
20
|
+
* --gap、var(--x))不算;`<!--` / `-->` 注释定界符不算。
|
|
21
|
+
* punct-three-dots warn 出现 3 个以上半角点(...)。不区分中英语境。
|
|
22
|
+
* punct-halfwidth-cjk error 半角 , . ; : ? ! 与汉字直接相邻(你好,世界)。豁免:
|
|
23
|
+
* . 与 , 前一个字符是数字时是小数点/千分位(3.14、1,000);. 紧邻另一个点时
|
|
24
|
+
* 归 punct-three-dots,不重复报。
|
|
25
|
+
* punct-fullwidth-latin warn 全角句读(,。;:?!、)两侧都是 ASCII 字母/数字,
|
|
26
|
+
* 即「夹在英文里」(Hello,world)。**只认两侧都 ASCII**:`这样写,React 更好`
|
|
27
|
+
* 里的 `,` 左侧是汉字,是合法中文标点,不报——这是本规则最容易误报的地方。
|
|
28
|
+
* punct-space-before-cjk-punct warn 汉字 + 空格 + 全角收尾标点(你好 ,世界)。
|
|
29
|
+
* punct-repeated-punct warn 同类标点连写(!! ?? ,。)或异类连写(,。)。
|
|
30
|
+
* 豁免:!? / ?! ——中文里是常见强调手法,不报。
|
|
31
|
+
* punct-paren-space warn 全角括号()内侧有多余空格(( 说明 )。只认圆括号,
|
|
32
|
+
* 【】《》暂不管(不同团队写法差异太大)。
|
|
33
|
+
* punct-quote-nesting warn 双引号 “ ” 内部再出现双引号(应改内层单引号 ‘’)。
|
|
34
|
+
* punct-dash-spacing warn em dash(— / ——)与汉字之间夹空格。豁免:两侧都不是
|
|
35
|
+
* 汉字的英文写法(a — b)不报——那是英文排版惯例。
|
|
36
|
+
* punct-cjk-latin-space info 同一文本段里汉字与拉丁字母紧贴 ≥3 处才报(1–2 处多半
|
|
37
|
+
* 是 API 名 / 单位缩写的正常写法)。纯数字紧贴(2026年、第3章)不算,与 source.js
|
|
38
|
+
* 的 mixedSpacingHits() 同口径。
|
|
39
|
+
* TODO(重复):本条与 layout_check S8(source.js 的 mixedSpacingHits)判据重叠,
|
|
40
|
+
* 若 Lead 决定去重,直接删掉本条即可,其余 11 条不受影响。
|
|
41
|
+
* punct-ellipsis-count info `…` 连写 3 个以上才报(中文规范就是两个 ……,1–2 个正常)。
|
|
42
|
+
*
|
|
43
|
+
* 通用约定:
|
|
44
|
+
* - 每条规则「同一文件」最多 3 条 finding,按出现顺序取前 3(文件键 = where 去掉末尾
|
|
45
|
+
* ":行号",所以单文件调用就是每规则 3 条)。
|
|
46
|
+
* - message 一律带实际违规片段(≤24 字,换行/制表符换成空格,普通空格保留——空格类
|
|
47
|
+
* 规则要靠它才能定位),格式为 `<说明>(片段「…」)`。
|
|
48
|
+
* - hasCJK === false 时:punct-halfwidth-cjk / punct-fullwidth-latin /
|
|
49
|
+
* punct-quote-nesting / punct-paren-space 不报(中文标点规则在英文语境无意义),
|
|
50
|
+
* punct-straight-quote 降为 info。
|
|
51
|
+
* - 纯函数:不改入参、不依赖时间/随机,同输入两次结果 deepEqual。
|
|
52
|
+
*/
|
|
53
|
+
import { finding } from "./common.js";
|
|
54
|
+
|
|
55
|
+
/* ------------------------------ 阈值与口径 ------------------------------ */
|
|
56
|
+
|
|
57
|
+
/** 每条规则「同一文件」最多留几条 finding:位置热点比穷举有用,score 也有 100 封顶。 */
|
|
58
|
+
const MAX_PER_RULE_PER_FILE = 3;
|
|
59
|
+
/** message 里违规片段的最大字数(含标点与空格)。 */
|
|
60
|
+
const SNIPPET_MAX = 24;
|
|
61
|
+
/** 汉字范围:与 source.js 的 CJK 口径一致,另加扩展 A(㐀-䶿)与兼容区(豈-)。 */
|
|
62
|
+
const CJK_RANGE = "\\u3400-\\u4dbf\\u4e00-\\u9fff\\uf900-\\ufaff";
|
|
63
|
+
const CJK_RE = new RegExp(`[${CJK_RANGE}]`);
|
|
64
|
+
/** 中文句读:只有这些参与「重复标点」,括号 / 引号 / 破折号各有专门规则。 */
|
|
65
|
+
const FW_SENTENCE = ",。;:?!、";
|
|
66
|
+
const FW_SENTENCE_CLASS = `[${FW_SENTENCE}]`;
|
|
67
|
+
/** 半角 → 全角(punct-halfwidth-cjk 的 fix 要逐字说清换哪个)。 */
|
|
68
|
+
const HALF_TO_FULL = { ",": ",", ".": "。", ";": ";", ":": ":", "?": "?", "!": "!" };
|
|
69
|
+
/** !? 连写在中文里是刻意的强调手法,按风格处理,不算「重复标点」。 */
|
|
70
|
+
const EMPHATIC_PAIRS = new Set(["!?", "?!"]);
|
|
71
|
+
|
|
72
|
+
/* ------------------------------ 预算好的正则 ------------------------------ */
|
|
73
|
+
|
|
74
|
+
/** `--` 连写(长度取实际连写数,`---` 也算一条)。 */
|
|
75
|
+
const RE_DOUBLE_HYPHEN = /-{2,}/g;
|
|
76
|
+
/** 三个以上半角点。 */
|
|
77
|
+
const RE_THREE_DOTS = /\.{3,}/g;
|
|
78
|
+
/** 半角标点与汉字直接相邻(两个方向都算)。 */
|
|
79
|
+
const RE_HALFWIDTH_CJK = new RegExp(`[${CJK_RANGE}][,.;:?!]|[,.;:?!][${CJK_RANGE}]`, "g");
|
|
80
|
+
/** 全角句读两侧都是 ASCII 字母/数字 = 夹在英文里。 */
|
|
81
|
+
const RE_FULLWIDTH_LATIN = new RegExp(`[A-Za-z0-9]${FW_SENTENCE_CLASS}[A-Za-z0-9]`, "g");
|
|
82
|
+
/** 汉字 + 空格 + 全角收尾标点。 */
|
|
83
|
+
const RE_SPACE_BEFORE_FW = new RegExp(`[${CJK_RANGE}][ \\t]+[,。;:?!、)】》”’]`, "g");
|
|
84
|
+
/** 汉字与拉丁字母紧贴(数字不算,见文件头)。 */
|
|
85
|
+
const RE_CJK_LATIN = new RegExp(`[${CJK_RANGE}][A-Za-z]|[A-Za-z][${CJK_RANGE}]`, "g");
|
|
86
|
+
/** 省略号连写 3 个以上。 */
|
|
87
|
+
const RE_ELLIPSIS_RUN = /…{3,}/g;
|
|
88
|
+
/** 汉字 + 空格 + em dash / em dash + 空格 + 汉字(破折号两侧多余空格)。 */
|
|
89
|
+
const RE_DASH_SPACE_AFTER_CJK = new RegExp(`[${CJK_RANGE}][ \\t]+—+`, "g");
|
|
90
|
+
const RE_SPACE_BEFORE_DASH_CJK = new RegExp(`—+[ \\t]+[${CJK_RANGE}]`, "g");
|
|
91
|
+
/** 全角左括号 / 右括号(括号内侧空格规则用)。 */
|
|
92
|
+
const RE_FW_OPEN = /(/g;
|
|
93
|
+
const RE_SPACE_FW_CLOSE = /[ \t]+)/g;
|
|
94
|
+
|
|
95
|
+
/* ------------------------------ 片段与匹配工具 ------------------------------ */
|
|
96
|
+
|
|
97
|
+
function hasCJK(text) {
|
|
98
|
+
CJK_RE.lastIndex = 0;
|
|
99
|
+
return CJK_RE.test(text);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 取命中位置附近的片段(≤ SNIPPET_MAX 字)。
|
|
104
|
+
* 只把换行 / 制表符换成空格,普通空格原样保留——`你好 ,世界` 这类规则要靠空格本身定位。
|
|
105
|
+
*/
|
|
106
|
+
function snippet(text, index, length) {
|
|
107
|
+
const span = Math.max(1, Math.min(length, SNIPPET_MAX));
|
|
108
|
+
const room = SNIPPET_MAX - span;
|
|
109
|
+
const latest = Math.max(0, text.length - SNIPPET_MAX);
|
|
110
|
+
const start = Math.max(0, Math.min(index - Math.floor(room / 2), latest));
|
|
111
|
+
return text.slice(start, start + SNIPPET_MAX).replace(/[\r\n\t]/g, " ");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** 避免 g 正则的 lastIndex 在多次调用之间串味(同输入必须同结果)。 */
|
|
115
|
+
function allMatches(text, re) {
|
|
116
|
+
const out = [];
|
|
117
|
+
re.lastIndex = 0;
|
|
118
|
+
let match;
|
|
119
|
+
while ((match = re.exec(text)) !== null) {
|
|
120
|
+
out.push(match);
|
|
121
|
+
if (match[0].length === 0) re.lastIndex += 1;
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** 命中项按出现顺序排好,重叠的只留先出现的那个(`——` 前后空格不该报两次)。 */
|
|
127
|
+
function orderedHits(hits) {
|
|
128
|
+
const sorted = hits.slice().sort((a, b) => a.index - b.index);
|
|
129
|
+
const kept = [];
|
|
130
|
+
for (const hit of sorted) {
|
|
131
|
+
const end = hit.index + Math.max(1, hit.length);
|
|
132
|
+
const overlaps = kept.some((other) => hit.index < other.index + Math.max(1, other.length) && other.index < end);
|
|
133
|
+
if (overlaps) continue;
|
|
134
|
+
kept.push(hit);
|
|
135
|
+
}
|
|
136
|
+
return kept;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** where = "src/a.html:42" → 文件键 "src/a.html"(每规则「单文件」封顶靠它)。 */
|
|
140
|
+
function fileKeyOf(where) {
|
|
141
|
+
const cut = where.lastIndexOf(":");
|
|
142
|
+
if (cut > 0 && /^\d+$/.test(where.slice(cut + 1))) return where.slice(0, cut);
|
|
143
|
+
return where;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/* ------------------------------ 各规则的扫描器 ------------------------------ */
|
|
147
|
+
/* 每个扫描器返回 [{ index, length, label?, count? }]:index/length 用于取片段,
|
|
148
|
+
label 是命中的具体字符(fix 里要指名道姓),count 是「同段共 N 处」的聚合数。 */
|
|
149
|
+
|
|
150
|
+
/** 中文语境的直引号;' 两侧是字母时按英文撇号放过," 紧跟数字时按英寸放过。 */
|
|
151
|
+
function scanStraightQuotes(text, cjkMode) {
|
|
152
|
+
if (cjkMode && !hasCJK(text)) return [];
|
|
153
|
+
const hits = [];
|
|
154
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
155
|
+
const char = text[i];
|
|
156
|
+
if (char !== '"' && char !== "'") continue;
|
|
157
|
+
const before = text[i - 1] ?? "";
|
|
158
|
+
const after = text[i + 1] ?? "";
|
|
159
|
+
if (char === "'" && /[A-Za-z]/.test(before) && /[A-Za-z]/.test(after)) continue;
|
|
160
|
+
if (char === '"' && /[0-9]/.test(before)) continue;
|
|
161
|
+
hits.push({ index: i, length: 1, label: char });
|
|
162
|
+
}
|
|
163
|
+
return hits;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function scanDoubleHyphen(text) {
|
|
167
|
+
const hits = [];
|
|
168
|
+
for (const match of allMatches(text, RE_DOUBLE_HYPHEN)) {
|
|
169
|
+
const before = text[match.index - 1] ?? "";
|
|
170
|
+
const after = text[match.index + match[0].length] ?? "";
|
|
171
|
+
if (/[A-Za-z_]/.test(after)) continue;
|
|
172
|
+
if (before === "!" || after === ">") continue;
|
|
173
|
+
hits.push({ index: match.index, length: match[0].length, label: match[0] });
|
|
174
|
+
}
|
|
175
|
+
return hits;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function scanThreeDots(text) {
|
|
179
|
+
return allMatches(text, RE_THREE_DOTS).map((match) => ({ index: match.index, length: match[0].length, label: match[0] }));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function scanHalfwidthCjk(text) {
|
|
183
|
+
const hits = [];
|
|
184
|
+
for (const match of allMatches(text, RE_HALFWIDTH_CJK)) {
|
|
185
|
+
const first = text[match.index];
|
|
186
|
+
const punctIndex = /[,.;:?!]/.test(first) ? match.index : match.index + 1;
|
|
187
|
+
const punct = text[punctIndex];
|
|
188
|
+
const before = text[punctIndex - 1] ?? "";
|
|
189
|
+
if ((punct === "." || punct === ",") && /[0-9]/.test(before)) continue;
|
|
190
|
+
if (punct === "." && (text[punctIndex - 1] === "." || text[punctIndex + 1] === ".")) continue;
|
|
191
|
+
// 实测假误报:`做动画片.jpg` —— 这里的点属于**拉丁文件名/扩展名**,不是中文标点。
|
|
192
|
+
// 判据:点后面紧跟 ≥2 个连续拉丁字母(jpg / com / txt),就当它是 token 的一部分。
|
|
193
|
+
if (punct === "." && /^[A-Za-z]{2,}/.test(text.slice(punctIndex + 1, punctIndex + 8))) continue;
|
|
194
|
+
hits.push({ index: punctIndex, length: 1, label: punct });
|
|
195
|
+
}
|
|
196
|
+
return hits;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function scanFullwidthLatin(text) {
|
|
200
|
+
return allMatches(text, RE_FULLWIDTH_LATIN).map((match) => ({
|
|
201
|
+
index: match.index + 1,
|
|
202
|
+
length: 1,
|
|
203
|
+
label: text[match.index + 1]
|
|
204
|
+
}));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function scanSpaceBeforeCjkPunct(text) {
|
|
208
|
+
return allMatches(text, RE_SPACE_BEFORE_FW).map((match) => ({
|
|
209
|
+
index: match.index,
|
|
210
|
+
length: match[0].length,
|
|
211
|
+
label: match[0].slice(-1)
|
|
212
|
+
}));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** 句读连写:先取最长连写段,再看是否属于「!?」这种刻意强调。 */
|
|
216
|
+
function scanRepeatedPunct(text) {
|
|
217
|
+
const hits = [];
|
|
218
|
+
let i = 0;
|
|
219
|
+
while (i < text.length) {
|
|
220
|
+
if (!FW_SENTENCE.includes(text[i])) {
|
|
221
|
+
i += 1;
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
let end = i + 1;
|
|
225
|
+
while (end < text.length && FW_SENTENCE.includes(text[end])) end += 1;
|
|
226
|
+
const run = text.slice(i, end);
|
|
227
|
+
if (run.length >= 2 && !(run.length === 2 && EMPHATIC_PAIRS.has(run))) {
|
|
228
|
+
hits.push({ index: i, length: run.length, label: run });
|
|
229
|
+
}
|
|
230
|
+
i = end;
|
|
231
|
+
}
|
|
232
|
+
return hits;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** 全角括号内侧空格:先按「成对」整段报一次,未闭合的再单独兜底。 */
|
|
236
|
+
function scanParenSpace(text) {
|
|
237
|
+
const hits = [];
|
|
238
|
+
for (const match of allMatches(text, RE_FW_OPEN)) {
|
|
239
|
+
const open = match.index;
|
|
240
|
+
// 只在 40 字内配对,避免把跨段落的不相关括号连成一片
|
|
241
|
+
const near = text.slice(open + 1, open + 41);
|
|
242
|
+
const closeAt = near.indexOf(")");
|
|
243
|
+
if (closeAt !== -1) {
|
|
244
|
+
const inner = near.slice(0, closeAt);
|
|
245
|
+
if (/^[ \t]/.test(inner) || /[ \t]$/.test(inner)) hits.push({ index: open, length: closeAt + 2 });
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
if (/^[ \t]/.test(near)) hits.push({ index: open, length: 2 });
|
|
249
|
+
}
|
|
250
|
+
// 只有右括号一侧有空格(左括号在上一个文本段里被切走)时兜底
|
|
251
|
+
for (const match of allMatches(text, RE_SPACE_FW_CLOSE)) {
|
|
252
|
+
hits.push({ index: match.index, length: match[0].length });
|
|
253
|
+
}
|
|
254
|
+
return hits;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** 双引号嵌套:同一文本段内 “ 出现在未闭合的 “ 里就是层级错。 */
|
|
258
|
+
function scanQuoteNesting(text) {
|
|
259
|
+
const hits = [];
|
|
260
|
+
let depth = 0;
|
|
261
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
262
|
+
const char = text[i];
|
|
263
|
+
if (char === "“") {
|
|
264
|
+
if (depth >= 1) hits.push({ index: i, length: 1 });
|
|
265
|
+
depth += 1;
|
|
266
|
+
} else if (char === "”" && depth > 0) {
|
|
267
|
+
depth -= 1;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return hits;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function scanDashSpacing(text) {
|
|
274
|
+
const hits = [];
|
|
275
|
+
for (const match of allMatches(text, RE_DASH_SPACE_AFTER_CJK)) hits.push({ index: match.index, length: match[0].length });
|
|
276
|
+
for (const match of allMatches(text, RE_SPACE_BEFORE_DASH_CJK)) hits.push({ index: match.index, length: match[0].length });
|
|
277
|
+
return hits;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** 汉字与拉丁字母紧贴:同一段满 3 处才报(1–2 处多半是正常写法)。 */
|
|
281
|
+
function scanCjkLatinSpace(text) {
|
|
282
|
+
const matches = allMatches(text, RE_CJK_LATIN);
|
|
283
|
+
if (matches.length < 3) return [];
|
|
284
|
+
return [{ index: matches[0].index, length: matches[0][0].length, count: matches.length }];
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function scanEllipsisCount(text) {
|
|
288
|
+
return allMatches(text, RE_ELLIPSIS_RUN).map((match) => ({ index: match.index, length: match[0].length, count: match[0].length }));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/* ------------------------------ 规则表 ------------------------------ */
|
|
292
|
+
/* 顺序即报告顺序;severityEnglish 表示英文语境下的降级档;cjkOnly 表示英文语境不报。 */
|
|
293
|
+
const RULES = [
|
|
294
|
+
{
|
|
295
|
+
rule: "punct-straight-quote",
|
|
296
|
+
severity: "error",
|
|
297
|
+
severityEnglish: "info",
|
|
298
|
+
scan: (text, cjkMode) => scanStraightQuotes(text, cjkMode),
|
|
299
|
+
describe: (hit, cjkMode) => {
|
|
300
|
+
// 英文语境下直引号是正常写法,只是风格提示,措辞跟着语境走
|
|
301
|
+
const where = cjkMode ? "中文语境" : "英文语境";
|
|
302
|
+
return hit.label === '"'
|
|
303
|
+
? {
|
|
304
|
+
message: `${where}出现直双引号`,
|
|
305
|
+
fix: '中文行文成对换成弯引号 “ 与 ”(他说"好" → 他说“好”);英文正文用直引号可以不改'
|
|
306
|
+
}
|
|
307
|
+
: {
|
|
308
|
+
message: `${where}出现直单引号`,
|
|
309
|
+
fix: "中文行文成对换成弯单引号 ‘ 与 ’(他说'好' → 他说‘好’);英文撇号 it's 保持半角"
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
},
|
|
313
|
+
{
|
|
314
|
+
rule: "punct-double-hyphen",
|
|
315
|
+
severity: "warn",
|
|
316
|
+
scan: (text) => scanDoubleHyphen(text),
|
|
317
|
+
describe: () => ({
|
|
318
|
+
message: "出现连写的半角连字符 --",
|
|
319
|
+
fix: "中文破折号用 ——(两个 em dash、不加空格);英文范围用 –、断句用 —;不要用 -- 凑"
|
|
320
|
+
})
|
|
321
|
+
},
|
|
322
|
+
{
|
|
323
|
+
rule: "punct-three-dots",
|
|
324
|
+
severity: "warn",
|
|
325
|
+
scan: (text) => scanThreeDots(text),
|
|
326
|
+
describe: () => ({
|
|
327
|
+
message: "用半角点代替省略号",
|
|
328
|
+
fix: "换成单字符省略号 …:中文行文连写两个就是 ……;\"...等\" → \"…等\""
|
|
329
|
+
})
|
|
330
|
+
},
|
|
331
|
+
{
|
|
332
|
+
rule: "punct-halfwidth-cjk",
|
|
333
|
+
severity: "error",
|
|
334
|
+
cjkOnly: true,
|
|
335
|
+
scan: (text) => scanHalfwidthCjk(text),
|
|
336
|
+
describe: (hit) => ({
|
|
337
|
+
message: `中文语境用了半角标点 ${hit.label}`,
|
|
338
|
+
fix: `换成全角:${hit.label} → ${HALF_TO_FULL[hit.label] ?? "(全角标点)"};中文行文的标点一律全角(整句英文除外)`
|
|
339
|
+
})
|
|
340
|
+
},
|
|
341
|
+
{
|
|
342
|
+
rule: "punct-fullwidth-latin",
|
|
343
|
+
severity: "warn",
|
|
344
|
+
cjkOnly: true,
|
|
345
|
+
scan: (text) => scanFullwidthLatin(text),
|
|
346
|
+
describe: (hit) => ({
|
|
347
|
+
message: `全角标点 ${hit.label} 夹在拉丁字母/数字之间`,
|
|
348
|
+
fix: "英文语境改用半角标点(,→, 。→. ;→; :→: ?!→?!);若整句是中文,把标点留在汉字一侧"
|
|
349
|
+
})
|
|
350
|
+
},
|
|
351
|
+
{
|
|
352
|
+
rule: "punct-space-before-cjk-punct",
|
|
353
|
+
severity: "warn",
|
|
354
|
+
cjkOnly: true,
|
|
355
|
+
scan: (text) => scanSpaceBeforeCjkPunct(text),
|
|
356
|
+
describe: (hit) => ({
|
|
357
|
+
message: `汉字与全角标点 ${hit.label} 之间有多余空格`,
|
|
358
|
+
fix: "删掉空格:\"你好 ,世界\" → \"你好,世界\";全角标点自带字面留白,不需要再加空格"
|
|
359
|
+
})
|
|
360
|
+
},
|
|
361
|
+
{
|
|
362
|
+
rule: "punct-repeated-punct",
|
|
363
|
+
severity: "warn",
|
|
364
|
+
scan: (text) => scanRepeatedPunct(text),
|
|
365
|
+
describe: (hit) => ({
|
|
366
|
+
message: `标点重复连用 ${hit.label}`,
|
|
367
|
+
// 异类连写(,。)保留语气更重的后一个,同类连写(!!)保留一个即可
|
|
368
|
+
fix: `收敛成一个:${hit.label} → ${hit.label.slice(-1)};强调靠句式和措辞,不靠叠加标点`
|
|
369
|
+
})
|
|
370
|
+
},
|
|
371
|
+
{
|
|
372
|
+
rule: "punct-paren-space",
|
|
373
|
+
severity: "warn",
|
|
374
|
+
cjkOnly: true,
|
|
375
|
+
scan: (text) => scanParenSpace(text),
|
|
376
|
+
describe: () => ({
|
|
377
|
+
message: "全角括号内侧有多余空格",
|
|
378
|
+
fix: "删掉括号内侧空格:\"( 说明 )\" → \"(说明)\";括号紧贴内容,外侧才按中英混排加空格"
|
|
379
|
+
})
|
|
380
|
+
},
|
|
381
|
+
{
|
|
382
|
+
rule: "punct-quote-nesting",
|
|
383
|
+
severity: "warn",
|
|
384
|
+
cjkOnly: true,
|
|
385
|
+
scan: (text) => scanQuoteNesting(text),
|
|
386
|
+
describe: () => ({
|
|
387
|
+
message: "双引号里又开了双引号",
|
|
388
|
+
fix: "外层用 “ ”,内层改用单引号 ‘ ’(他说:“他问‘走吧’”);嵌套最多两层"
|
|
389
|
+
})
|
|
390
|
+
},
|
|
391
|
+
{
|
|
392
|
+
rule: "punct-dash-spacing",
|
|
393
|
+
severity: "warn",
|
|
394
|
+
cjkOnly: true,
|
|
395
|
+
scan: (text) => scanDashSpacing(text),
|
|
396
|
+
describe: () => ({
|
|
397
|
+
message: "破折号与汉字之间有多余空格",
|
|
398
|
+
fix: "中文破折号 —— 两侧紧贴文字:\"他走了 —— 算了\" → \"他走了——算了\""
|
|
399
|
+
})
|
|
400
|
+
},
|
|
401
|
+
{
|
|
402
|
+
rule: "punct-cjk-latin-space",
|
|
403
|
+
severity: "info",
|
|
404
|
+
scan: (text) => scanCjkLatinSpace(text),
|
|
405
|
+
describe: (hit) => ({
|
|
406
|
+
message: `汉字与拉丁字母紧贴共 ${hit.count} 处`,
|
|
407
|
+
fix: `汉字与拉丁字母之间加一个半角空格(React框架 → React 框架),同段共 ${hit.count} 处;数字紧贴(2026年)不用改`
|
|
408
|
+
})
|
|
409
|
+
},
|
|
410
|
+
{
|
|
411
|
+
rule: "punct-ellipsis-count",
|
|
412
|
+
severity: "info",
|
|
413
|
+
scan: (text) => scanEllipsisCount(text),
|
|
414
|
+
describe: (hit) => ({
|
|
415
|
+
message: `省略号连写了 ${hit.count} 个`,
|
|
416
|
+
fix: `中文省略号固定两个 … 即 ……(${hit.count} 个 → 2 个),多的删掉`
|
|
417
|
+
})
|
|
418
|
+
}
|
|
419
|
+
];
|
|
420
|
+
|
|
421
|
+
/** 12 条规则的 id(接线 / 文档生成用的只读清单,顺序固定)。 */
|
|
422
|
+
export const PUNCT_RULE_IDS = Object.freeze(RULES.map((entry) => entry.rule));
|
|
423
|
+
|
|
424
|
+
/* ------------------------------ 入口 ------------------------------ */
|
|
425
|
+
|
|
426
|
+
/** 入参归一:对象 { text, where } 与纯字符串都接,空文本/非法项直接跳过。 */
|
|
427
|
+
function normalizeSegments(segments) {
|
|
428
|
+
if (!Array.isArray(segments)) return [];
|
|
429
|
+
const list = [];
|
|
430
|
+
for (const item of segments) {
|
|
431
|
+
if (typeof item === "string") {
|
|
432
|
+
if (item !== "") list.push({ text: item, where: "" });
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
if (item === null || typeof item !== "object") continue;
|
|
436
|
+
const text = typeof item.text === "string" ? item.text : "";
|
|
437
|
+
if (text === "") continue;
|
|
438
|
+
const where = typeof item.where === "string" ? item.where : "";
|
|
439
|
+
list.push({ text, where });
|
|
440
|
+
}
|
|
441
|
+
return list;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function severityOf(rule, cjkMode) {
|
|
445
|
+
if (!cjkMode && typeof rule.severityEnglish === "string") return rule.severityEnglish;
|
|
446
|
+
return rule.severity;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* 中文标点 / 排版体检。
|
|
451
|
+
* @param {Array<{text: string, where: string}>} segments 已由 source.js 剥掉代码/注释/标签的文本段
|
|
452
|
+
* @param {{hasCJK?: boolean}} [options] hasCJK === false 表示整份目标没有中文(英文语境)
|
|
453
|
+
* @returns {Array<{severity: "error"|"warn"|"info", rule: string, message: string, fix: string, where?: string}>}
|
|
454
|
+
*/
|
|
455
|
+
export function auditPunct(segments, options = {}) {
|
|
456
|
+
const settings = options !== null && typeof options === "object" ? options : {};
|
|
457
|
+
const cjkMode = settings.hasCJK !== false;
|
|
458
|
+
const list = normalizeSegments(segments);
|
|
459
|
+
const findings = [];
|
|
460
|
+
for (const rule of RULES) {
|
|
461
|
+
if (rule.cjkOnly === true && !cjkMode) continue;
|
|
462
|
+
const usedPerFile = new Map();
|
|
463
|
+
for (const segment of list) {
|
|
464
|
+
const file = fileKeyOf(segment.where);
|
|
465
|
+
if ((usedPerFile.get(file) ?? 0) >= MAX_PER_RULE_PER_FILE) continue;
|
|
466
|
+
const hits = rule.scan(segment.text, cjkMode);
|
|
467
|
+
if (!Array.isArray(hits) || hits.length === 0) continue;
|
|
468
|
+
for (const hit of orderedHits(hits)) {
|
|
469
|
+
const used = usedPerFile.get(file) ?? 0;
|
|
470
|
+
if (used >= MAX_PER_RULE_PER_FILE) break;
|
|
471
|
+
const { message, fix } = rule.describe(hit, cjkMode);
|
|
472
|
+
findings.push(
|
|
473
|
+
finding({
|
|
474
|
+
severity: severityOf(rule, cjkMode),
|
|
475
|
+
rule: rule.rule,
|
|
476
|
+
message: `${message}(片段「${snippet(segment.text, hit.index, hit.length)}」)`,
|
|
477
|
+
fix,
|
|
478
|
+
...(segment.where === "" ? {} : { where: segment.where })
|
|
479
|
+
})
|
|
480
|
+
);
|
|
481
|
+
usedPerFile.set(file, used + 1);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
return findings;
|
|
486
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* layouts/rules-structure.js — 结构类排版规则(不依赖字体的那些)。
|
|
3
|
+
*
|
|
4
|
+
* 三条规则来自业界实践(2026-11-07 调研):
|
|
5
|
+
* heading 标题跳级 h1→h3:layout-lint-mcp 的 audit_typography 有这一项;
|
|
6
|
+
* 文档层级跳跃会让屏幕阅读器与读者都丢失位置感,且直接暴露「没规划结构」。
|
|
7
|
+
* touch 触摸目标:WCAG 2.2 AA(2.5.8)要求 24×24 CSS px;Apple HIG 44×44;
|
|
8
|
+
* Material 48×48。三者递进,所以分三档。
|
|
9
|
+
* baseline 垂直节奏:typography-skills 的 baseline check 把每行文字对照基准网格;
|
|
10
|
+
* 我们用「行 y 对 grid 取模」,比逐字对齐宽松,只抓明显失控。
|
|
11
|
+
*
|
|
12
|
+
* 全部是纯函数:不读文件、不联网、确定性输出。
|
|
13
|
+
*/
|
|
14
|
+
import { finding } from "./common.js";
|
|
15
|
+
|
|
16
|
+
/** 每条规则最多报几条:报告要能读完,热点比穷举有用。 */
|
|
17
|
+
const PER_RULE_CAP = 3;
|
|
18
|
+
|
|
19
|
+
function cap(severity, items) {
|
|
20
|
+
return items.slice(0, PER_RULE_CAP).map((item) => finding({ severity, ...item }));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/* ------------------------------ 标题层级 ------------------------------ */
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 标题跳级。只在「相邻两个标题」之间判:跳 >1 级 = error;回退任意级正常
|
|
27
|
+
* (h3 回到 h1 是开新章节,很常见)。
|
|
28
|
+
* @param {Array<{level:number, where:string}>} headings 按文档顺序
|
|
29
|
+
*/
|
|
30
|
+
export function auditHeadingOrder(headings) {
|
|
31
|
+
const list = Array.isArray(headings) ? headings.filter((h) => Number.isFinite(h?.level) && h.level >= 1 && h.level <= 6) : [];
|
|
32
|
+
if (list.length === 0) return [];
|
|
33
|
+
const out = [];
|
|
34
|
+
|
|
35
|
+
// 跳级:h1 → h3 缺 h2
|
|
36
|
+
const skips = [];
|
|
37
|
+
for (let i = 1; i < list.length; i += 1) {
|
|
38
|
+
const prev = list[i - 1];
|
|
39
|
+
const cur = list[i];
|
|
40
|
+
if (cur.level > prev.level + 1) {
|
|
41
|
+
skips.push({
|
|
42
|
+
rule: "structure-heading-skip",
|
|
43
|
+
where: cur.where,
|
|
44
|
+
message: `标题跳级:h${prev.level} 之后直接是 h${cur.level},中间缺 ${Array.from({ length: cur.level - prev.level - 1 }, (_, k) => `h${prev.level + k + 1}`).join("、")}`,
|
|
45
|
+
fix: `把 h${cur.level} 改成 h${prev.level + 1},或在中间补 ${Array.from({ length: cur.level - prev.level - 1 }, (_, k) => `h${prev.level + k + 1}`).join("、")} 小标题(层级不能跳,否则大纲与屏幕阅读器都断链)`
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
out.push(...cap("error", skips));
|
|
50
|
+
|
|
51
|
+
// 首个标题不是 h1
|
|
52
|
+
if (list[0].level !== 1) {
|
|
53
|
+
out.push(
|
|
54
|
+
finding({
|
|
55
|
+
severity: "warn",
|
|
56
|
+
rule: "structure-heading-first",
|
|
57
|
+
where: list[0].where,
|
|
58
|
+
message: `第一个标题是 h${list[0].level} 而不是 h1:页面缺主标题`,
|
|
59
|
+
fix: "把页面主标题改成 h1(每页只有一个),它下面的小节从 h2 开始"
|
|
60
|
+
})
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// 多个 h1
|
|
65
|
+
const h1s = list.filter((h) => h.level === 1);
|
|
66
|
+
if (h1s.length > 1) {
|
|
67
|
+
out.push(
|
|
68
|
+
finding({
|
|
69
|
+
severity: "warn",
|
|
70
|
+
rule: "structure-heading-duplicate-h1",
|
|
71
|
+
where: h1s[1].where,
|
|
72
|
+
message: `出现 ${h1s.length} 个 h1:主标题不唯一`,
|
|
73
|
+
fix: "保留一个 h1 作页面主标题,其余降为 h2(多 h1 会让大纲变平、SEO 与读屏都变差)"
|
|
74
|
+
})
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/* ------------------------------ 触摸目标 ------------------------------ */
|
|
82
|
+
|
|
83
|
+
/** WCAG 2.2 AA 下限、Apple HIG、Material 推荐值。 */
|
|
84
|
+
export const TOUCH_MIN = 24;
|
|
85
|
+
export const TOUCH_RECOMMENDED = 44;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 触摸目标尺寸。取短边判:宽 200 高 20 的按钮依然点不中。
|
|
89
|
+
* @param {Array<{width:number, height:number, label?:string, where:string}>} targets
|
|
90
|
+
*/
|
|
91
|
+
export function auditTouchTargets(targets) {
|
|
92
|
+
const list = Array.isArray(targets)
|
|
93
|
+
? targets.filter((t) => Number.isFinite(t?.width) && Number.isFinite(t?.height) && t.width > 0 && t.height > 0)
|
|
94
|
+
: [];
|
|
95
|
+
if (list.length === 0) return [];
|
|
96
|
+
|
|
97
|
+
const tooSmall = [];
|
|
98
|
+
const belowRec = [];
|
|
99
|
+
for (const t of list) {
|
|
100
|
+
const short = Math.min(t.width, t.height);
|
|
101
|
+
const name = typeof t.label === "string" && t.label !== "" ? t.label : "交互元素";
|
|
102
|
+
if (short < TOUCH_MIN) {
|
|
103
|
+
tooSmall.push({
|
|
104
|
+
rule: "structure-touch-too-small",
|
|
105
|
+
where: t.where,
|
|
106
|
+
message: `${name} 触摸目标 ${t.width}×${t.height}px,短边 ${short}px < WCAG 2.2 AA 的 ${TOUCH_MIN}px`,
|
|
107
|
+
fix: `把可点区域提到至少 ${TOUCH_MIN}×${TOUCH_MIN}px(推荐 ${TOUCH_RECOMMENDED}×${TOUCH_RECOMMENDED}px);视觉可以小,用 padding 或伪元素扩大点击区`
|
|
108
|
+
});
|
|
109
|
+
} else if (short < TOUCH_RECOMMENDED) {
|
|
110
|
+
belowRec.push({
|
|
111
|
+
rule: "structure-touch-below-recommended",
|
|
112
|
+
where: t.where,
|
|
113
|
+
message: `${name} 触摸目标 ${t.width}×${t.height}px,短边 ${short}px 刚过 AA 下限但低于 ${TOUCH_RECOMMENDED}px 推荐值`,
|
|
114
|
+
fix: `移动端(Apple HIG)建议 ${TOUCH_RECOMMENDED}×${TOUCH_RECOMMENDED}px;桌面端可忽略这条 warn`
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return [...cap("error", tooSmall), ...cap("warn", belowRec)];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/* ------------------------------ 基线网格 ------------------------------ */
|
|
123
|
+
|
|
124
|
+
/** 默认网格:8px(4px 是更细的间距网格,文字行节奏通常按 8)。 */
|
|
125
|
+
export const DEFAULT_BASELINE_GRID = 8;
|
|
126
|
+
/** 少于此数量的行不判网格(小样本波动大,极易误报)。 */
|
|
127
|
+
export const BASELINE_MIN_LINES = 4;
|
|
128
|
+
/** 偏离网格的行占比超过它才报。 */
|
|
129
|
+
export const BASELINE_OFFGRID_RATIO = 0.3;
|
|
130
|
+
/** 取模偏差容差(px)。 */
|
|
131
|
+
const GRID_TOLERANCE = 1;
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* 基线网格。只看行 y,不碰行距(行距是 G3 的事)。
|
|
135
|
+
* @param {Array<{y:number, height?:number, where:string}>} lines
|
|
136
|
+
* @param {{grid?:number}} [options]
|
|
137
|
+
*/
|
|
138
|
+
export function auditBaselineGrid(lines, options = {}) {
|
|
139
|
+
const grid = Number.isFinite(options.grid) && options.grid > 0 ? options.grid : DEFAULT_BASELINE_GRID;
|
|
140
|
+
const list = Array.isArray(lines) ? lines.filter((l) => Number.isFinite(l?.y)) : [];
|
|
141
|
+
if (list.length < BASELINE_MIN_LINES) return [];
|
|
142
|
+
|
|
143
|
+
const offGrid = list.filter((l) => {
|
|
144
|
+
const rem = ((l.y % grid) + grid) % grid;
|
|
145
|
+
return Math.min(rem, grid - rem) > GRID_TOLERANCE;
|
|
146
|
+
});
|
|
147
|
+
const out = [];
|
|
148
|
+
if (offGrid.length / list.length > BASELINE_OFFGRID_RATIO) {
|
|
149
|
+
const worst = offGrid[0];
|
|
150
|
+
const nearest = Math.round(worst.y / grid) * grid;
|
|
151
|
+
out.push(
|
|
152
|
+
finding({
|
|
153
|
+
severity: "warn",
|
|
154
|
+
rule: "structure-baseline-offgrid",
|
|
155
|
+
where: worst.where,
|
|
156
|
+
message: `${offGrid.length}/${list.length} 行文字的基线不在 ${grid}px 网格上(>${Math.round(BASELINE_OFFGRID_RATIO * 100)}%):垂直节奏失控`,
|
|
157
|
+
fix: `把行的 y 对齐到 ${grid} 的倍数(最近值 ${nearest});先固定行高与段间距为 ${grid} 的倍数,再定位`
|
|
158
|
+
})
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// 间距种类过多 → 垂直节奏不统一(info,不扣分重点)
|
|
163
|
+
const gaps = [];
|
|
164
|
+
for (let i = 1; i < list.length; i += 1) {
|
|
165
|
+
const delta = Math.round(list[i].y - list[i - 1].y);
|
|
166
|
+
if (delta > 0) gaps.push(delta);
|
|
167
|
+
}
|
|
168
|
+
const distinct = [...new Set(gaps)];
|
|
169
|
+
if (distinct.length >= 3) {
|
|
170
|
+
out.push(
|
|
171
|
+
finding({
|
|
172
|
+
severity: "info",
|
|
173
|
+
rule: "structure-baseline-inconsistent",
|
|
174
|
+
where: "整版",
|
|
175
|
+
message: `行间距出现 ${distinct.length} 种不同值(${distinct.slice(0, 6).join("/")}):垂直节奏不统一`,
|
|
176
|
+
fix: `行距收敛到 1–2 个值(正文一个、标题一个),并且都是 ${grid} 的倍数`
|
|
177
|
+
})
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return out;
|
|
182
|
+
}
|