@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.
- package/README.de.md +135 -246
- package/README.md +134 -245
- package/cli/blueprints/onboarding.html +2 -2
- package/cli/cli-manifest.json +97 -0
- package/cli/docgen/extract-a11y.js +89 -0
- package/cli/docgen/extract-attributes.js +95 -0
- package/cli/docgen/extract-cli.js +50 -0
- package/cli/docgen/extract-components.js +175 -0
- package/cli/docgen/extract-meta.js +32 -0
- package/cli/docgen/extract-rules.js +76 -0
- package/cli/docgen/extract-tokens.js +79 -0
- package/cli/docgen/extract-utilities.js +75 -0
- package/cli/docgen/markdown.js +43 -0
- package/cli/docs-generate.js +196 -0
- package/cli/index.js +121 -5
- package/cli/meta.js +97 -0
- package/cli/perf-audit.js +174 -0
- package/cli/pii-scanner.js +134 -0
- package/cli/scanner-rules-data.js +62 -0
- package/cli/scanner.js +68 -1
- package/cli/search-index.js +163 -0
- package/cli/tokens-build.js +21 -1
- package/cli/tokens-validate.js +69 -0
- package/components/a11y-entry.js +42 -0
- package/components/a11y-utils.js +71 -0
- package/components/focus-manager.js +45 -8
- package/components/index.js +22 -12
- package/components/runtime/component-loaders.js +43 -0
- package/components/runtime/index.js +77 -0
- package/components/runtime-entry.js +10 -0
- package/components/sanitize.js +135 -54
- package/components/velin-accordion.js +12 -0
- package/components/velin-bottom-nav.js +19 -0
- package/components/velin-carousel.js +2 -2
- package/components/velin-code-block.js +142 -0
- package/components/velin-command.js +15 -0
- package/components/velin-copy.js +18 -5
- package/components/velin-counter.js +3 -0
- package/components/velin-dialog.js +9 -1
- package/components/velin-email.js +94 -0
- package/components/velin-icon.js +65 -22
- package/components/velin-lightbox.js +8 -3
- package/components/velin-live-dot.js +5 -0
- package/components/velin-modal.js +6 -3
- package/components/velin-popover.js +34 -2
- package/components/velin-reveal.js +7 -65
- package/components/velin-scroll-top.js +1 -1
- package/components/velin-search.js +267 -0
- package/components/velin-secure-field.js +99 -0
- package/components/velin-sparkline.js +27 -7
- package/components/{velin-stepper-wc.js → velin-stepper.js} +37 -8
- package/components/velin-theme-toggle.js +12 -5
- package/components/velin-toast.js +4 -0
- package/components/{velin-tooltip-wc.js → velin-tooltip.js} +22 -3
- package/core/a11y/component-contracts.json +221 -0
- package/core/attributes/index.js +6 -0
- package/core/attributes/registry.js +268 -0
- package/core/highlight/highlight.js +114 -0
- package/core/highlight/index.js +29 -0
- package/core/highlight/languages/_utils.js +52 -0
- package/core/highlight/languages/blade.js +16 -0
- package/core/highlight/languages/css.js +26 -0
- package/core/highlight/languages/html.js +17 -0
- package/core/highlight/languages/js.js +58 -0
- package/core/highlight/languages/json.js +14 -0
- package/core/highlight/languages/markdown.js +16 -0
- package/core/highlight/languages/php.js +39 -0
- package/core/highlight/languages/plain.js +7 -0
- package/core/highlight/languages/shell.js +33 -0
- package/core/highlight/languages/sql.js +20 -0
- package/core/highlight/languages/typescript.js +28 -0
- package/core/highlight/observe.js +53 -0
- package/core/highlight/registry.js +107 -0
- package/core/highlight/render.js +35 -0
- package/core/highlight/types.js +28 -0
- package/core/meta/build.js +293 -0
- package/core/meta/index.js +17 -0
- package/core/meta/schema.js +49 -0
- package/core/motion/effects.js +54 -0
- package/core/motion/index.js +76 -0
- package/core/motion/scheduler.js +72 -0
- package/core/motion/scroll.js +32 -0
- package/core/motion/stagger.js +24 -0
- package/core/search/docs-url.js +96 -0
- package/core/search/engine.js +139 -0
- package/core/search/highlight.js +48 -0
- package/core/search/index.js +126 -0
- package/core/search/providers.js +36 -0
- package/core/search/types.js +61 -0
- package/core/search/worker-client.js +56 -0
- package/core/search/worker.js +20 -0
- package/dist/chunks/a11y-utils-IRC7LOPF.js +16 -0
- package/dist/chunks/attributes-3BZLI7XF.js +364 -0
- package/dist/chunks/attributes-4N5JP4CG.js +364 -0
- package/dist/chunks/attributes-5Q2WXGRC.js +374 -0
- package/dist/chunks/attributes-AIR7SVLK.js +364 -0
- package/dist/chunks/attributes-BGRN5XZO.js +364 -0
- package/dist/chunks/attributes-BVSQAYLR.js +364 -0
- package/dist/chunks/attributes-CDYFUY7Y.js +364 -0
- package/dist/chunks/attributes-JMYJZBPE.js +364 -0
- package/dist/chunks/attributes-R5JY3X4O.js +374 -0
- package/dist/chunks/attributes-STYCS3O5.js +391 -0
- package/dist/chunks/attributes-TNSPQTHX.js +374 -0
- package/dist/chunks/attributes-TPANLMLF.js +391 -0
- package/dist/chunks/blade-LIQLVKZQ.js +19 -0
- package/dist/chunks/browser-MRKYZPEH.js +1149 -0
- package/dist/chunks/chunk-2354NEBP.js +109 -0
- package/dist/chunks/chunk-2VNA7G7S.js +109 -0
- package/dist/chunks/chunk-42VVPW6N.js +132 -0
- package/dist/chunks/chunk-7FFAMRFV.js +44 -0
- package/dist/chunks/chunk-7MCGPHVC.js +69 -0
- package/dist/chunks/chunk-AQLRJSGG.js +109 -0
- package/dist/chunks/chunk-EWPSDMQT.js +109 -0
- package/dist/chunks/chunk-F7OLHTGX.js +22 -0
- package/dist/chunks/chunk-J472ERN2.js +242 -0
- package/dist/chunks/chunk-LA7264K4.js +85 -0
- package/dist/chunks/chunk-M5CHQVP6.js +61 -0
- package/dist/chunks/chunk-MKZP2TBH.js +89 -0
- package/dist/chunks/chunk-QTJJ3DPO.js +109 -0
- package/dist/chunks/chunk-RDE3QE7Z.js +89 -0
- package/dist/chunks/chunk-RSMRR27R.js +109 -0
- package/dist/chunks/chunk-RZQVWFHW.js +109 -0
- package/dist/chunks/chunk-SRL6EWHP.js +109 -0
- package/dist/chunks/chunk-U4XHMZ7D.js +109 -0
- package/dist/chunks/chunk-V5YIUA6G.js +109 -0
- package/dist/chunks/chunk-XIET2VSZ.js +109 -0
- package/dist/chunks/chunk-XYLJ2GCB.js +205 -0
- package/dist/chunks/chunk-Z4JYQ2XX.js +51 -0
- package/dist/chunks/chunk-ZPSPKUYD.js +109 -0
- package/dist/chunks/css-MQ5YU77P.js +28 -0
- package/dist/chunks/highlight-GJHHV4KM.js +37 -0
- package/dist/chunks/highlight-XTHRKBA3.js +37 -0
- package/dist/chunks/html-ZODC4DLQ.js +20 -0
- package/dist/chunks/js-MNSNYTJU.js +7 -0
- package/dist/chunks/json-VEM2HFH3.js +17 -0
- package/dist/chunks/markdown-E7OPGS3S.js +19 -0
- package/dist/chunks/php-MLL7T3LT.js +41 -0
- package/dist/chunks/plain-LJJDC5TN.js +8 -0
- package/dist/chunks/runtime-entry.js +14 -0
- package/dist/chunks/shell-QTRK4U4E.js +35 -0
- package/dist/chunks/sql-PPWLHGJ2.js +23 -0
- package/dist/chunks/typescript-JT3CCYWL.js +30 -0
- package/dist/chunks/velin-accordion-FBIPAEXG.js +105 -0
- package/dist/chunks/velin-accordion-QAQO7BI7.js +117 -0
- package/dist/chunks/velin-announcer-PVTXXCWR.js +33 -0
- package/dist/chunks/velin-bottom-nav-2N2TYWOF.js +86 -0
- package/dist/chunks/velin-bottom-nav-7GEMPIM6.js +86 -0
- package/dist/chunks/velin-bottom-nav-CTU6FFZV.js +101 -0
- package/dist/chunks/velin-bottom-nav-N3DFQR66.js +86 -0
- package/dist/chunks/velin-carousel-Y5VCOABZ.js +213 -0
- package/dist/chunks/velin-carousel-ZTBANFQQ.js +213 -0
- package/dist/chunks/velin-code-block-ASUBTAQV.js +119 -0
- package/dist/chunks/velin-code-block-CIAURDRJ.js +132 -0
- package/dist/chunks/velin-code-block-TSUKPY4C.js +123 -0
- package/dist/chunks/velin-code-block-Y6CM726Q.js +119 -0
- package/dist/chunks/velin-collapse-5XYBUIID.js +80 -0
- package/dist/chunks/velin-combobox-2KDTCQ7N.js +155 -0
- package/dist/chunks/velin-combobox-NXS4QB4R.js +155 -0
- package/dist/chunks/velin-combobox-UGA56HP3.js +155 -0
- package/dist/chunks/velin-command-CKDNO4BW.js +148 -0
- package/dist/chunks/velin-command-M7RNHUPR.js +136 -0
- package/dist/chunks/velin-command-ONKO2A3J.js +136 -0
- package/dist/chunks/velin-command-TMLSRGIU.js +136 -0
- package/dist/chunks/velin-copy-G2TZ5YBB.js +81 -0
- package/dist/chunks/velin-copy-NYDBOKXT.js +81 -0
- package/dist/chunks/velin-copy-T5KDTDXP.js +81 -0
- package/dist/chunks/velin-countdown-4ZVDGK4R.js +101 -0
- package/dist/chunks/velin-countdown-5XSTN3BH.js +101 -0
- package/dist/chunks/velin-countdown-7AIY66CU.js +101 -0
- package/dist/chunks/velin-counter-ISCTEAHI.js +118 -0
- package/dist/chunks/velin-counter-L5FYJF7A.js +121 -0
- package/dist/chunks/velin-dialog-D2GUHSCD.js +144 -0
- package/dist/chunks/velin-dialog-ELFOZIXZ.js +152 -0
- package/dist/chunks/velin-dialog-GTTZI564.js +147 -0
- package/dist/chunks/velin-dialog-IJLFDXAJ.js +144 -0
- package/dist/chunks/velin-dialog-TNS3WXMB.js +144 -0
- package/dist/chunks/velin-drawer-BE6TYN3H.js +130 -0
- package/dist/chunks/velin-drawer-DLL2GLT6.js +130 -0
- package/dist/chunks/velin-drawer-MJS6WCF7.js +130 -0
- package/dist/chunks/velin-dropdown-PLUIFA5M.js +172 -0
- package/dist/chunks/velin-email-N5B5ZYTE.js +90 -0
- package/dist/chunks/velin-email-NTM6DG3V.js +90 -0
- package/dist/chunks/velin-email-RBX6ZR4S.js +90 -0
- package/dist/chunks/velin-haptic-4QQTM3RK.js +50 -0
- package/dist/chunks/velin-icon-7M76DOPB.js +164 -0
- package/dist/chunks/velin-icon-KZO3IEHG.js +164 -0
- package/dist/chunks/velin-icon-WTFPG2DD.js +191 -0
- package/dist/chunks/velin-icon-ZTSKUOVN.js +193 -0
- package/dist/chunks/velin-lightbox-6E7CAATP.js +149 -0
- package/dist/chunks/velin-lightbox-I75U6JF4.js +140 -0
- package/dist/chunks/velin-lightbox-QOTB4PRL.js +140 -0
- package/dist/chunks/velin-lightbox-UCD6N7HF.js +140 -0
- package/dist/chunks/velin-live-dot-RJHX2DGY.js +74 -0
- package/dist/chunks/velin-live-dot-XHUZCIDZ.js +67 -0
- package/dist/chunks/velin-menubar-I426AED2.js +86 -0
- package/dist/chunks/velin-menubar-J66JXRWW.js +86 -0
- package/dist/chunks/velin-menubar-SFEXJE56.js +86 -0
- package/dist/chunks/velin-modal-2HOBXE6F.js +176 -0
- package/dist/chunks/velin-modal-5HYNNNUI.js +176 -0
- package/dist/chunks/velin-modal-AEOTZ67O.js +177 -0
- package/dist/chunks/velin-modal-GLI2XZZB.js +176 -0
- package/dist/chunks/velin-persist-DFPL3QP4.js +110 -0
- package/dist/chunks/velin-popover-EXINRIGJ.js +145 -0
- package/dist/chunks/velin-popover-HQW6Q2Y6.js +172 -0
- package/dist/chunks/velin-popover-OXX7MRZ6.js +145 -0
- package/dist/chunks/velin-popover-T72HK3DF.js +151 -0
- package/dist/chunks/velin-popover-YKCCROJG.js +145 -0
- package/dist/chunks/velin-progress-ring-E4YRLZHH.js +66 -0
- package/dist/chunks/velin-rating-6CDBI6DG.js +91 -0
- package/dist/chunks/velin-rating-N4DI2XNL.js +91 -0
- package/dist/chunks/velin-rating-XRQJV4LO.js +91 -0
- package/dist/chunks/velin-scroll-top-F2FCZNYD.js +58 -0
- package/dist/chunks/velin-scroll-top-OHYVVHHY.js +58 -0
- package/dist/chunks/velin-scrollspy-OKCT4FDV.js +50 -0
- package/dist/chunks/velin-search-2XRQFBCS.js +556 -0
- package/dist/chunks/velin-search-4YHJWYSS.js +556 -0
- package/dist/chunks/velin-search-7B4KJ6TX.js +557 -0
- package/dist/chunks/velin-search-LWHY23Q5.js +474 -0
- package/dist/chunks/velin-secure-field-6PV7RZDJ.js +95 -0
- package/dist/chunks/velin-secure-field-H7FSBUJW.js +95 -0
- package/dist/chunks/velin-secure-field-OFNAXCDP.js +95 -0
- package/dist/chunks/velin-segmented-control-EKL4E3BL.js +109 -0
- package/dist/chunks/velin-segmented-control-GYD5BIXQ.js +109 -0
- package/dist/chunks/velin-segmented-control-PQX6LKOJ.js +109 -0
- package/dist/chunks/velin-sheet-3OGI4HD7.js +119 -0
- package/dist/chunks/velin-sheet-OUK6572T.js +119 -0
- package/dist/chunks/velin-sheet-R2Y67X6H.js +119 -0
- package/dist/chunks/velin-sparkline-GVEANJVW.js +184 -0
- package/dist/chunks/velin-sparkline-KDA3GQAN.js +162 -0
- package/dist/chunks/velin-sparkline-XVTFC6GE.js +181 -0
- package/dist/chunks/velin-stepper-2ZIHLKGM.js +141 -0
- package/dist/chunks/velin-stepper-7ECRUYHD.js +148 -0
- package/dist/chunks/velin-stepper-FYEUEWE6.js +141 -0
- package/dist/chunks/velin-stepper-X54WGHDG.js +141 -0
- package/dist/chunks/velin-stepper-wc-QU3KRVQR.js +133 -0
- package/dist/chunks/velin-tabs-5UQ5ZVMV.js +138 -0
- package/dist/chunks/velin-theme-toggle-32J2CBNC.js +291 -0
- package/dist/chunks/velin-theme-toggle-BNNS7WLV.js +285 -0
- package/dist/chunks/velin-theme-toggle-NO7V4TWI.js +285 -0
- package/dist/chunks/velin-theme-toggle-QELCJM2Q.js +291 -0
- package/dist/chunks/velin-toast-OMMOA2DK.js +109 -0
- package/dist/chunks/velin-toast-RCZ4CSUC.js +109 -0
- package/dist/chunks/velin-toast-RSF2IHT3.js +113 -0
- package/dist/chunks/velin-toast-WEUBTJG6.js +109 -0
- package/dist/chunks/velin-tooltip-664IOHZA.js +112 -0
- package/dist/chunks/velin-tooltip-BDHGRTQB.js +112 -0
- package/dist/chunks/velin-tooltip-HVUOKNVX.js +112 -0
- package/dist/chunks/velin-tooltip-wc-LXDVT6DE.js +103 -0
- package/dist/chunks/worker-client-R6DBYPFT.js +46 -0
- package/dist/llms.test.txt +40 -0
- package/dist/llms.txt +40 -0
- package/dist/search-index.json +1773 -0
- package/dist/search-index.test.json +1773 -0
- package/dist/themes/brutalist.min.css +1 -1
- package/dist/themes/midnight.min.css +1 -1
- package/dist/themes/pastel.min.css +1 -1
- package/dist/themes/retro.min.css +1 -1
- package/dist/themes/sunset.min.css +1 -1
- package/dist/velin-agent.json +684 -0
- package/dist/velin-agent.test.json +684 -0
- package/dist/velinstyle-components.iife.js +7715 -3515
- package/dist/velinstyle-components.js +7087 -2859
- package/dist/velinstyle-components.min.js +196 -109
- package/dist/velinstyle.css +604 -65
- package/dist/velinstyle.d.ts +54 -0
- package/dist/velinstyle.min.css +1 -1
- package/examples/search-index.schema.json +33 -0
- package/package.json +56 -10
- package/src/a11y/authentication.css +37 -0
- package/src/a11y/consistent-help.css +22 -0
- package/src/a11y/dragging-alternatives.css +30 -0
- package/src/a11y/focus-appearance.css +20 -0
- package/src/a11y/high-contrast-aaa.css +16 -26
- package/src/a11y/skip-link.css +3 -1
- package/src/a11y/sr-only.css +1 -1
- package/src/base/reset.css +0 -36
- package/src/base/wc-placeholder.css +102 -0
- package/src/components/attributes.css +42 -0
- package/src/components/highlight.css +182 -0
- package/src/components/input-group.css +1 -2
- package/src/components/nav.css +2 -3
- package/src/components/search.css +95 -0
- package/src/components/stepper.css +2 -4
- package/src/components/timeline.css +1 -2
- package/src/components/tooltip.css +2 -4
- package/src/layout/container.css +5 -5
- package/src/layout/grid.css +8 -8
- package/src/layout/patterns.css +5 -5
- package/src/themes/brutalist.css +2 -11
- package/src/themes/corporate.css +0 -9
- package/src/themes/earth.css +0 -9
- package/src/themes/forest.css +0 -9
- package/src/themes/midnight.css +1 -10
- package/src/themes/neon.css +0 -9
- package/src/themes/nordic.css +0 -9
- package/src/themes/ocean.css +0 -9
- package/src/themes/pastel.css +1 -10
- package/src/themes/retro.css +2 -11
- package/src/themes/sharp.css +0 -7
- package/src/themes/soft.css +0 -7
- package/src/themes/sunset.css +1 -10
- package/src/tokens/color.css +45 -46
- package/src/tokens/fonts.css +10 -0
- package/src/tokens/typography.css +4 -5
- package/src/utilities/animation.css +2 -2
- package/src/utilities/display.css +3 -3
- package/src/utilities/divide.css +2 -6
- package/src/utilities/responsive.css +4 -4
- package/src/utilities/safe-area.css +1 -1
- package/src/utilities/scroll-animation.css +34 -0
- package/src/utilities/spacing.css +17 -8
- package/src/utilities/text.css +4 -1
- 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
|
-
|
|
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
|
+
}
|