dsh-visualkit 0.0.0-stage → 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (283) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/LICENSE +26 -0
  3. package/README.md +427 -2
  4. package/THIRD_PARTY.md +55 -0
  5. package/cordis.patch.yml +29 -0
  6. package/data/assets/README.md +38 -0
  7. package/data/assets/icons/brands/bilibili.svg +1 -0
  8. package/data/assets/icons/brands/discord.svg +1 -0
  9. package/data/assets/icons/brands/neteasecloudmusic.svg +1 -0
  10. package/data/assets/icons/brands/qq.svg +1 -0
  11. package/data/assets/icons/brands/sinaweibo.svg +1 -0
  12. package/data/assets/icons/brands/steam.svg +1 -0
  13. package/data/assets/icons/brands/telegram.svg +1 -0
  14. package/data/assets/icons/brands/tiktok.svg +1 -0
  15. package/data/assets/icons/brands/twitch.svg +1 -0
  16. package/data/assets/icons/brands/wechat.svg +1 -0
  17. package/data/assets/icons/brands/x.svg +1 -0
  18. package/data/assets/icons/brands/youtube.svg +1 -0
  19. package/data/assets/icons/lucide/bell.svg +1 -0
  20. package/data/assets/icons/lucide/calendar.svg +1 -0
  21. package/data/assets/icons/lucide/clock.svg +1 -0
  22. package/data/assets/icons/lucide/gamepad-2.svg +1 -0
  23. package/data/assets/icons/lucide/gift.svg +1 -0
  24. package/data/assets/icons/lucide/heart.svg +1 -0
  25. package/data/assets/icons/lucide/mic.svg +1 -0
  26. package/data/assets/icons/lucide/play.svg +1 -0
  27. package/data/assets/icons/lucide/star.svg +1 -0
  28. package/data/assets/icons/lucide/video.svg +1 -0
  29. package/data/assets/icons/phosphor/LICENSE +21 -0
  30. package/data/assets/icons/phosphor/atom-duotone.svg +1 -0
  31. package/data/assets/icons/phosphor/binary-duotone.svg +1 -0
  32. package/data/assets/icons/phosphor/blueprint-duotone.svg +1 -0
  33. package/data/assets/icons/phosphor/circuitry-duotone.svg +1 -0
  34. package/data/assets/icons/phosphor/cloud-arrow-up-duotone.svg +1 -0
  35. package/data/assets/icons/phosphor/code-duotone.svg +1 -0
  36. package/data/assets/icons/phosphor/compass-duotone.svg +1 -0
  37. package/data/assets/icons/phosphor/cpu-duotone.svg +1 -0
  38. package/data/assets/icons/phosphor/cube-duotone.svg +1 -0
  39. package/data/assets/icons/phosphor/database-duotone.svg +1 -0
  40. package/data/assets/icons/phosphor/flask-duotone.svg +1 -0
  41. package/data/assets/icons/phosphor/frame-corners-duotone.svg +1 -0
  42. package/data/assets/icons/phosphor/function-duotone.svg +1 -0
  43. package/data/assets/icons/phosphor/hard-drives-duotone.svg +1 -0
  44. package/data/assets/icons/phosphor/hexagon-duotone.svg +1 -0
  45. package/data/assets/icons/phosphor/infinity-duotone.svg +1 -0
  46. package/data/assets/icons/phosphor/lightning-duotone.svg +1 -0
  47. package/data/assets/icons/phosphor/magic-wand-duotone.svg +1 -0
  48. package/data/assets/icons/phosphor/moon-stars-duotone.svg +1 -0
  49. package/data/assets/icons/phosphor/paint-brush-duotone.svg +1 -0
  50. package/data/assets/icons/phosphor/pen-nib-duotone.svg +1 -0
  51. package/data/assets/icons/phosphor/planet-duotone.svg +1 -0
  52. package/data/assets/icons/phosphor/rocket-launch-duotone.svg +1 -0
  53. package/data/assets/icons/phosphor/ruler-duotone.svg +1 -0
  54. package/data/assets/icons/phosphor/selection-plus-duotone.svg +1 -0
  55. package/data/assets/icons/phosphor/shapes-duotone.svg +1 -0
  56. package/data/assets/icons/phosphor/sparkle-duotone.svg +1 -0
  57. package/data/assets/icons/phosphor/sun-horizon-duotone.svg +1 -0
  58. package/data/assets/icons/phosphor/terminal-window-duotone.svg +1 -0
  59. package/data/assets/icons/phosphor/triangle-duotone.svg +1 -0
  60. package/data/assets/icons/phosphor/waveform-duotone.svg +1 -0
  61. package/data/assets/icons/tabler/alarm.svg +1 -0
  62. package/data/assets/icons/tabler/badge-ad.svg +1 -0
  63. package/data/assets/icons/tabler/bell.svg +1 -0
  64. package/data/assets/icons/tabler/bolt.svg +1 -0
  65. package/data/assets/icons/tabler/brand-bilibili.svg +1 -0
  66. package/data/assets/icons/tabler/brand-discord.svg +1 -0
  67. package/data/assets/icons/tabler/brand-qq.svg +1 -0
  68. package/data/assets/icons/tabler/brand-twitch.svg +1 -0
  69. package/data/assets/icons/tabler/brand-wechat.svg +1 -0
  70. package/data/assets/icons/tabler/brand-weibo.svg +1 -0
  71. package/data/assets/icons/tabler/brand-youtube.svg +1 -0
  72. package/data/assets/icons/tabler/broadcast.svg +1 -0
  73. package/data/assets/icons/tabler/calendar-event.svg +1 -0
  74. package/data/assets/icons/tabler/calendar-week.svg +1 -0
  75. package/data/assets/icons/tabler/clock-hour-3.svg +1 -0
  76. package/data/assets/icons/tabler/coin.svg +1 -0
  77. package/data/assets/icons/tabler/crown.svg +1 -0
  78. package/data/assets/icons/tabler/currency-yen.svg +1 -0
  79. package/data/assets/icons/tabler/device-gamepad-2.svg +1 -0
  80. package/data/assets/icons/tabler/diamond.svg +1 -0
  81. package/data/assets/icons/tabler/flame.svg +1 -0
  82. package/data/assets/icons/tabler/gift.svg +1 -0
  83. package/data/assets/icons/tabler/headphones.svg +1 -0
  84. package/data/assets/icons/tabler/heart-filled.svg +1 -0
  85. package/data/assets/icons/tabler/heart.svg +1 -0
  86. package/data/assets/icons/tabler/live-view.svg +1 -0
  87. package/data/assets/icons/tabler/microphone.svg +1 -0
  88. package/data/assets/icons/tabler/mood-smile.svg +1 -0
  89. package/data/assets/icons/tabler/mood-tongue.svg +1 -0
  90. package/data/assets/icons/tabler/movie.svg +1 -0
  91. package/data/assets/icons/tabler/player-play.svg +1 -0
  92. package/data/assets/icons/tabler/playlist.svg +1 -0
  93. package/data/assets/icons/tabler/rocket.svg +1 -0
  94. package/data/assets/icons/tabler/sparkles.svg +1 -0
  95. package/data/assets/icons/tabler/star-filled.svg +1 -0
  96. package/data/assets/icons/tabler/star.svg +1 -0
  97. package/data/assets/icons/tabler/tag.svg +1 -0
  98. package/data/assets/icons/tabler/thumb-up.svg +1 -0
  99. package/data/assets/icons/tabler/ticket.svg +1 -0
  100. package/data/assets/icons/tabler/video.svg +1 -0
  101. package/data/assets/illustrations/openpeeps/LICENSE +8 -0
  102. package/data/assets/illustrations/openpeeps/peep-10.svg +29 -0
  103. package/data/assets/illustrations/openpeeps/peep-100.svg +32 -0
  104. package/data/assets/illustrations/openpeeps/peep-101.svg +25 -0
  105. package/data/assets/illustrations/openpeeps/peep-102.svg +28 -0
  106. package/data/assets/illustrations/openpeeps/peep-103.svg +32 -0
  107. package/data/assets/illustrations/openpeeps/peep-105.svg +25 -0
  108. package/data/assets/illustrations/openpeeps/peep-12.svg +29 -0
  109. package/data/assets/illustrations/openpeeps/peep-13.svg +27 -0
  110. package/data/assets/illustrations/openpeeps/peep-16.svg +27 -0
  111. package/data/assets/illustrations/openpeeps/peep-17.svg +31 -0
  112. package/data/assets/illustrations/openpeeps/peep-22.svg +29 -0
  113. package/data/assets/illustrations/openpeeps/peep-25.svg +29 -0
  114. package/data/assets/illustrations/openpeeps/peep-26.svg +29 -0
  115. package/data/assets/illustrations/openpeeps/peep-27.svg +29 -0
  116. package/data/assets/illustrations/openpeeps/peep-30.svg +29 -0
  117. package/data/assets/illustrations/openpeeps/peep-31.svg +27 -0
  118. package/data/assets/illustrations/openpeeps/peep-34.svg +29 -0
  119. package/data/assets/illustrations/openpeeps/peep-38.svg +29 -0
  120. package/data/assets/illustrations/openpeeps/peep-39.svg +31 -0
  121. package/data/assets/illustrations/openpeeps/peep-4.svg +29 -0
  122. package/data/assets/illustrations/openpeeps/peep-41.svg +29 -0
  123. package/data/assets/illustrations/openpeeps/peep-44.svg +29 -0
  124. package/data/assets/illustrations/openpeeps/peep-47.svg +26 -0
  125. package/data/assets/illustrations/openpeeps/peep-48.svg +29 -0
  126. package/data/assets/illustrations/openpeeps/peep-50.svg +31 -0
  127. package/data/assets/illustrations/openpeeps/peep-51.svg +29 -0
  128. package/data/assets/illustrations/openpeeps/peep-53.svg +27 -0
  129. package/data/assets/illustrations/openpeeps/peep-54.svg +26 -0
  130. package/data/assets/illustrations/openpeeps/peep-55.svg +29 -0
  131. package/data/assets/illustrations/openpeeps/peep-56.svg +27 -0
  132. package/data/assets/illustrations/openpeeps/peep-58.svg +29 -0
  133. package/data/assets/illustrations/openpeeps/peep-61.svg +27 -0
  134. package/data/assets/illustrations/openpeeps/peep-64.svg +27 -0
  135. package/data/assets/illustrations/openpeeps/peep-68.svg +27 -0
  136. package/data/assets/illustrations/openpeeps/peep-69.svg +27 -0
  137. package/data/assets/illustrations/openpeeps/peep-7.svg +31 -0
  138. package/data/assets/illustrations/openpeeps/peep-72.svg +29 -0
  139. package/data/assets/illustrations/openpeeps/peep-76.svg +31 -0
  140. package/data/assets/illustrations/openpeeps/peep-77.svg +27 -0
  141. package/data/assets/illustrations/openpeeps/peep-80.svg +27 -0
  142. package/data/assets/illustrations/openpeeps/peep-81.svg +29 -0
  143. package/data/assets/illustrations/openpeeps/peep-83.svg +29 -0
  144. package/data/assets/manifest.json +1 -0
  145. package/data/assets/parts//342/206/223/346/212/225/345/275/261.png +0 -0
  146. package/data/assets/parts//344/270/211/350/247/222/345/217/221/345/244/2711.png +0 -0
  147. package/data/assets/parts//344/270/211/350/247/222/345/217/221/345/244/2712.png +0 -0
  148. package/data/assets/parts//344/270/211/350/247/222/345/217/221/345/244/2713.png +0 -0
  149. package/data/assets/parts//344/270/211/350/247/222/345/217/221/345/244/2714.png +0 -0
  150. package/data/assets/parts//345/217/214/351/251/254/345/260/276.png +0 -0
  151. package/data/assets/parts//345/217/214/351/251/254/345/260/2761.png +0 -0
  152. package/data/assets/parts//345/217/2211.png +0 -0
  153. package/data/assets/parts//345/217/2212.png +0 -0
  154. package/data/assets/parts//345/217/2213.png +0 -0
  155. package/data/assets/parts//345/217/221/345/234/210.png +0 -0
  156. package/data/assets/parts//345/217/221/345/244/2711.png +0 -0
  157. package/data/assets/parts//345/217/221/345/244/2712.png +0 -0
  158. package/data/assets/parts//345/217/243/346/260/264/345/267/276/345/211/215.png +0 -0
  159. package/data/assets/parts//345/217/243/346/260/264/345/267/276/345/220/216.png +0 -0
  160. package/data/assets/parts//345/217/263/344/270/212/347/234/274/347/235/221.png +0 -0
  161. package/data/assets/parts//345/217/263/346/211/213.png +0 -0
  162. package/data/assets/parts//345/217/263/346/211/2131.png +0 -0
  163. package/data/assets/parts//345/217/263/347/234/274/347/220/203.png +0 -0
  164. package/data/assets/parts//345/217/263/347/234/274/347/231/275.png +0 -0
  165. package/data/assets/parts//345/217/263/350/205/277.png +0 -0
  166. package/data/assets/parts//345/221/206/346/257/233.png +0 -0
  167. package/data/assets/parts//345/230/264/345/267/264.png +0 -0
  168. package/data/assets/parts//345/260/276/345/267/264.png +0 -0
  169. package/data/assets/parts//345/267/246/344/270/212/347/234/274/347/235/221.png +0 -0
  170. package/data/assets/parts//345/267/246/345/217/214/347/234/274/347/232/256.png +0 -0
  171. package/data/assets/parts//345/267/246/346/211/213.png +0 -0
  172. package/data/assets/parts//345/267/246/346/211/2131.png +0 -0
  173. package/data/assets/parts//345/267/246/347/234/274/347/220/203.png +0 -0
  174. package/data/assets/parts//345/267/246/347/234/274/347/231/275.png +0 -0
  175. package/data/assets/parts//345/267/246/350/205/277.png +0 -0
  176. package/data/assets/parts//346/212/225/345/275/261.png +0 -0
  177. package/data/assets/parts//347/234/211/346/257/233.png +0 -0
  178. package/data/assets/parts//347/234/274/347/275/251.png +0 -0
  179. package/data/assets/parts//350/200/263/346/234/2651.png +0 -0
  180. package/data/assets/parts//350/200/263/346/234/2652.png +0 -0
  181. package/data/assets/parts//350/204/221/345/243/263.png +0 -0
  182. package/data/assets/parts//350/241/243/346/234/215/345/211/215.png +0 -0
  183. package/data/assets/parts//350/241/243/346/234/215/345/220/216.png +0 -0
  184. package/data/assets/parts//350/241/243/346/234/215/351/273/221.png +0 -0
  185. package/data/assets/parts//350/241/243/346/234/215/351/273/2211.png +0 -0
  186. package/data/assets/parts//350/272/253/344/275/223.png +0 -0
  187. package/data/assets/parts//351/223/201/346/235/277.png +0 -0
  188. package/data/assets/parts//351/273/221/347/234/274/345/234/210.png +0 -0
  189. package/data/assets/parts//351/273/221/347/234/274/345/234/2102.png +0 -0
  190. package/data/assets/patterns/checker.svg +1 -0
  191. package/data/assets/patterns/circles-overlap.svg +1 -0
  192. package/data/assets/patterns/crosshatch.svg +1 -0
  193. package/data/assets/patterns/diagonal.svg +1 -0
  194. package/data/assets/patterns/dots.svg +1 -0
  195. package/data/assets/patterns/grid.svg +1 -0
  196. package/data/assets/patterns/halftone.svg +1 -0
  197. package/data/assets/patterns/plus.svg +1 -0
  198. package/data/assets/patterns/scales.svg +1 -0
  199. package/data/assets/patterns/triangles.svg +1 -0
  200. package/data/assets/patterns/waves.svg +1 -0
  201. package/data/assets/patterns/zigzag.svg +1 -0
  202. package/data/assets/stickers/openmoji/1F375.svg +12 -0
  203. package/data/assets/stickers/openmoji/1F9E8.svg +21 -0
  204. package/data/assets/stickers/twemoji/1f389.svg +1 -0
  205. package/data/assets/stickers/twemoji/1f3a4.svg +1 -0
  206. package/data/assets/stickers/twemoji/1f3a7.svg +1 -0
  207. package/data/assets/stickers/twemoji/1f3ae.svg +1 -0
  208. package/data/assets/stickers/twemoji/1f44b.svg +1 -0
  209. package/data/assets/stickers/twemoji/1f44d.svg +1 -0
  210. package/data/assets/stickers/twemoji/1f494.svg +1 -0
  211. package/data/assets/stickers/twemoji/1f4a2.svg +1 -0
  212. package/data/assets/stickers/twemoji/1f4a8.svg +1 -0
  213. package/data/assets/stickers/twemoji/1f4b5.svg +1 -0
  214. package/data/assets/stickers/twemoji/1f4d6.svg +1 -0
  215. package/data/assets/stickers/twemoji/1f525.svg +1 -0
  216. package/data/assets/stickers/twemoji/1f600.svg +1 -0
  217. package/data/assets/stickers/twemoji/1f602.svg +1 -0
  218. package/data/assets/stickers/twemoji/1f60d.svg +1 -0
  219. package/data/assets/stickers/twemoji/1f62d.svg +1 -0
  220. package/data/assets/stickers/twemoji/1f680.svg +1 -0
  221. package/data/assets/stickers/twemoji/1f979.svg +1 -0
  222. package/data/assets/stickers/twemoji/1f97a.svg +1 -0
  223. package/data/assets/stickers/twemoji/26a1.svg +1 -0
  224. package/data/assets/stickers/twemoji/2705.svg +1 -0
  225. package/data/assets/stickers/twemoji/274c.svg +1 -0
  226. package/data/assets/stickers/twemoji/2764.svg +1 -0
  227. package/data/assets/stickers/twemoji/2b50.svg +1 -0
  228. package/data/assets/stickers/twemoji_1f525.svg +1 -0
  229. package/data/assets/stickers/twemoji_1f600.svg +1 -0
  230. package/data/assets/stickers/twemoji_1f979.svg +1 -0
  231. package/data/assets/stickers/twemoji_2764.svg +1 -0
  232. package/data/assets/textures/grain-dark.png +0 -0
  233. package/data/assets/textures/grain-light.png +0 -0
  234. package/data/assets/textures/grain-warm.png +0 -0
  235. package/data/lucide-icons.json +1 -0
  236. package/data/packs/tabler.icons.json +1 -0
  237. package/icon.svg +26 -0
  238. package/lib/assets.js +228 -0
  239. package/lib/audit.js +284 -0
  240. package/lib/contrast-render.js +213 -0
  241. package/lib/css-rewrite.js +379 -0
  242. package/lib/detect.js +701 -0
  243. package/lib/diff.js +153 -0
  244. package/lib/emit.js +557 -0
  245. package/lib/html-context.js +315 -0
  246. package/lib/icon-render.js +107 -0
  247. package/lib/icondb.js +402 -0
  248. package/lib/image-normalize.js +140 -0
  249. package/lib/index.js +1289 -0
  250. package/lib/layouts/common.js +48 -0
  251. package/lib/layouts/image.js +1010 -0
  252. package/lib/layouts/index.js +10 -0
  253. package/lib/layouts/intent.js +66 -0
  254. package/lib/layouts/metrics-text.js +91 -0
  255. package/lib/layouts/png.js +75 -0
  256. package/lib/layouts/rules-contrast.js +575 -0
  257. package/lib/layouts/rules-punct.js +486 -0
  258. package/lib/layouts/rules-structure.js +182 -0
  259. package/lib/layouts/source.js +1961 -0
  260. package/lib/layouts/svg.js +1204 -0
  261. package/lib/morphcore.js +139 -0
  262. package/lib/preview.js +225 -0
  263. package/lib/render-html.js +255 -0
  264. package/lib/render.js +486 -0
  265. package/lib/rules.js +267 -0
  266. package/lib/shadow.js +83 -0
  267. package/lib/skip.js +56 -0
  268. package/lib/style.js +688 -0
  269. package/lib/svg.js +729 -0
  270. package/lib/taste-image.js +323 -0
  271. package/lib/taste.js +219 -0
  272. package/locale/en.json +6 -0
  273. package/locale/zh.json +6 -0
  274. package/package.json +92 -4
  275. package/vendor/morphicons-adapters.iife.js +903 -0
  276. package/vendor/morphicons.iife.js +2441 -0
  277. package/vendor/resvg/LICENSE +373 -0
  278. package/vendor/resvg/index.d.ts +98 -0
  279. package/vendor/resvg/index.js +23 -0
  280. package/vendor/resvg/js-binding.d.ts +52 -0
  281. package/vendor/resvg/js-binding.js +1 -0
  282. package/vendor/resvg/package.json +138 -0
  283. package/vendor/resvg/resvgjs.win32-x64-msvc.node +0 -0
package/icon.svg ADDED
@@ -0,0 +1,26 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="30" height="30" viewBox="0 0 256 256" fill="none" role="img" aria-label="视觉设计工具箱">
2
+ <defs>
3
+ <linearGradient id="dsh-pk" x1="28" y1="28" x2="228" y2="228" gradientUnits="userSpaceOnUse"><stop stop-color="#F43F5E"/><stop offset="1" stop-color="#DB2777"/></linearGradient>
4
+ <linearGradient id="dsh-fu" x1="48" y1="88" x2="136" y2="128" gradientUnits="userSpaceOnUse"><stop stop-color="#D946EF"/><stop offset="1" stop-color="#C026D3"/></linearGradient>
5
+ <linearGradient id="dsh-vi" x1="142" y1="70" x2="208" y2="158" gradientUnits="userSpaceOnUse"><stop stop-color="#A855F7"/><stop offset="1" stop-color="#7C3AED"/></linearGradient>
6
+ </defs>
7
+ <rect x="28" y="28" width="200" height="200" rx="46" fill="none" stroke="url(#dsh-pk)" stroke-width="12"/>
8
+ <rect x="48" y="48" width="106" height="14" rx="7" fill="url(#dsh-pk)"/>
9
+ <rect x="48" y="70" width="62" height="7" rx="3.5" fill="#EC4899"/>
10
+ <rect x="48" y="88" width="78" height="6" rx="3" fill="url(#dsh-fu)"/>
11
+ <rect x="48" y="104" width="66" height="6" rx="3" fill="url(#dsh-fu)"/>
12
+ <rect x="48" y="120" width="52" height="6" rx="3" fill="url(#dsh-fu)"/>
13
+ <rect x="142" y="70" width="66" height="88" rx="10" fill="url(#dsh-vi)"/>
14
+ <circle cx="167" cy="93" r="6.5" fill="#FDE68A"/>
15
+ <path d="M158 140l11-15 7 9 5-6 8 12z" fill="#EDE9FE"/>
16
+ <g fill="#F5F3FF">
17
+ <rect x="148" y="76" width="8" height="8" rx="2"/>
18
+ <rect x="194" y="76" width="8" height="8" rx="2"/>
19
+ <rect x="148" y="144" width="8" height="8" rx="2"/>
20
+ <rect x="194" y="144" width="8" height="8" rx="2"/>
21
+ </g>
22
+ <rect x="48" y="168" width="22" height="22" rx="7" fill="#EC4899"/>
23
+ <rect x="76" y="168" width="22" height="22" rx="7" fill="#D946EF"/>
24
+ <rect x="104" y="168" width="22" height="22" rx="7" fill="#A855F7"/>
25
+ <rect x="132" y="168" width="22" height="22" rx="7" fill="#D97706"/>
26
+ </svg>
package/lib/assets.js ADDED
@@ -0,0 +1,228 @@
1
+ /**
2
+ * assets.js — design_render action=asset 的实现:内置素材库检索 / 内联 / 改色。
3
+ *
4
+ * 素材在插件包内(data/assets,5.8MB / 221 项,已去掉缩略图)。
5
+ * 不依赖外部素材工作区的绝对路径——这是「独立插件」的关键一步。
6
+ *
7
+ * 安全:所有路径都经 resolve 后校验是否仍在素材目录内,挡目录穿越。
8
+ */
9
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
10
+ import { existsSync, statSync } from "node:fs";
11
+ import { dirname, extname, join, resolve, sep } from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+
14
+ const ASSETS_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "data", "assets");
15
+ const MANIFEST = join(ASSETS_DIR, "manifest.json");
16
+
17
+ let cache = null;
18
+
19
+ async function loadManifest() {
20
+ if (cache !== null) return cache;
21
+ let raw;
22
+ try {
23
+ raw = await readFile(MANIFEST, "utf8");
24
+ } catch (error) {
25
+ throw new Error(`素材 manifest 不可读:${MANIFEST}(${error?.message ?? error})`);
26
+ }
27
+ const parsed = JSON.parse(raw);
28
+ cache = Array.isArray(parsed.items) ? parsed.items : [];
29
+ return cache;
30
+ }
31
+
32
+ /** 关键词检索:name / category / desc / file 四处子串匹配,名字命中优先。 */
33
+ async function find(keyword, limit = 20) {
34
+ const items = await loadManifest();
35
+ const query = String(keyword ?? "").trim().toLowerCase();
36
+ if (query === "") return { total: items.length, items: items.slice(0, limit) };
37
+ // 中文检索词的口语同义词:用户说「模式」指图案、「底纹」指纹理,「模式」直搜 total:0
38
+ // 会让人以为素材库里没有图案。只做小范围、无歧义的设计词汇映射。
39
+ const SYNONYMS = {
40
+ 模式: ["图案"],
41
+ 纹样: ["图案"],
42
+ 花纹: ["图案"],
43
+ 底纹: ["纹理"],
44
+ 标志: ["品牌图标"],
45
+ logo: ["品牌图标"],
46
+ avatar: ["角色部件"],
47
+ 插图: ["插画"]
48
+ };
49
+ const variants = [...new Set([query, ...(SYNONYMS[query] ?? []).map((v) => String(v).toLowerCase())])];
50
+ const scored = [];
51
+ for (const item of items) {
52
+ const name = String(item.name ?? "").toLowerCase();
53
+ const category = String(item.category ?? "").toLowerCase();
54
+ const desc = String(item.desc ?? "").toLowerCase();
55
+ const file = String(item.file ?? "").toLowerCase();
56
+ let score = Infinity;
57
+ for (const q of variants) {
58
+ let s = Infinity;
59
+ if (name === q) s = 0;
60
+ else if (name.startsWith(q)) s = 1;
61
+ else if (name.includes(q)) s = 2;
62
+ else if (category.includes(q)) s = 3;
63
+ else if (desc.includes(q)) s = 4;
64
+ else if (file.includes(q)) s = 5;
65
+ if (s < score) score = s;
66
+ if (score === 0) break;
67
+ }
68
+ if (score !== Infinity) scored.push({ score, item });
69
+ }
70
+ scored.sort((a, b) => a.score - b.score || String(a.item.name).localeCompare(String(b.item.name)));
71
+ return { total: scored.length, items: scored.slice(0, limit).map((s) => s.item) };
72
+ }
73
+
74
+ /** 把素材库相对路径解析成绝对路径,越界即拒。 */
75
+ function resolveInside(rel) {
76
+ const wanted = String(rel ?? "").trim();
77
+ if (wanted === "") throw new Error("rel is required(素材库相对路径,如 icons/brands/github.svg)");
78
+ const full = resolve(ASSETS_DIR, wanted);
79
+ if (full !== ASSETS_DIR && !full.startsWith(ASSETS_DIR + sep)) {
80
+ throw new Error(`路径越界:${wanted} 不在素材库内`);
81
+ }
82
+ if (!existsSync(full)) throw new Error(`素材不存在:${wanted}(用 action=asset mode=find 检索)`);
83
+ return full;
84
+ }
85
+
86
+ /** inline/save 只对矢量素材有意义:位图(.png)读成 utf8 是乱码,
87
+ * 还会伪造一个 viewBox 冒充 SVG(实测 48 个 png 素材都会中招)。 */
88
+ function resolveSvg(rel) {
89
+ const file = resolveInside(rel);
90
+ let isDir = false;
91
+ try {
92
+ isDir = statSync(file).isDirectory();
93
+ } catch {
94
+ isDir = false;
95
+ }
96
+ if (isDir) throw new Error(`${rel} 是一个目录:inline/save 需要具体文件路径(先 mode=find 检索)`);
97
+ if (extname(file).toLowerCase() !== ".svg") {
98
+ throw new Error(`${rel} 不是 SVG(${extname(file) || "无扩展名"}):inline/save 只处理矢量素材;位图请直接引用素材库路径`);
99
+ }
100
+ return file;
101
+ }
102
+
103
+ /**
104
+ * 改色:三层处理,覆盖素材库里实际存在的三种写法。
105
+ * 1. currentColor → 直接替换(我们自己的图标都走这个)
106
+ * 2. fill/stroke 具体色值 → 替换值,不动结构(不误伤 <path d> 里的坐标)
107
+ * 3. 图形根本没有 fill/stroke(simple-icons 的 12 个品牌图就这么写,靠浏览器默认黑填充)
108
+ * → 必须把颜色下推到每个图形元素;只给根 <svg> 加 fill 会在 inline 剥壳后丢失
109
+ */
110
+ const COLOR_RE = /^[\w#(),.%+\- ]+$/;
111
+
112
+ function recolor(svg, color) {
113
+ if (color === undefined || color === null || String(color).trim() === "") return svg;
114
+ const wanted = String(color).trim();
115
+ // 颜色会被拼进 SVG 属性:不校验就只能任由 `red"/><script>` 注入新标签
116
+ // (inline 结果会内联进页面,save 产物会被直接打开)。
117
+ if (!COLOR_RE.test(wanted)) {
118
+ throw new Error(`color 含不允许的字符(收到 "${wanted}")——仅接受 #hex / rgb() / 命名色等颜色写法`);
119
+ }
120
+ let out = svg;
121
+ if (out.includes("currentColor")) {
122
+ out = out.split("currentColor").join(wanted);
123
+ }
124
+ out = out.replace(/(fill|stroke)\s*=\s*"([^"]*)"/gi, (whole, attr, value) => {
125
+ const v = String(value).trim().toLowerCase();
126
+ if (v === "none" || v === "transparent" || v === "" || v.startsWith("url(")) return whole;
127
+ return `${attr}="${wanted}"`;
128
+ });
129
+ const SHAPE = /<(path|circle|rect|ellipse|polygon|polyline)\b((?:"[^"]*"|[^>"])*)>/g;
130
+ const hasPaint = /<(path|circle|rect|ellipse|polygon|polyline)\b[^>]*\b(fill|stroke)\s*=/.test(out);
131
+ if (!hasPaint) {
132
+ out = out.replace(SHAPE, (whole, tag, attrs) => (/(^|\s)(fill|stroke)\s*=/.test(attrs) ? whole : `<${tag}${attrs} fill="${wanted}">`));
133
+ }
134
+ return out;
135
+ }
136
+
137
+ /** 剥掉 <svg> 外壳,取内部内容并计算 viewBox。
138
+ * 同时去掉 <title>/<desc>:inline 的用途是嵌进外层 SVG,外层通常自带可达性标签,
139
+ * 留着会在最终页面里出现重复标题。 */
140
+ function innerOf(svg) {
141
+ const open = svg.match(/<svg\b[^>]*>/i);
142
+ if (open === null) return { inner: svg.trim(), viewBox: undefined };
143
+ const close = svg.toLowerCase().lastIndexOf("</svg>");
144
+ const start = svg.indexOf(open[0]) + open[0].length;
145
+ const end = close === -1 ? svg.length : close;
146
+ const vb = open[0].match(/viewBox\s*=\s*"([^"]*)"/i);
147
+ const inner = svg
148
+ .slice(start, end)
149
+ .replace(/<title\b[^>]*>[\s\S]*?<\/title>/gi, "")
150
+ .replace(/<desc\b[^>]*>[\s\S]*?<\/desc>/gi, "")
151
+ .trim();
152
+ return { inner, viewBox: vb ? vb[1] : undefined };
153
+ }
154
+
155
+ /**
156
+ * action=asset:
157
+ * mode=find 检索(默认)
158
+ * mode=inline 返回内联内容(可改色),用于嵌进外层 SVG —— 不嵌 <svg> 壳
159
+ * mode=save 整篇改色后落盘,返回输出路径
160
+ */
161
+ async function asset({ mode = "find", keyword, rel, color, saveTo, limit }) {
162
+ if (mode === "find") {
163
+ const result = await find(keyword, limit ?? 20);
164
+ return { mode, keyword: keyword ?? "", total: result.total, items: result.items };
165
+ }
166
+ if (mode === "inline") {
167
+ const file = resolveSvg(rel);
168
+ const svg = recolor(await readFile(file, "utf8"), color);
169
+ const { inner, viewBox } = innerOf(svg);
170
+ const item = (await loadManifest()).find((entry) => entry.file === String(rel).split(sep).join("/"));
171
+ // 内联内容直接进模型上下文:大图(如 openpeeps 插画 65KB)会吃掉上万 token。
172
+ // 截断必须在元素边界切(lastIndexOf "/>"),否则返回半截标签根本不能用;
173
+ // 同时 note 必须如实说「已截断」——旧实现只置 truncated 标记却返回全文,是谎报。
174
+ const INLINE_LIMIT = 20000;
175
+ let shown = inner;
176
+ let truncated = false;
177
+ if (inner.length > INLINE_LIMIT) {
178
+ // 优先切在自闭合元素结束处("/>");openpeeps 那种 `<path …></path>` 写法
179
+ // 前两万字符里一个 "/>" 都没有,再退到任意标签结束的 ">" 上——总之不能切在
180
+ // 属性值中间(否则返回半截 path 数据,note 声称“到元素边界”就是谎报)。
181
+ const slash = inner.lastIndexOf("/>", INLINE_LIMIT - 2);
182
+ let cutEnd = slash > 0 ? slash + 2 : -1;
183
+ if (cutEnd < 0) {
184
+ const gt = inner.lastIndexOf(">", INLINE_LIMIT - 1);
185
+ cutEnd = gt > 0 ? gt + 1 : -1;
186
+ }
187
+ shown = cutEnd > 0 ? inner.slice(0, cutEnd) : inner.slice(0, INLINE_LIMIT);
188
+ truncated = true;
189
+ }
190
+ return {
191
+ mode,
192
+ rel,
193
+ ...(item !== undefined ? { name: item.name, license: item.license, category: item.category } : {}),
194
+ viewBox: viewBox ?? "0 0 24 24",
195
+ inner: shown,
196
+ truncated,
197
+ bytes: Buffer.byteLength(svg, "utf8"),
198
+ ...(truncated
199
+ ? { note: `内联内容 ${inner.length} 字符超过 ${INLINE_LIMIT},已截断到标签边界(可能是部分元素树,仅供预览);需要完整文件请用 mode=save 落盘` }
200
+ : {})
201
+ };
202
+ }
203
+ if (mode === "save") {
204
+ if (typeof saveTo !== "string" || saveTo.trim() === "") throw new Error("mode=save 需要 saveTo");
205
+ const file = resolveSvg(rel);
206
+ const svg = recolor(await readFile(file, "utf8"), color);
207
+ await mkdir(dirname(saveTo), { recursive: true });
208
+ await writeFile(saveTo, svg, "utf8");
209
+ return { mode, rel, path: saveTo, bytes: Buffer.byteLength(svg, "utf8"), color: color ?? null };
210
+ }
211
+ throw new Error(`unknown mode "${mode}"(find / inline / save)`);
212
+ }
213
+
214
+ /** 素材库概况(供工具描述与自检用)。 */
215
+ async function summary() {
216
+ const items = await loadManifest();
217
+ const categories = new Map();
218
+ for (const item of items) {
219
+ const key = item.category ?? "未分类";
220
+ categories.set(key, (categories.get(key) ?? 0) + 1);
221
+ }
222
+ return {
223
+ count: items.length,
224
+ categories: [...categories.entries()].map(([name, count]) => ({ name, count }))
225
+ };
226
+ }
227
+
228
+ export { asset, find, innerOf, recolor, summary };
package/lib/audit.js ADDED
@@ -0,0 +1,284 @@
1
+ /**
2
+ * audit.js — ui_audit 的实现核心:按项目预设巡检图标规范。
3
+ *
4
+ * echo 预设(规则出处 frontend/STYLE_GUIDE.md §3):
5
+ * E1 emoji 禁用:src 下 vue/js/ts/html 文本扫描 pictographic 区段,违例即报
6
+ * E2 静态图标误用 MorphIcon:<MorphIcon :icon=...> 绑定里没有条件表达式的报 review
7
+ * colibri 预设(规则出处 界面设计规范 §12.5 + js/ui.js):
8
+ * C1 ICONS 字典自检:每条 inner 必须能被 parseIconNodes 解析
9
+ * C2 引用核对:模板 data-morph="x" / JS morphTo(el,'x') / UI.svg('x') /
10
+ * UI.morphIcon('x') 的字面量引用必须存在于字典
11
+ * C3 动态引用(变量传名)报 info,提示人工核对
12
+ */
13
+ import { open, readFile, readdir, stat } from "node:fs/promises";
14
+ import { existsSync, readFileSync } from "node:fs";
15
+ import { basename, join, relative, resolve } from "node:path";
16
+ import { parseIconNodes } from "./svg.js";
17
+
18
+ // 测试/样本目录不参与巡检:硬规则("图标一律 SVG")不能因为仓库里放了带 emoji 的
19
+ // fixture 就报错——实测把本包自己扫出 55 个 error,全是 test/ 里故意写的样本。
20
+ const SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build", "vendor", ".uilab-previews", "coverage", "test", "tests", "__tests__", "fixtures", "spec"]);
21
+ const PER_FILE_CAP = 2 * 1024 * 1024;
22
+ // 审查修复 #278:逐文件串行读盘 → 按批并发读取(各文件相互独立;批内逐文件
23
+ // 处理、截断与 break 语义不变——最多多读一小批文件,readCapped 无副作用)
24
+ const READ_CHUNK = 16;
25
+
26
+ /** pictographic emoji + 符号区段(含变体选择符),排除普通箭头/中文标点。
27
+ * codereview #480:区域指示符区段 \u{1F1E6}-\u{1F1FF} 已被 \u{1F000}-\u{1FAFF}
28
+ * 完全覆盖(1F1E6 ≥ 1F000 且 1F1FF ≤ 1FAFF),原并列写出是冗余残留,已删。 */
29
+ const EMOJI_RE = /[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}\u{2B00}-\u{2BFF}\u{FE0F}]/u;
30
+
31
+ async function walk(root, { maxFiles = 4000, exts, subdirs }) {
32
+ const out = [];
33
+ // 单文件 root:taste_check 的参数描述是「File or directory」,旧实现直接 readdir
34
+ // 一个文件 → ENOTDIR 被 catch 吞掉 → 返回 0 个文件(巡检静默失效)。
35
+ try {
36
+ const info = await stat(root);
37
+ if (info.isFile()) {
38
+ const okExt = exts.some((x) => root.endsWith(x)) && !root.endsWith(".min.js") && !root.endsWith(".map");
39
+ return okExt ? [root] : [];
40
+ }
41
+ } catch {
42
+ return out;
43
+ }
44
+ const queue = [];
45
+ const roots = subdirs ? subdirs.map((d) => join(root, d)) : [root];
46
+ for (const r of roots) if (existsSync(r)) queue.push(r);
47
+ while (queue.length > 0 && out.length < maxFiles) {
48
+ const dir = queue.shift();
49
+ let entries;
50
+ try {
51
+ entries = await readdir(dir, { withFileTypes: true });
52
+ } catch {
53
+ continue;
54
+ }
55
+ for (const e of entries) {
56
+ const full = join(dir, e.name);
57
+ if (e.isDirectory()) {
58
+ if (!SKIP_DIRS.has(e.name) && !e.name.startsWith(".")) queue.push(full);
59
+ } else if (e.isFile() && exts.some((x) => e.name.endsWith(x)) && !e.name.endsWith(".min.js") && !e.name.endsWith(".map")) {
60
+ out.push(full);
61
+ if (out.length >= maxFiles) break;
62
+ }
63
+ }
64
+ }
65
+ return out;
66
+ }
67
+
68
+ async function readCapped(file) {
69
+ try {
70
+ const s = await stat(file);
71
+ if (!s.isFile()) return null;
72
+ if (s.size <= PER_FILE_CAP) return await readFile(file, "utf8");
73
+ // 超过上限的文件不能直接跳过:它仍会被算进 filesScanned,跳过等于“报了扫描数却没真扫”。
74
+ // 读前 PER_FILE_CAP 字节继续扫(构建产物/压缩包常是大文件,前面的 emoji 同样违规)。
75
+ const handle = await open(file, "r");
76
+ try {
77
+ const buf = Buffer.alloc(PER_FILE_CAP);
78
+ const { bytesRead } = await handle.read(buf, 0, PER_FILE_CAP, 0);
79
+ return buf.subarray(0, bytesRead).toString("utf8");
80
+ } finally {
81
+ await handle.close();
82
+ }
83
+ } catch {
84
+ return null;
85
+ }
86
+ }
87
+
88
+ function lineOf(text, index) {
89
+ return text.slice(0, index).split("\n").length;
90
+ }
91
+
92
+ /** root 本身就是被扫文件时,relative(root, root) 是空串——报告里会变成 "U1 :1"。
93
+ * 退回文件名,单文件巡检才能看出是哪个文件。 */
94
+ function relOf(root, file) {
95
+ const rel = relative(root, file);
96
+ return rel === "" ? basename(root) : rel;
97
+ }
98
+
99
+ async function auditEcho(root, limits) {
100
+ const findings = [];
101
+ // Echo 项目根可能是仓库根(frontend/ 子目录)或前端根(直接含 src/)
102
+ const base = existsSync(join(root, "frontend")) ? join(root, "frontend") : root;
103
+ const files = await walk(base, { maxFiles: limits.maxFiles, exts: [".vue", ".js", ".ts", ".html"], subdirs: ["src"] });
104
+ if (files.length === 0) findings.push({ severity: "warn", rule: "E0", file: relative(root, base) || ".", line: 0, message: "src/ 下没有扫到 vue/js/ts/html 文件——确认 root 是否为 Echo 项目根目录(frontend 之下应含 src/)" });
105
+ for (let i = 0; i < files.length; i += READ_CHUNK) {
106
+ const batch = files.slice(i, i + READ_CHUNK);
107
+ const texts = await Promise.all(batch.map((f) => readCapped(f)));
108
+ for (let k = 0; k < batch.length; k += 1) {
109
+ const text = texts[k];
110
+ if (text === null) continue;
111
+ const rel = relOf(root, batch[k]);
112
+ // E1 emoji 禁用
113
+ for (const m of text.matchAll(new RegExp(EMOJI_RE.source, "gu"))) {
114
+ const idx = m.index ?? 0;
115
+ findings.push({ severity: "error", rule: "E1", file: rel, line: lineOf(text, idx), message: `emoji "${m[0]}" —— STYLE_GUIDE §3 全面禁用(图标全部 SVG)` });
116
+ if (findings.length > limits.maxFindings) break;
117
+ }
118
+ // E2 MorphIcon 静态装载(review 级:绑定是否两态由人判断)
119
+ // 审查修复 #276:标签匹配跳过引号段(绑定表达式含 > 的箭头函数不再
120
+ // 提前截断标签);:icon 兼容单/双引号;两态判定收紧为成对三元(? … :)
121
+ // ——可选链 a?.b、取反 !x 不再被当作两态条件而漏报
122
+ for (const m of text.matchAll(/<MorphIcon\b(?:"[^"]*"|'[^']*'|[^>"'])*>/gs)) {
123
+ const tag = m[0].replace(/\s+/g, " ");
124
+ const icon = tag.match(/:icon\s*=\s*(["'])((?:(?!\1)[\s\S])*)\1/);
125
+ // 先剔除可选链 `?.` 与空值合并 `??`,再要求“? … :”成对出现:
126
+ // 否则 :icon="props?.icon" 这类静态绑定会被错误当成两态(E2 误报)。
127
+ const iconExpr = icon ? icon[2].replace(/\?\./g, "").replace(/\?\?/g, "") : "";
128
+ if (icon && !/[^?]\?[^?:]*:/.test(iconExpr)) {
129
+ findings.push({ severity: "warn", rule: "E2", file: rel, line: lineOf(text, m.index ?? 0), message: `<MorphIcon :icon="${icon[2]}"> 绑定无两态条件——静态图标不得用 MorphIcon 装载(走 n-icon + vicons)` });
130
+ }
131
+ }
132
+ }
133
+ if (findings.length > limits.maxFindings * 2) break;
134
+ }
135
+ return { preset: "echo", root, filesScanned: files.length, findings };
136
+ }
137
+
138
+ function extractColibriDict(uiJsPath) {
139
+ // 审查修复 #277:readFileSync 兜底——existsSync 与读取之间的竞态、EACCES、
140
+ // EMFILE 不再裸抛出 audit()(与 readCapped/walk 的防御口径一致,失败走 C0 路径)
141
+ let text;
142
+ try {
143
+ text = readFileSync(uiJsPath, "utf8");
144
+ } catch {
145
+ return { dict: null };
146
+ }
147
+ const block = text.match(/const ICONS = \{([\s\S]*?)\n\s*\};/);
148
+ if (block === null) return { dict: null };
149
+ const dict = {};
150
+ // 审查修复 #279:条目值兼容单/双引号(双引号条目原先被静默丢弃,其引用会
151
+ // 成串误报 C2「不在字典」);键锚定行首(允许缩进)——降低匹配到值内部
152
+ // xxx:'yyy' 片段注入假条目的风险
153
+ const re = /^[ \t]*(\w+)\s*:\s*(["'])((?:\\.|(?!\2)[^\\])*)\2/gm;
154
+ for (const m of block[1].matchAll(re)) dict[m[1]] = m[3];
155
+ return { dict };
156
+ }
157
+
158
+ async function auditColibri(root, limits) {
159
+ const findings = [];
160
+ const uiJsPath = join(root, "js", "ui.js");
161
+ if (!existsSync(uiJsPath)) {
162
+ return { preset: "colibri", root, filesScanned: 0, findings: [{ severity: "error", rule: "C0", file: "js/ui.js", line: 0, message: "js/ui.js 不存在——确认 root 是否为 Colibri 项目根目录" }] };
163
+ }
164
+ const { dict } = extractColibriDict(uiJsPath);
165
+ if (dict === null) {
166
+ return { preset: "colibri", root, filesScanned: 0, findings: [{ severity: "error", rule: "C0", file: "js/ui.js", line: 0, message: "ICONS 字典解析失败(读取失败或结构变化?)" }] };
167
+ }
168
+ // C1 字典自检
169
+ for (const [name, inner] of Object.entries(dict)) {
170
+ if (parseIconNodes(inner) === null) {
171
+ findings.push({ severity: "error", rule: "C1", file: "js/ui.js", line: 0, message: `ICONS.${name} 无法解析为 morphicons IconNode(含 g/transform/未识别标签或为空)——该图标运行时会降级静态换图` });
172
+ }
173
+ }
174
+ // C2/C3 引用核对:html 模板 + js(ui.js 本身只扫 morphTo 字面量以外的实现无关引用,跳过)
175
+ const files = await walk(root, { maxFiles: limits.maxFiles, exts: [".html", ".js"] });
176
+ let scanned = 0;
177
+ for (let i = 0; i < files.length; i += READ_CHUNK) {
178
+ const batch = files.slice(i, i + READ_CHUNK);
179
+ const texts = await Promise.all(batch.map((f) => readCapped(f)));
180
+ for (let k = 0; k < batch.length; k += 1) {
181
+ const file = batch[k];
182
+ const rel = relOf(root, file);
183
+ if (rel === join("js", "ui.js")) continue;
184
+ const text = texts[k];
185
+ if (text === null) continue;
186
+ scanned += 1;
187
+ // 审查修复 #279:data-morph 兼容单/双引号属性;
188
+ // 审查修复 #275:morphTo 首参允许一层嵌套括号(querySelector(...)
189
+ // 形态原先整条静默漏检,C2/C3 两个模式同步修改)
190
+ const patterns = [
191
+ { re: /data-morph=["'](\w+)["']/g, how: "data-morph" },
192
+ { re: /\bmorphTo\(\s*[^,()]*(?:\([^()]*\)[^,()]*)*,\s*['"](\w+)['"]/g, how: "morphTo" },
193
+ { re: /\bsvg\(\s*['"](\w+)['"]/g, how: "UI.svg" },
194
+ { re: /\bmorphIcon\(\s*['"](\w+)['"]/g, how: "UI.morphIcon" }
195
+ ];
196
+ for (const { re, how } of patterns) {
197
+ for (const m of text.matchAll(re)) {
198
+ const name = m[1];
199
+ if (name === "true" || name === "false") continue;
200
+ // 不要用 `name in dict`:dict 是普通对象字面量,'constructor'/'toString'/'__proto__'
201
+ // 这类原型键会误判为“图标存在”,把真实的悬空引用吞掉。
202
+ if (!Object.prototype.hasOwnProperty.call(dict, name)) {
203
+ findings.push({ severity: "error", rule: "C2", file: rel, line: lineOf(text, m.index ?? 0), message: `${how} 引用 "${name}" 不在 ICONS 字典——运行时图标为空(svg)或降级换图(morphTo)` });
204
+ }
205
+ }
206
+ }
207
+ // C3 动态引用提示
208
+ // codereview #479:捕获组 [A-Za-z_$][\w$]* 只可能匹配标识符字符、永不含引号,
209
+ // 原先的 /['"]/.test(m[1]) 守卫恒为 false(死代码,误导读者以为存在引号路径),已删。
210
+ for (const m of text.matchAll(/\bmorphTo\(\s*[^,()]*(?:\([^()]*\)[^,()]*)*,\s*([A-Za-z_$][\w$]*)\s*\)/g)) {
211
+ findings.push({ severity: "info", rule: "C3", file: rel, line: lineOf(text, m.index ?? 0), message: `morphTo 第二参为变量 "${m[1]}"——静态核对不可达,请人工确认取值都在字典内` });
212
+ }
213
+ }
214
+ if (findings.length > limits.maxFindings * 3) break;
215
+ }
216
+ return { preset: "colibri", root, filesScanned: scanned + 1, dictSize: Object.keys(dict).length, findings };
217
+ }
218
+
219
+ function detectPreset(root) {
220
+ if (existsSync(join(root, "frontend", "STYLE_GUIDE.md")) || existsSync(join(root, "STYLE_GUIDE.md"))) return "echo";
221
+ if (existsSync(join(root, "js", "ui.js"))) return "colibri";
222
+ return undefined;
223
+ }
224
+
225
+ /**
226
+ * universal 预设(2026-10-01 新增):任意项目的 emoji 图标扫描。
227
+ * 背景:AI 开发时习惯性用 emoji 做图标(owner 级禁令:图标一律 SVG)。
228
+ * 规则 U1:代码/模板文件中的 emoji 全部报 error(与 echo 预设同严格度,
229
+ * 扫描面更宽——不限 src/,覆盖首次开发项目的任意目录结构)。
230
+ */
231
+ const UNIVERSAL_EXTS = [".vue", ".js", ".mjs", ".ts", ".html", ".htm", ".jsx", ".tsx", ".svelte"];
232
+
233
+ async function auditUniversal(root, limits) {
234
+ const findings = [];
235
+ const files = await walk(root, { maxFiles: limits.maxFiles, exts: UNIVERSAL_EXTS });
236
+ let emojiTotal = 0;
237
+ for (let i = 0; i < files.length; i += READ_CHUNK) {
238
+ const batch = files.slice(i, i + READ_CHUNK);
239
+ const texts = await Promise.all(batch.map((f) => readCapped(f)));
240
+ for (let k = 0; k < batch.length; k += 1) {
241
+ const text = texts[k];
242
+ if (text === null) continue;
243
+ const rel = relOf(root, batch[k]);
244
+ for (const m of text.matchAll(new RegExp(EMOJI_RE.source, "gu"))) {
245
+ emojiTotal += 1;
246
+ if (findings.length < limits.maxFindings) {
247
+ findings.push({
248
+ severity: "error",
249
+ rule: "U1",
250
+ file: rel,
251
+ line: lineOf(text, m.index ?? 0),
252
+ message: `emoji "${m[0]}" —— 图标一律用 SVG(icon action=find 选型 + action=emit 出码),emoji 做图标全面禁止`
253
+ });
254
+ }
255
+ }
256
+ }
257
+ }
258
+ // 审查修复 #273:预截断使 findings 永不超上限——截断标记改按 emojiTotal 声明,
259
+ // audit() 优先取 runner 给出的标记(否则 universal 分支 truncated 恒为 false)
260
+ return { preset: "universal", root, filesScanned: files.length, emojiTotal, truncated: emojiTotal > limits.maxFindings, findings };
261
+ }
262
+
263
+ /**
264
+ * 巡检入口。preset: 'echo' | 'colibri' | 'universal' | 'auto'(默认)。
265
+ * auto 按标记文件判定 echo/colibri;判定不出(如首次开发的新项目)时
266
+ * 自动落到 universal——emoji 禁令是全局规则,不依赖项目标记。
267
+ */
268
+ async function audit(root, preset, limits = {}) {
269
+ const abs = resolve(root);
270
+ const lim = { maxFiles: limits.maxFiles ?? 4000, maxFindings: limits.maxFindings ?? 60 };
271
+ if (!existsSync(abs)) throw new Error(`root 不存在:${abs}`);
272
+ const chosen = preset === "auto" || preset === undefined ? (detectPreset(abs) ?? "universal") : preset;
273
+ const runner = chosen === "echo" ? auditEcho : chosen === "colibri" ? auditColibri : auditUniversal;
274
+ const result = await runner(abs, lim);
275
+ // 审查修复 #274:先截断再统计——counts 与返回的 findings 口径一致
276
+ //(echo/colibri 内部可积累到上限的 2~3 倍才中断,旧写法按未截断全量统计,
277
+ // 会出现 counts.error=119 而返回 findings 只有 60 条的自相矛盾)
278
+ const shown = result.findings.slice(0, lim.maxFindings);
279
+ const counts = { error: 0, warn: 0, info: 0 };
280
+ for (const f of shown) counts[f.severity] = (counts[f.severity] ?? 0) + 1;
281
+ return { ...result, counts, truncated: result.truncated ?? result.findings.length > lim.maxFindings, findings: shown };
282
+ }
283
+
284
+ export { audit, detectPreset };