@birdapi/velinstyle 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (313) hide show
  1. package/README.de.md +135 -246
  2. package/README.md +134 -245
  3. package/cli/blueprints/onboarding.html +2 -2
  4. package/cli/cli-manifest.json +97 -0
  5. package/cli/docgen/extract-a11y.js +89 -0
  6. package/cli/docgen/extract-attributes.js +95 -0
  7. package/cli/docgen/extract-cli.js +50 -0
  8. package/cli/docgen/extract-components.js +175 -0
  9. package/cli/docgen/extract-meta.js +32 -0
  10. package/cli/docgen/extract-rules.js +76 -0
  11. package/cli/docgen/extract-tokens.js +79 -0
  12. package/cli/docgen/extract-utilities.js +75 -0
  13. package/cli/docgen/markdown.js +43 -0
  14. package/cli/docs-generate.js +196 -0
  15. package/cli/index.js +121 -5
  16. package/cli/meta.js +97 -0
  17. package/cli/perf-audit.js +174 -0
  18. package/cli/pii-scanner.js +134 -0
  19. package/cli/scanner-rules-data.js +62 -0
  20. package/cli/scanner.js +68 -1
  21. package/cli/search-index.js +163 -0
  22. package/cli/tokens-build.js +21 -1
  23. package/cli/tokens-validate.js +69 -0
  24. package/components/a11y-entry.js +42 -0
  25. package/components/a11y-utils.js +71 -0
  26. package/components/focus-manager.js +45 -8
  27. package/components/index.js +22 -12
  28. package/components/runtime/component-loaders.js +43 -0
  29. package/components/runtime/index.js +77 -0
  30. package/components/runtime-entry.js +10 -0
  31. package/components/sanitize.js +135 -54
  32. package/components/velin-accordion.js +12 -0
  33. package/components/velin-bottom-nav.js +19 -0
  34. package/components/velin-carousel.js +2 -2
  35. package/components/velin-code-block.js +142 -0
  36. package/components/velin-command.js +15 -0
  37. package/components/velin-copy.js +18 -5
  38. package/components/velin-counter.js +3 -0
  39. package/components/velin-dialog.js +9 -1
  40. package/components/velin-email.js +94 -0
  41. package/components/velin-icon.js +65 -22
  42. package/components/velin-lightbox.js +8 -3
  43. package/components/velin-live-dot.js +5 -0
  44. package/components/velin-modal.js +6 -3
  45. package/components/velin-popover.js +34 -2
  46. package/components/velin-reveal.js +7 -65
  47. package/components/velin-scroll-top.js +1 -1
  48. package/components/velin-search.js +267 -0
  49. package/components/velin-secure-field.js +99 -0
  50. package/components/velin-sparkline.js +27 -7
  51. package/components/{velin-stepper-wc.js → velin-stepper.js} +37 -8
  52. package/components/velin-theme-toggle.js +12 -5
  53. package/components/velin-toast.js +4 -0
  54. package/components/{velin-tooltip-wc.js → velin-tooltip.js} +22 -3
  55. package/core/a11y/component-contracts.json +221 -0
  56. package/core/attributes/index.js +6 -0
  57. package/core/attributes/registry.js +268 -0
  58. package/core/highlight/highlight.js +114 -0
  59. package/core/highlight/index.js +29 -0
  60. package/core/highlight/languages/_utils.js +52 -0
  61. package/core/highlight/languages/blade.js +16 -0
  62. package/core/highlight/languages/css.js +26 -0
  63. package/core/highlight/languages/html.js +17 -0
  64. package/core/highlight/languages/js.js +58 -0
  65. package/core/highlight/languages/json.js +14 -0
  66. package/core/highlight/languages/markdown.js +16 -0
  67. package/core/highlight/languages/php.js +39 -0
  68. package/core/highlight/languages/plain.js +7 -0
  69. package/core/highlight/languages/shell.js +33 -0
  70. package/core/highlight/languages/sql.js +20 -0
  71. package/core/highlight/languages/typescript.js +28 -0
  72. package/core/highlight/observe.js +53 -0
  73. package/core/highlight/registry.js +107 -0
  74. package/core/highlight/render.js +35 -0
  75. package/core/highlight/types.js +28 -0
  76. package/core/meta/build.js +293 -0
  77. package/core/meta/index.js +17 -0
  78. package/core/meta/schema.js +49 -0
  79. package/core/motion/effects.js +54 -0
  80. package/core/motion/index.js +76 -0
  81. package/core/motion/scheduler.js +72 -0
  82. package/core/motion/scroll.js +32 -0
  83. package/core/motion/stagger.js +24 -0
  84. package/core/search/docs-url.js +96 -0
  85. package/core/search/engine.js +139 -0
  86. package/core/search/highlight.js +48 -0
  87. package/core/search/index.js +126 -0
  88. package/core/search/providers.js +36 -0
  89. package/core/search/types.js +61 -0
  90. package/core/search/worker-client.js +56 -0
  91. package/core/search/worker.js +20 -0
  92. package/dist/chunks/a11y-utils-IRC7LOPF.js +16 -0
  93. package/dist/chunks/attributes-3BZLI7XF.js +364 -0
  94. package/dist/chunks/attributes-4N5JP4CG.js +364 -0
  95. package/dist/chunks/attributes-5Q2WXGRC.js +374 -0
  96. package/dist/chunks/attributes-AIR7SVLK.js +364 -0
  97. package/dist/chunks/attributes-BGRN5XZO.js +364 -0
  98. package/dist/chunks/attributes-BVSQAYLR.js +364 -0
  99. package/dist/chunks/attributes-CDYFUY7Y.js +364 -0
  100. package/dist/chunks/attributes-JMYJZBPE.js +364 -0
  101. package/dist/chunks/attributes-R5JY3X4O.js +374 -0
  102. package/dist/chunks/attributes-STYCS3O5.js +391 -0
  103. package/dist/chunks/attributes-TNSPQTHX.js +374 -0
  104. package/dist/chunks/attributes-TPANLMLF.js +391 -0
  105. package/dist/chunks/blade-LIQLVKZQ.js +19 -0
  106. package/dist/chunks/browser-MRKYZPEH.js +1149 -0
  107. package/dist/chunks/chunk-2354NEBP.js +109 -0
  108. package/dist/chunks/chunk-2VNA7G7S.js +109 -0
  109. package/dist/chunks/chunk-42VVPW6N.js +132 -0
  110. package/dist/chunks/chunk-7FFAMRFV.js +44 -0
  111. package/dist/chunks/chunk-7MCGPHVC.js +69 -0
  112. package/dist/chunks/chunk-AQLRJSGG.js +109 -0
  113. package/dist/chunks/chunk-EWPSDMQT.js +109 -0
  114. package/dist/chunks/chunk-F7OLHTGX.js +22 -0
  115. package/dist/chunks/chunk-J472ERN2.js +242 -0
  116. package/dist/chunks/chunk-LA7264K4.js +85 -0
  117. package/dist/chunks/chunk-M5CHQVP6.js +61 -0
  118. package/dist/chunks/chunk-MKZP2TBH.js +89 -0
  119. package/dist/chunks/chunk-QTJJ3DPO.js +109 -0
  120. package/dist/chunks/chunk-RDE3QE7Z.js +89 -0
  121. package/dist/chunks/chunk-RSMRR27R.js +109 -0
  122. package/dist/chunks/chunk-RZQVWFHW.js +109 -0
  123. package/dist/chunks/chunk-SRL6EWHP.js +109 -0
  124. package/dist/chunks/chunk-U4XHMZ7D.js +109 -0
  125. package/dist/chunks/chunk-V5YIUA6G.js +109 -0
  126. package/dist/chunks/chunk-XIET2VSZ.js +109 -0
  127. package/dist/chunks/chunk-XYLJ2GCB.js +205 -0
  128. package/dist/chunks/chunk-Z4JYQ2XX.js +51 -0
  129. package/dist/chunks/chunk-ZPSPKUYD.js +109 -0
  130. package/dist/chunks/css-MQ5YU77P.js +28 -0
  131. package/dist/chunks/highlight-GJHHV4KM.js +37 -0
  132. package/dist/chunks/highlight-XTHRKBA3.js +37 -0
  133. package/dist/chunks/html-ZODC4DLQ.js +20 -0
  134. package/dist/chunks/js-MNSNYTJU.js +7 -0
  135. package/dist/chunks/json-VEM2HFH3.js +17 -0
  136. package/dist/chunks/markdown-E7OPGS3S.js +19 -0
  137. package/dist/chunks/php-MLL7T3LT.js +41 -0
  138. package/dist/chunks/plain-LJJDC5TN.js +8 -0
  139. package/dist/chunks/runtime-entry.js +14 -0
  140. package/dist/chunks/shell-QTRK4U4E.js +35 -0
  141. package/dist/chunks/sql-PPWLHGJ2.js +23 -0
  142. package/dist/chunks/typescript-JT3CCYWL.js +30 -0
  143. package/dist/chunks/velin-accordion-FBIPAEXG.js +105 -0
  144. package/dist/chunks/velin-accordion-QAQO7BI7.js +117 -0
  145. package/dist/chunks/velin-announcer-PVTXXCWR.js +33 -0
  146. package/dist/chunks/velin-bottom-nav-2N2TYWOF.js +86 -0
  147. package/dist/chunks/velin-bottom-nav-7GEMPIM6.js +86 -0
  148. package/dist/chunks/velin-bottom-nav-CTU6FFZV.js +101 -0
  149. package/dist/chunks/velin-bottom-nav-N3DFQR66.js +86 -0
  150. package/dist/chunks/velin-carousel-Y5VCOABZ.js +213 -0
  151. package/dist/chunks/velin-carousel-ZTBANFQQ.js +213 -0
  152. package/dist/chunks/velin-code-block-ASUBTAQV.js +119 -0
  153. package/dist/chunks/velin-code-block-CIAURDRJ.js +132 -0
  154. package/dist/chunks/velin-code-block-TSUKPY4C.js +123 -0
  155. package/dist/chunks/velin-code-block-Y6CM726Q.js +119 -0
  156. package/dist/chunks/velin-collapse-5XYBUIID.js +80 -0
  157. package/dist/chunks/velin-combobox-2KDTCQ7N.js +155 -0
  158. package/dist/chunks/velin-combobox-NXS4QB4R.js +155 -0
  159. package/dist/chunks/velin-combobox-UGA56HP3.js +155 -0
  160. package/dist/chunks/velin-command-CKDNO4BW.js +148 -0
  161. package/dist/chunks/velin-command-M7RNHUPR.js +136 -0
  162. package/dist/chunks/velin-command-ONKO2A3J.js +136 -0
  163. package/dist/chunks/velin-command-TMLSRGIU.js +136 -0
  164. package/dist/chunks/velin-copy-G2TZ5YBB.js +81 -0
  165. package/dist/chunks/velin-copy-NYDBOKXT.js +81 -0
  166. package/dist/chunks/velin-copy-T5KDTDXP.js +81 -0
  167. package/dist/chunks/velin-countdown-4ZVDGK4R.js +101 -0
  168. package/dist/chunks/velin-countdown-5XSTN3BH.js +101 -0
  169. package/dist/chunks/velin-countdown-7AIY66CU.js +101 -0
  170. package/dist/chunks/velin-counter-ISCTEAHI.js +118 -0
  171. package/dist/chunks/velin-counter-L5FYJF7A.js +121 -0
  172. package/dist/chunks/velin-dialog-D2GUHSCD.js +144 -0
  173. package/dist/chunks/velin-dialog-ELFOZIXZ.js +152 -0
  174. package/dist/chunks/velin-dialog-GTTZI564.js +147 -0
  175. package/dist/chunks/velin-dialog-IJLFDXAJ.js +144 -0
  176. package/dist/chunks/velin-dialog-TNS3WXMB.js +144 -0
  177. package/dist/chunks/velin-drawer-BE6TYN3H.js +130 -0
  178. package/dist/chunks/velin-drawer-DLL2GLT6.js +130 -0
  179. package/dist/chunks/velin-drawer-MJS6WCF7.js +130 -0
  180. package/dist/chunks/velin-dropdown-PLUIFA5M.js +172 -0
  181. package/dist/chunks/velin-email-N5B5ZYTE.js +90 -0
  182. package/dist/chunks/velin-email-NTM6DG3V.js +90 -0
  183. package/dist/chunks/velin-email-RBX6ZR4S.js +90 -0
  184. package/dist/chunks/velin-haptic-4QQTM3RK.js +50 -0
  185. package/dist/chunks/velin-icon-7M76DOPB.js +164 -0
  186. package/dist/chunks/velin-icon-KZO3IEHG.js +164 -0
  187. package/dist/chunks/velin-icon-WTFPG2DD.js +191 -0
  188. package/dist/chunks/velin-icon-ZTSKUOVN.js +193 -0
  189. package/dist/chunks/velin-lightbox-6E7CAATP.js +149 -0
  190. package/dist/chunks/velin-lightbox-I75U6JF4.js +140 -0
  191. package/dist/chunks/velin-lightbox-QOTB4PRL.js +140 -0
  192. package/dist/chunks/velin-lightbox-UCD6N7HF.js +140 -0
  193. package/dist/chunks/velin-live-dot-RJHX2DGY.js +74 -0
  194. package/dist/chunks/velin-live-dot-XHUZCIDZ.js +67 -0
  195. package/dist/chunks/velin-menubar-I426AED2.js +86 -0
  196. package/dist/chunks/velin-menubar-J66JXRWW.js +86 -0
  197. package/dist/chunks/velin-menubar-SFEXJE56.js +86 -0
  198. package/dist/chunks/velin-modal-2HOBXE6F.js +176 -0
  199. package/dist/chunks/velin-modal-5HYNNNUI.js +176 -0
  200. package/dist/chunks/velin-modal-AEOTZ67O.js +177 -0
  201. package/dist/chunks/velin-modal-GLI2XZZB.js +176 -0
  202. package/dist/chunks/velin-persist-DFPL3QP4.js +110 -0
  203. package/dist/chunks/velin-popover-EXINRIGJ.js +145 -0
  204. package/dist/chunks/velin-popover-HQW6Q2Y6.js +172 -0
  205. package/dist/chunks/velin-popover-OXX7MRZ6.js +145 -0
  206. package/dist/chunks/velin-popover-T72HK3DF.js +151 -0
  207. package/dist/chunks/velin-popover-YKCCROJG.js +145 -0
  208. package/dist/chunks/velin-progress-ring-E4YRLZHH.js +66 -0
  209. package/dist/chunks/velin-rating-6CDBI6DG.js +91 -0
  210. package/dist/chunks/velin-rating-N4DI2XNL.js +91 -0
  211. package/dist/chunks/velin-rating-XRQJV4LO.js +91 -0
  212. package/dist/chunks/velin-scroll-top-F2FCZNYD.js +58 -0
  213. package/dist/chunks/velin-scroll-top-OHYVVHHY.js +58 -0
  214. package/dist/chunks/velin-scrollspy-OKCT4FDV.js +50 -0
  215. package/dist/chunks/velin-search-2XRQFBCS.js +556 -0
  216. package/dist/chunks/velin-search-4YHJWYSS.js +556 -0
  217. package/dist/chunks/velin-search-7B4KJ6TX.js +557 -0
  218. package/dist/chunks/velin-search-LWHY23Q5.js +474 -0
  219. package/dist/chunks/velin-secure-field-6PV7RZDJ.js +95 -0
  220. package/dist/chunks/velin-secure-field-H7FSBUJW.js +95 -0
  221. package/dist/chunks/velin-secure-field-OFNAXCDP.js +95 -0
  222. package/dist/chunks/velin-segmented-control-EKL4E3BL.js +109 -0
  223. package/dist/chunks/velin-segmented-control-GYD5BIXQ.js +109 -0
  224. package/dist/chunks/velin-segmented-control-PQX6LKOJ.js +109 -0
  225. package/dist/chunks/velin-sheet-3OGI4HD7.js +119 -0
  226. package/dist/chunks/velin-sheet-OUK6572T.js +119 -0
  227. package/dist/chunks/velin-sheet-R2Y67X6H.js +119 -0
  228. package/dist/chunks/velin-sparkline-GVEANJVW.js +184 -0
  229. package/dist/chunks/velin-sparkline-KDA3GQAN.js +162 -0
  230. package/dist/chunks/velin-sparkline-XVTFC6GE.js +181 -0
  231. package/dist/chunks/velin-stepper-2ZIHLKGM.js +141 -0
  232. package/dist/chunks/velin-stepper-7ECRUYHD.js +148 -0
  233. package/dist/chunks/velin-stepper-FYEUEWE6.js +141 -0
  234. package/dist/chunks/velin-stepper-X54WGHDG.js +141 -0
  235. package/dist/chunks/velin-stepper-wc-QU3KRVQR.js +133 -0
  236. package/dist/chunks/velin-tabs-5UQ5ZVMV.js +138 -0
  237. package/dist/chunks/velin-theme-toggle-32J2CBNC.js +291 -0
  238. package/dist/chunks/velin-theme-toggle-BNNS7WLV.js +285 -0
  239. package/dist/chunks/velin-theme-toggle-NO7V4TWI.js +285 -0
  240. package/dist/chunks/velin-theme-toggle-QELCJM2Q.js +291 -0
  241. package/dist/chunks/velin-toast-OMMOA2DK.js +109 -0
  242. package/dist/chunks/velin-toast-RCZ4CSUC.js +109 -0
  243. package/dist/chunks/velin-toast-RSF2IHT3.js +113 -0
  244. package/dist/chunks/velin-toast-WEUBTJG6.js +109 -0
  245. package/dist/chunks/velin-tooltip-664IOHZA.js +112 -0
  246. package/dist/chunks/velin-tooltip-BDHGRTQB.js +112 -0
  247. package/dist/chunks/velin-tooltip-HVUOKNVX.js +112 -0
  248. package/dist/chunks/velin-tooltip-wc-LXDVT6DE.js +103 -0
  249. package/dist/chunks/worker-client-R6DBYPFT.js +46 -0
  250. package/dist/llms.test.txt +40 -0
  251. package/dist/llms.txt +40 -0
  252. package/dist/search-index.json +1773 -0
  253. package/dist/search-index.test.json +1773 -0
  254. package/dist/themes/brutalist.min.css +1 -1
  255. package/dist/themes/midnight.min.css +1 -1
  256. package/dist/themes/pastel.min.css +1 -1
  257. package/dist/themes/retro.min.css +1 -1
  258. package/dist/themes/sunset.min.css +1 -1
  259. package/dist/velin-agent.json +684 -0
  260. package/dist/velin-agent.test.json +684 -0
  261. package/dist/velinstyle-components.iife.js +7715 -3515
  262. package/dist/velinstyle-components.js +7087 -2859
  263. package/dist/velinstyle-components.min.js +196 -109
  264. package/dist/velinstyle.css +604 -65
  265. package/dist/velinstyle.d.ts +54 -0
  266. package/dist/velinstyle.min.css +1 -1
  267. package/examples/search-index.schema.json +33 -0
  268. package/package.json +56 -10
  269. package/src/a11y/authentication.css +37 -0
  270. package/src/a11y/consistent-help.css +22 -0
  271. package/src/a11y/dragging-alternatives.css +30 -0
  272. package/src/a11y/focus-appearance.css +20 -0
  273. package/src/a11y/high-contrast-aaa.css +16 -26
  274. package/src/a11y/skip-link.css +3 -1
  275. package/src/a11y/sr-only.css +1 -1
  276. package/src/base/reset.css +0 -36
  277. package/src/base/wc-placeholder.css +102 -0
  278. package/src/components/attributes.css +42 -0
  279. package/src/components/highlight.css +182 -0
  280. package/src/components/input-group.css +1 -2
  281. package/src/components/nav.css +2 -3
  282. package/src/components/search.css +95 -0
  283. package/src/components/stepper.css +2 -4
  284. package/src/components/timeline.css +1 -2
  285. package/src/components/tooltip.css +2 -4
  286. package/src/layout/container.css +5 -5
  287. package/src/layout/grid.css +8 -8
  288. package/src/layout/patterns.css +5 -5
  289. package/src/themes/brutalist.css +2 -11
  290. package/src/themes/corporate.css +0 -9
  291. package/src/themes/earth.css +0 -9
  292. package/src/themes/forest.css +0 -9
  293. package/src/themes/midnight.css +1 -10
  294. package/src/themes/neon.css +0 -9
  295. package/src/themes/nordic.css +0 -9
  296. package/src/themes/ocean.css +0 -9
  297. package/src/themes/pastel.css +1 -10
  298. package/src/themes/retro.css +2 -11
  299. package/src/themes/sharp.css +0 -7
  300. package/src/themes/soft.css +0 -7
  301. package/src/themes/sunset.css +1 -10
  302. package/src/tokens/color.css +45 -46
  303. package/src/tokens/fonts.css +10 -0
  304. package/src/tokens/typography.css +4 -5
  305. package/src/utilities/animation.css +2 -2
  306. package/src/utilities/display.css +3 -3
  307. package/src/utilities/divide.css +2 -6
  308. package/src/utilities/responsive.css +4 -4
  309. package/src/utilities/safe-area.css +1 -1
  310. package/src/utilities/scroll-animation.css +34 -0
  311. package/src/utilities/spacing.css +17 -8
  312. package/src/utilities/text.css +4 -1
  313. package/src/velinstyle.css +9 -0
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Performance audit for static HTML — images, scripts, inline styles.
3
+ */
4
+ import { existsSync, readFileSync, writeFileSync, readdirSync } from 'fs';
5
+ import { join, extname, relative } from 'path';
6
+
7
+ const DEFAULT_IGNORE = ['node_modules', 'dist', '.git', '.next', 'build'];
8
+
9
+ export function walkHtmlFiles(dir, ignore = DEFAULT_IGNORE) {
10
+ const results = [];
11
+ if (!existsSync(dir)) return results;
12
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
13
+ if (ignore.includes(entry.name)) continue;
14
+ const full = join(dir, entry.name);
15
+ if (entry.isDirectory()) results.push(...walkHtmlFiles(full, ignore));
16
+ else if (extname(entry.name).toLowerCase() === '.html') results.push(full);
17
+ }
18
+ return results;
19
+ }
20
+
21
+ function lineOf(content, index) {
22
+ return content.slice(0, index).split('\n').length;
23
+ }
24
+
25
+ /**
26
+ * @param {string} html
27
+ * @param {{ file?: string }} meta
28
+ */
29
+ export function auditHtml(html, meta = {}) {
30
+ const issues = [];
31
+ const file = meta.file || '';
32
+ const lines = html.split('\n');
33
+
34
+ lines.forEach((line, idx) => {
35
+ const ln = idx + 1;
36
+
37
+ const imgTags = [...line.matchAll(/<img\b[^>]*>/gi)];
38
+ for (const m of imgTags) {
39
+ const tag = m[0];
40
+ const hasW = /\bwidth\s*=/i.test(tag);
41
+ const hasH = /\bheight\s*=/i.test(tag);
42
+ const hasStyleDim = /style\s*=\s*["'][^"']*(?:width|height)\s*:/i.test(tag);
43
+ if (!hasW && !hasH && !hasStyleDim) {
44
+ issues.push({
45
+ id: 'img-missing-dimensions',
46
+ severity: 'warning',
47
+ message: '<img> without width/height causes layout shift (CLS).',
48
+ line: ln,
49
+ file,
50
+ fixable: true,
51
+ fix: (l) => l.replace(/<img\b/i, '<img width="800" height="450"'),
52
+ });
53
+ }
54
+ if (!/\bloading\s*=/i.test(tag) && !/\bfetchpriority\s*=\s*["']high["']/i.test(tag)) {
55
+ issues.push({
56
+ id: 'img-no-lazy',
57
+ severity: 'info',
58
+ message: 'Consider loading="lazy" for below-fold images.',
59
+ line: ln,
60
+ file,
61
+ fixable: true,
62
+ fix: (l) => l.replace(/<img\b/i, '<img loading="lazy"'),
63
+ });
64
+ }
65
+ }
66
+
67
+ const scriptTags = [...line.matchAll(/<script\b[^>]*>/gi)];
68
+ for (const m of scriptTags) {
69
+ const tag = m[0];
70
+ if (/\bsrc\s*=/i.test(tag) && !/\bdefer\b/i.test(tag) && !/\basync\b/i.test(tag) && !/\btype\s*=\s*["']module["']/i.test(tag)) {
71
+ issues.push({
72
+ id: 'script-no-defer',
73
+ severity: 'warning',
74
+ message: 'External <script> without defer/async blocks parsing.',
75
+ line: ln,
76
+ file,
77
+ fixable: true,
78
+ fix: (l) => l.replace(/<script\b/i, '<script defer'),
79
+ });
80
+ }
81
+ }
82
+
83
+ if (/\sstyle\s*=\s*["'][^"']{120,}["']/i.test(line)) {
84
+ issues.push({
85
+ id: 'large-inline-style',
86
+ severity: 'info',
87
+ message: 'Large inline style block. Prefer external CSS.',
88
+ line: ln,
89
+ file,
90
+ fixable: false,
91
+ });
92
+ }
93
+ });
94
+
95
+ if (/@font-face/i.test(html) && !/font-display\s*:\s*swap/i.test(html)) {
96
+ issues.push({
97
+ id: 'font-display-swap',
98
+ severity: 'info',
99
+ message: 'Add font-display: swap to @font-face rules.',
100
+ file,
101
+ line: 1,
102
+ fixable: false,
103
+ });
104
+ }
105
+
106
+ if (/velinstyle-components\.min\.js/i.test(html) && /velin-(?:modal|toast|tabs)/i.test(html) === false) {
107
+ issues.push({
108
+ id: 'unused-velin-import',
109
+ severity: 'info',
110
+ message: 'Full component bundle loaded. Consider @birdapi/velinstyle/runtime for tree-shaking.',
111
+ file,
112
+ line: 1,
113
+ fixable: false,
114
+ });
115
+ }
116
+
117
+ return issues;
118
+ }
119
+
120
+ export function auditPath(targetPath) {
121
+ const files = walkHtmlFiles(targetPath);
122
+ const issues = [];
123
+ for (const file of files) {
124
+ const html = readFileSync(file, 'utf-8');
125
+ issues.push(...auditHtml(html, { file }));
126
+ }
127
+ return { issues, files };
128
+ }
129
+
130
+ export function formatTextReport(issues, files) {
131
+ const lines = [`Performance audit — ${files.length} HTML file(s)\n`];
132
+ if (issues.length === 0) {
133
+ lines.push('No performance issues found.');
134
+ return lines.join('\n');
135
+ }
136
+ for (const i of issues) {
137
+ const loc = i.line ? `${relative(process.cwd(), i.file)}:${i.line}` : i.file;
138
+ lines.push(`[${i.severity}] ${i.id} — ${loc}`);
139
+ lines.push(` ${i.message}`);
140
+ }
141
+ return lines.join('\n');
142
+ }
143
+
144
+ export function fixPath(targetPath, { write = false, dryRun = true } = {}) {
145
+ const { issues } = auditPath(targetPath);
146
+ const fixable = issues.filter((i) => i.fixable && typeof i.fix === 'function');
147
+ const byFile = {};
148
+ for (const issue of fixable) {
149
+ if (!byFile[issue.file]) byFile[issue.file] = [];
150
+ byFile[issue.file].push(issue);
151
+ }
152
+ const changes = [];
153
+ for (const [file, fileIssues] of Object.entries(byFile)) {
154
+ const original = readFileSync(file, 'utf-8');
155
+ const lines = original.split('\n');
156
+ fileIssues.sort((a, b) => b.line - a.line);
157
+ const applied = [];
158
+ for (const issue of fileIssues) {
159
+ const idx = issue.line - 1;
160
+ if (idx < 0 || idx >= lines.length) continue;
161
+ const next = issue.fix(lines[idx]);
162
+ if (next !== lines[idx]) {
163
+ lines[idx] = next;
164
+ applied.push(issue.id);
165
+ }
166
+ }
167
+ const content = lines.join('\n');
168
+ if (content !== original) {
169
+ if (write && !dryRun) writeFileSync(file, content, 'utf-8');
170
+ changes.push({ file, changes: applied });
171
+ }
172
+ }
173
+ return { ok: true, changes, dryRun: dryRun && !write };
174
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * PII / secrets detection for velinstyle scan (--only pii).
3
+ */
4
+
5
+ const EMAIL_RE = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g;
6
+ const PLACEHOLDER_EMAILS = new Set([
7
+ 'user@example.com',
8
+ 'test@example.com',
9
+ 'admin@example.com',
10
+ 'email@example.com',
11
+ 'you@example.com',
12
+ 'name@example.com',
13
+ '{{email}}',
14
+ '{{EMAIL}}',
15
+ ]);
16
+
17
+ const SECRET_PATTERNS = [
18
+ { re: /\b(?:api[_-]?key|apikey)\s*[:=]\s*['"][^'"]{8,}['"]/i, rule: 'pii/hardcoded-secret', msg: 'Possible API key in source.' },
19
+ { re: /\b(?:secret|password|passwd|pwd)\s*[:=]\s*['"][^'"]{4,}['"]/i, rule: 'pii/hardcoded-secret', msg: 'Possible password/secret in source.' },
20
+ { re: /\bAKIA[0-9A-Z]{16}\b/, rule: 'pii/hardcoded-secret', msg: 'AWS access key pattern detected.' },
21
+ { re: /\bsk_(?:live|test)_[0-9a-zA-Z]{16,}\b/, rule: 'pii/hardcoded-secret', msg: 'Stripe secret key pattern detected.' },
22
+ { re: /\bghp_[0-9a-zA-Z]{36,}\b/, rule: 'pii/hardcoded-secret', msg: 'GitHub token pattern detected.' },
23
+ ];
24
+
25
+ export function isPlaceholderEmail(email) {
26
+ const lower = email.toLowerCase();
27
+ if (PLACEHOLDER_EMAILS.has(lower)) return true;
28
+ if (/@example\.(com|org|net)$/i.test(email)) return true;
29
+ if (/@test\.(com|local)$/i.test(email)) return true;
30
+ if (/@localhost$/i.test(email)) return true;
31
+ return false;
32
+ }
33
+
34
+ export function maskEmail(email, placeholder = 'user@example.com') {
35
+ return placeholder;
36
+ }
37
+
38
+ export function fixHardcodedEmailLine(line, placeholder = 'user@example.com') {
39
+ return line.replace(EMAIL_RE, (match) => {
40
+ if (isPlaceholderEmail(match)) return match;
41
+ return placeholder;
42
+ });
43
+ }
44
+
45
+ export function scanPIIHTML(content, file, options = {}) {
46
+ const issues = [];
47
+ const lines = content.split('\n');
48
+ const placeholder = options.fixEmailPlaceholder || 'user@example.com';
49
+
50
+ lines.forEach((line, idx) => {
51
+ const ln = idx + 1;
52
+ if (/\/\/|\/\*|\*\/|<!--/.test(line) && !/@/.test(line.replace(/<!--.*-->/, ''))) {
53
+ /* still scan — emails in comments are PII too */
54
+ }
55
+
56
+ const emails = [...line.matchAll(EMAIL_RE)].map((m) => m[0]);
57
+ for (const email of emails) {
58
+ if (isPlaceholderEmail(email)) continue;
59
+ issues.push({
60
+ file,
61
+ line: ln,
62
+ severity: 1,
63
+ rule: 'pii/hardcoded-email',
64
+ message: `Hardcoded email "${email}" found. Use env/config or <velin-email>.`,
65
+ fixable: true,
66
+ fix: (currentLine) => fixHardcodedEmailLine(currentLine, placeholder),
67
+ });
68
+ }
69
+
70
+ const hasMailto = /mailto:/i.test(line);
71
+ const emailRe = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/;
72
+ if (hasMailto && emailRe.test(line)) {
73
+ issues.push({
74
+ file,
75
+ line: ln,
76
+ severity: 2,
77
+ rule: 'pii/mailto-in-source',
78
+ message: 'mailto: link exposes email in HTML. Consider <velin-email> for obfuscation.',
79
+ fixable: false,
80
+ });
81
+ }
82
+ });
83
+
84
+ return issues;
85
+ }
86
+
87
+ export function scanPIIJS(content, file) {
88
+ const issues = [];
89
+ const lines = content.split('\n');
90
+
91
+ lines.forEach((line, idx) => {
92
+ const ln = idx + 1;
93
+
94
+ for (const { re, rule, msg } of SECRET_PATTERNS) {
95
+ if (re.test(line)) {
96
+ issues.push({
97
+ file,
98
+ line: ln,
99
+ severity: 0,
100
+ rule,
101
+ message: msg,
102
+ fixable: false,
103
+ });
104
+ }
105
+ }
106
+
107
+ const emails = [...line.matchAll(EMAIL_RE)].map((m) => m[0]);
108
+ for (const email of emails) {
109
+ if (isPlaceholderEmail(email)) continue;
110
+ issues.push({
111
+ file,
112
+ line: ln,
113
+ severity: 1,
114
+ rule: 'pii/hardcoded-email',
115
+ message: `Hardcoded email "${email}" in JavaScript.`,
116
+ fixable: true,
117
+ fix: (currentLine) => fixHardcodedEmailLine(currentLine),
118
+ });
119
+ }
120
+
121
+ if (/localStorage\.setItem\s*\([^)]*@/.test(line) || /sessionStorage\.setItem\s*\([^)]*@/.test(line)) {
122
+ issues.push({
123
+ file,
124
+ line: ln,
125
+ severity: 1,
126
+ rule: 'pii/localstorage-pii',
127
+ message: 'Storing email-like data in Web Storage may violate privacy policies.',
128
+ fixable: false,
129
+ });
130
+ }
131
+ });
132
+
133
+ return issues;
134
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Scanner rule metadata for documentation and tooling.
3
+ * Scan logic lives in scanner.js / pii-scanner.js / perf-audit.js.
4
+ */
5
+
6
+ /** @typedef {{ id: string, category: string, severity: string, message: string, fixHint?: string }} ScannerRuleMeta */
7
+
8
+ /** @type {ScannerRuleMeta[]} */
9
+ export const SCANNER_RULES = [
10
+ // Security — HTML
11
+ { id: 'security/no-inline-handler', category: 'security', severity: 'warning', message: 'Inline event handler found. Use addEventListener() instead.', fixHint: 'Move handler to JS module.' },
12
+ { id: 'security/no-javascript-url', category: 'security', severity: 'error', message: 'javascript: URL detected. XSS vector.', fixHint: 'Use button + script or href to safe URL.' },
13
+ { id: 'security/safe-external-link', category: 'security', severity: 'warning', message: 'target="_blank" without rel="noopener noreferrer".', fixHint: 'Add rel="noopener noreferrer" — auto-fix with --fix.' },
14
+ { id: 'security/no-meta-refresh', category: 'security', severity: 'error', message: '<meta http-equiv="refresh"> can redirect without consent.', fixHint: 'Use server redirect or JS with user action.' },
15
+ { id: 'security/no-inline-style', category: 'security', severity: 'warning', message: 'Inline style attribute increases XSS surface.', fixHint: 'Use VelinStyle utility classes.' },
16
+ { id: 'security/no-data-html-uri', category: 'security', severity: 'error', message: 'data:text/html URI can execute script.', fixHint: 'Avoid data: HTML URIs in href/src.' },
17
+ { id: 'security/dangerous-target', category: 'security', severity: 'warning', message: '<form target="_blank"> is unusual.', fixHint: 'Use same-tab navigation.' },
18
+ { id: 'security/integrity-missing', category: 'security', severity: 'info', message: 'External <script> without integrity (SRI).', fixHint: 'Add integrity + crossorigin for CDN scripts.' },
19
+ { id: 'security/csp-meta', category: 'security', severity: 'info', message: 'No CSP meta tag found.', fixHint: 'Add Content-Security-Policy header or meta.' },
20
+ // Security — JS
21
+ { id: 'security/no-raw-innerhtml', category: 'security', severity: 'warning', message: 'Direct innerHTML without sanitization.', fixHint: 'Use escapeHTML() from @birdapi/velinstyle/sanitize.' },
22
+ { id: 'security/no-document-write', category: 'security', severity: 'error', message: 'document.write() is dangerous.', fixHint: 'Use DOM APIs or template elements.' },
23
+ { id: 'security/no-eval', category: 'security', severity: 'error', message: 'eval() is a critical risk.', fixHint: 'Never use eval in production code.' },
24
+ { id: 'security/no-function-constructor', category: 'security', severity: 'error', message: 'new Function() equals eval().', fixHint: 'Refactor to static functions.' },
25
+ { id: 'security/postmessage-wildcard', category: 'security', severity: 'warning', message: 'postMessage with targetOrigin "*".', fixHint: 'Specify exact target origin.' },
26
+ // PII
27
+ { id: 'pii/hardcoded-email', category: 'pii', severity: 'warning', message: 'Hardcoded email in source.', fixHint: 'Use env/config or <velin-email>. --fix masks to placeholder.' },
28
+ { id: 'pii/mailto-in-source', category: 'pii', severity: 'info', message: 'mailto: exposes email in HTML.', fixHint: 'Consider <velin-email> obfuscation.' },
29
+ { id: 'pii/hardcoded-secret', category: 'pii', severity: 'error', message: 'Possible API key or secret in source.', fixHint: 'Use environment variables or secret manager.' },
30
+ { id: 'pii/localstorage-pii', category: 'pii', severity: 'warning', message: 'Email-like data in Web Storage.', fixHint: 'Avoid storing PII in localStorage.' },
31
+ // A11y
32
+ { id: 'a11y/html-lang', category: 'a11y', severity: 'error', message: '<html> missing lang attribute.', fixHint: 'Add lang="de" or appropriate locale — --fix.' },
33
+ { id: 'a11y/img-alt', category: 'a11y', severity: 'error', message: '<img> without alt.', fixHint: 'Add alt text or alt="" for decorative images.' },
34
+ { id: 'a11y/img-decorative', category: 'a11y', severity: 'warning', message: 'Decorative <img alt=""> without aria-hidden.', fixHint: 'Add aria-hidden="true" when visible text carries the meaning.' },
35
+ { id: 'a11y/velin-icon-label', category: 'a11y', severity: 'warning', message: 'velin-icon in icon-only button without label.', fixHint: 'Add label="…" on <velin-icon>.' },
36
+ { id: 'a11y/sparkline-label', category: 'a11y', severity: 'warning', message: 'velin-sparkline without accessible name.', fixHint: 'Add label attribute or wrap in <figure><figcaption>.' },
37
+ { id: 'a11y/skeleton-text', category: 'a11y', severity: 'warning', message: 'velin-skeleton on non-empty element.', fixHint: 'Use skeleton only on empty placeholders.' },
38
+ { id: 'a11y/button-label', category: 'a11y', severity: 'warning', message: '<button> without visible text or aria-label.', fixHint: 'Add text or aria-label.' },
39
+ { id: 'a11y/input-label', category: 'a11y', severity: 'warning', message: '<input> without label association.', fixHint: 'Use <label for> or aria-label.' },
40
+ { id: 'a11y/skip-link', category: 'a11y', severity: 'warning', message: 'No skip link for keyboard users.', fixHint: 'Add .velin-skip-link to #main — --fix when id="main" exists.' },
41
+ { id: 'a11y/landmark-main', category: 'a11y', severity: 'warning', message: 'No <main> landmark.', fixHint: 'Wrap primary content in <main>.' },
42
+ { id: 'a11y/heading-order', category: 'a11y', severity: 'warning', message: 'Heading levels skip (e.g. h2 → h4).', fixHint: 'Use sequential heading levels.' },
43
+ { id: 'a11y/interactive-aria-hidden', category: 'a11y', severity: 'error', message: 'Interactive element with aria-hidden="true".', fixHint: 'Remove aria-hidden or use inert.' },
44
+ { id: 'a11y/iframe-title', category: 'a11y', severity: 'error', message: '<iframe> without title.', fixHint: 'Add descriptive title attribute.' },
45
+ { id: 'a11y/autocomplete-auth', category: 'a11y', severity: 'warning', message: 'Auth field missing autocomplete (WCAG 2.2).', fixHint: 'Add autocomplete="username" or "current-password".' },
46
+ { id: 'a11y/invalid-describedby', category: 'a11y', severity: 'warning', message: 'aria-invalid without aria-describedby.', fixHint: 'Link to error message element id.' },
47
+ // CSS
48
+ { id: 'css/var-fallback', category: 'css', severity: 'info', message: 'CSS variable without fallback in var().', fixHint: 'Use var(--velin-*, fallback).' },
49
+ { id: 'css/z-index-token', category: 'css', severity: 'warning', message: 'Raw z-index instead of --velin-z-* token.', fixHint: 'Use tokens from z-index.css — --fix suggests token.' },
50
+ { id: 'css/no-important', category: 'css', severity: 'info', message: '!important usage.', fixHint: 'Prefer @layer and specificity.' },
51
+ { id: 'css/vendor-prefix', category: 'css', severity: 'info', message: 'Unnecessary vendor prefix.', fixHint: 'Lightning CSS handles autoprefixing.' },
52
+ ];
53
+
54
+ /** @type {ScannerRuleMeta[]} */
55
+ export const PERF_RULES = [
56
+ { id: 'perf/img-missing-dimensions', category: 'perf', severity: 'warning', message: '<img> without width/height causes CLS.', fixHint: 'Add width and height attributes — perf fix.' },
57
+ { id: 'perf/img-no-lazy', category: 'perf', severity: 'info', message: 'Consider loading="lazy" for below-fold images.', fixHint: 'Add loading="lazy".' },
58
+ { id: 'perf/script-no-defer', category: 'perf', severity: 'warning', message: 'External script without defer/async.', fixHint: 'Add defer or type="module".' },
59
+ { id: 'perf/large-inline-style', category: 'perf', severity: 'info', message: 'Large inline style block.', fixHint: 'Move to external CSS.' },
60
+ { id: 'perf/font-display-swap', category: 'perf', severity: 'info', message: '@font-face without font-display: swap.', fixHint: 'Add font-display: swap.' },
61
+ { id: 'perf/unused-velin-import', category: 'perf', severity: 'info', message: 'Full component bundle loaded.', fixHint: 'Use @birdapi/velinstyle/runtime for tree-shaking.' },
62
+ ];
package/cli/scanner.js CHANGED
@@ -1,6 +1,9 @@
1
1
  import { existsSync, readFileSync, readdirSync, writeFileSync } from 'fs';
2
2
  import { join, relative, extname } from 'path';
3
3
  import { applyFixes, fixSafeExternalLinkLine, fixZIndexLine } from './apply-fixes.js';
4
+ import { scanPIIHTML, scanPIIJS } from './pii-scanner.js';
5
+
6
+ export { SCANNER_RULES, PERF_RULES } from './scanner-rules-data.js';
4
7
 
5
8
  const SEVERITY = { error: 0, warning: 1, info: 2 };
6
9
  const SEVERITY_LABEL = { 0: 'ERROR', 1: 'WARNING', 2: 'INFO' };
@@ -197,8 +200,10 @@ function scanSecurityJS(content, file) {
197
200
 
198
201
  function issueCategory(rule) {
199
202
  if (rule.startsWith('security/')) return 'security';
203
+ if (rule.startsWith('pii/')) return 'pii';
200
204
  if (rule.startsWith('a11y/')) return 'a11y';
201
205
  if (rule.startsWith('css/')) return 'css';
206
+ if (rule.startsWith('perf/')) return 'perf';
202
207
  return 'other';
203
208
  }
204
209
 
@@ -222,13 +227,25 @@ function scanA11yHTML(content, file) {
222
227
 
223
228
  const imgMatches = line.matchAll(/<img\b[^>]*>/gi);
224
229
  for (const m of imgMatches) {
225
- if (!/\balt\s*=/i.test(m[0])) {
230
+ const tag = m[0];
231
+ if (!/\balt\s*=/i.test(tag)) {
226
232
  issues.push({
227
233
  file, line: ln, severity: 0,
228
234
  rule: 'a11y/img-alt',
229
235
  message: '<img> without alt attribute. Add alt="" for decorative or descriptive text.',
230
236
  fixable: false,
231
237
  });
238
+ } else if (
239
+ /\balt\s*=\s*["']\s*["']/i.test(tag)
240
+ && !/\baria-hidden\s*=\s*["']true["']/i.test(tag)
241
+ && !/\brole\s*=\s*["']presentation["']/i.test(tag)
242
+ ) {
243
+ issues.push({
244
+ file, line: ln, severity: 1,
245
+ rule: 'a11y/img-decorative',
246
+ message: 'Decorative <img alt=""> should include aria-hidden="true" so assistive tech ignores it consistently.',
247
+ fixable: false,
248
+ });
232
249
  }
233
250
  }
234
251
 
@@ -244,6 +261,31 @@ function scanA11yHTML(content, file) {
244
261
  }
245
262
  }
246
263
 
264
+ if (/<button\b[^>]*>[\s\S]*<velin-icon\b/i.test(line) && /<velin-icon\b(?![^>]*\blabel\s*=)/i.test(line)) {
265
+ issues.push({
266
+ file, line: ln, severity: 1,
267
+ rule: 'a11y/velin-icon-label',
268
+ message: '<velin-icon> inside icon-only <button> should have a label attribute.',
269
+ fixable: false,
270
+ });
271
+ }
272
+ if (/<velin-sparkline\b/i.test(line) && !/<velin-sparkline\b[^>]*\blabel\s*=/i.test(line) && !/<figcaption/i.test(line)) {
273
+ issues.push({
274
+ file, line: ln, severity: 1,
275
+ rule: 'a11y/sparkline-label',
276
+ message: '<velin-sparkline> needs label or a parent <figure> with <figcaption>.',
277
+ fixable: false,
278
+ });
279
+ }
280
+ if (/\bvelin-skeleton\b/i.test(line) && />\s*[^<\s][^<]+</i.test(line)) {
281
+ issues.push({
282
+ file, line: ln, severity: 1,
283
+ rule: 'a11y/skeleton-text',
284
+ message: 'velin-skeleton on elements with visible text may hide content from assistive tech.',
285
+ fixable: false,
286
+ });
287
+ }
288
+
247
289
  const inputMatches = line.matchAll(/<input\b[^>]*>/gi);
248
290
  for (const m of inputMatches) {
249
291
  const tag = m[0];
@@ -312,6 +354,27 @@ function scanA11yHTML(content, file) {
312
354
  });
313
355
  }
314
356
  }
357
+
358
+ if (/<form\b/i.test(line) && /type\s*=\s*["'](?:password|email)["']/i.test(line)) {
359
+ const formCtx = content.slice(Math.max(0, content.indexOf(line) - 800), content.indexOf(line) + 200);
360
+ if (/type\s*=\s*["']password["']/i.test(formCtx) && !/autocomplete\s*=/i.test(line) && !/autocomplete\s*=/i.test(formCtx)) {
361
+ issues.push({
362
+ file, line: ln, severity: 1,
363
+ rule: 'a11y/autocomplete-auth',
364
+ message: 'Auth field missing autocomplete (WCAG 2.2 / 1.3.5). Add autocomplete="username" or "current-password".',
365
+ fixable: false,
366
+ });
367
+ }
368
+ }
369
+
370
+ if (/aria-invalid\s*=\s*["']true["']/i.test(line) && !/aria-describedby/i.test(line)) {
371
+ issues.push({
372
+ file, line: ln, severity: 1,
373
+ rule: 'a11y/invalid-describedby',
374
+ message: 'aria-invalid without aria-describedby linking to error help text.',
375
+ fixable: false,
376
+ });
377
+ }
315
378
  });
316
379
 
317
380
  return issues;
@@ -396,10 +459,13 @@ export function scan(targetPath, options = {}) {
396
459
 
397
460
  let allIssues = [];
398
461
 
462
+ const fixEmailPlaceholder = options.fixEmailPlaceholder || 'user@example.com';
463
+
399
464
  for (const file of htmlFiles) {
400
465
  const content = readFileSync(file, 'utf-8');
401
466
  allIssues.push(...scanSecurityHTML(content, file));
402
467
  allIssues.push(...scanA11yHTML(content, file));
468
+ allIssues.push(...scanPIIHTML(content, file, { fixEmailPlaceholder }));
403
469
  }
404
470
 
405
471
  for (const file of cssFiles) {
@@ -410,6 +476,7 @@ export function scan(targetPath, options = {}) {
410
476
  for (const file of jsFiles) {
411
477
  const content = readFileSync(file, 'utf-8');
412
478
  allIssues.push(...scanSecurityJS(content, file));
479
+ allIssues.push(...scanPIIJS(content, file));
413
480
  }
414
481
 
415
482
  allIssues = allIssues.filter(i => i.severity <= minSeverity);
@@ -0,0 +1,163 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, existsSync } from 'fs';
2
+ import { join, dirname, relative, basename } from 'path';
3
+ import { fileURLToPath } from 'url';
4
+
5
+ const __dirname = dirname(fileURLToPath(import.meta.url));
6
+ const PKG_ROOT = join(__dirname, '..');
7
+
8
+ const TITLE_RE = /^#\s+<([^>]+)>|^#\s+(.+)$/m;
9
+ const H1_RE = /^#\s+(.+)$/m;
10
+
11
+ function excerptFromMd(text, max = 160) {
12
+ const lines = text.split('\n');
13
+ for (const line of lines) {
14
+ if (line.startsWith('#') || !line.trim() || line.startsWith('|') || line.startsWith('<!--')) continue;
15
+ return line.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1').replace(/`/g, '').trim().slice(0, max);
16
+ }
17
+ return '';
18
+ }
19
+
20
+ function slugId(category, name) {
21
+ return `${category}:${name}`.replace(/\s+/g, '-').toLowerCase();
22
+ }
23
+
24
+ function walkFiles(dir, cb) {
25
+ if (!existsSync(dir)) return;
26
+ for (const ent of readdirSync(dir, { withFileTypes: true })) {
27
+ const full = join(dir, ent.name);
28
+ if (ent.isDirectory()) walkFiles(full, cb);
29
+ else cb(full);
30
+ }
31
+ }
32
+
33
+ function collectMarkdown(dir, category, urlPrefix, weight = 1) {
34
+ const entries = [];
35
+ if (!existsSync(dir)) return entries;
36
+ walkFiles(dir, (full) => {
37
+ const name = basename(full);
38
+ if (!name.endsWith('.md') || name === 'README.md') return;
39
+ const text = readFileSync(full, 'utf-8');
40
+ const tagMatch = text.match(TITLE_RE);
41
+ const h1Match = text.match(H1_RE);
42
+ const title = tagMatch
43
+ ? (tagMatch[1] || tagMatch[2] || basename(name, '.md')).trim()
44
+ : (h1Match ? h1Match[1] : basename(name, '.md')).replace(/`/g, '').trim();
45
+ const rel = relative(dir, full).replace(/\\/g, '/');
46
+ const base = basename(name, '.md');
47
+ entries.push({
48
+ id: slugId(category, base),
49
+ title,
50
+ excerpt: excerptFromMd(text),
51
+ url: `${urlPrefix}${rel.replace(/\.md$/, '.html')}`,
52
+ category,
53
+ keywords: [base, category, title],
54
+ weight,
55
+ });
56
+ });
57
+ return entries;
58
+ }
59
+
60
+ function collectHtmlSamples(dirs) {
61
+ const entries = [];
62
+ for (const dir of dirs) {
63
+ if (!existsSync(dir)) continue;
64
+ const walk = (folder) => {
65
+ for (const ent of readdirSync(folder, { withFileTypes: true })) {
66
+ const full = join(folder, ent.name);
67
+ if (ent.isDirectory()) walk(full);
68
+ else if (ent.name.endsWith('.html')) {
69
+ const text = readFileSync(full, 'utf-8');
70
+ const titleM = text.match(/<title>([^<]+)<\/title>/i);
71
+ const h1M = text.match(/<h1[^>]*>([^<]+)<\/h1>/i);
72
+ const title = (h1M?.[1] || titleM?.[1] || ent.name).replace(/<[^>]+>/g, '').trim();
73
+ const rel = relative(PKG_ROOT, full).replace(/\\/g, '/');
74
+ entries.push({
75
+ id: slugId('examples', rel),
76
+ title,
77
+ excerpt: rel,
78
+ url: rel,
79
+ category: 'examples',
80
+ keywords: [ent.name, 'sample', 'example'],
81
+ weight: 0.85,
82
+ });
83
+ }
84
+ }
85
+ };
86
+ walk(dir);
87
+ }
88
+ return entries;
89
+ }
90
+
91
+ function collectCliApi(manifestPath) {
92
+ const entries = [];
93
+ if (!existsSync(manifestPath)) return entries;
94
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
95
+ for (const cmd of manifest.commands || []) {
96
+ entries.push({
97
+ id: slugId('api', cmd.name || cmd.id),
98
+ title: `CLI: ${cmd.name || cmd.id}`,
99
+ excerpt: cmd.summary || cmd.description || '',
100
+ url: `docs/generated/cli/commands.html#${cmd.name || cmd.id}`,
101
+ category: 'api',
102
+ keywords: ['cli', cmd.name, ...(cmd.aliases || [])],
103
+ weight: 1.1,
104
+ });
105
+ }
106
+ return entries;
107
+ }
108
+
109
+ /**
110
+ * @param {object} [options]
111
+ * @param {string} [options.outFile]
112
+ * @param {string} [options.generatedDir]
113
+ * @param {string[]} [options.extraHtmlDirs]
114
+ */
115
+ export function buildSearchIndex(options = {}) {
116
+ const generatedDir = options.generatedDir || join(PKG_ROOT, 'docs', 'generated');
117
+ const outFile = options.outFile || join(PKG_ROOT, 'dist', 'search-index.json');
118
+
119
+ const entries = [
120
+ ...collectMarkdown(join(generatedDir, 'components'), 'components', 'docs/generated/components/', 1.3),
121
+ ...collectMarkdown(join(generatedDir, 'tokens'), 'docs', 'docs/generated/tokens/', 1),
122
+ ...collectMarkdown(join(generatedDir, 'utilities'), 'docs', 'docs/generated/utilities/', 1),
123
+ ...collectMarkdown(join(generatedDir, 'cli'), 'api', 'docs/generated/cli/', 1.15),
124
+ ...collectMarkdown(join(generatedDir, 'rules'), 'docs', 'docs/generated/rules/', 0.95),
125
+ ...collectMarkdown(join(generatedDir, 'a11y'), 'docs', 'docs/generated/a11y/', 1),
126
+ ...collectMarkdown(join(generatedDir, 'attributes'), 'docs', 'docs/generated/attributes/', 1.2),
127
+ ...collectCliApi(join(PKG_ROOT, 'cli', 'cli-manifest.json')),
128
+ ...collectHtmlSamples([
129
+ join(PKG_ROOT, 'samples'),
130
+ join(PKG_ROOT, 'examples'),
131
+ ...(options.extraHtmlDirs || []),
132
+ ]),
133
+ ];
134
+
135
+ const seen = new Set();
136
+ const unique = [];
137
+ for (const e of entries) {
138
+ if (seen.has(e.id)) continue;
139
+ seen.add(e.id);
140
+ unique.push(e);
141
+ }
142
+
143
+ unique.sort((a, b) => a.title.localeCompare(b.title));
144
+
145
+ const payload = {
146
+ version: 1,
147
+ generatedAt: new Date().toISOString(),
148
+ entries: unique,
149
+ };
150
+
151
+ mkdirSync(dirname(outFile), { recursive: true });
152
+ writeFileSync(outFile, JSON.stringify(payload, null, 2), 'utf-8');
153
+ return { ok: true, count: unique.length, outFile };
154
+ }
155
+
156
+ export function searchIndexMain(argv = process.argv.slice(2)) {
157
+ const outIdx = argv.indexOf('--out');
158
+ const extraIdx = argv.indexOf('--extra-html');
159
+ const outFile = outIdx !== -1 && argv[outIdx + 1] ? argv[outIdx + 1] : join(PKG_ROOT, 'dist', 'search-index.json');
160
+ const extra = extraIdx !== -1 && argv[extraIdx + 1] ? argv[extraIdx + 1].split(',') : [];
161
+ const result = buildSearchIndex({ outFile, extraHtmlDirs: extra });
162
+ console.log(`Wrote ${result.count} search entries → ${result.outFile}`);
163
+ }