decantr 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/AGENTS.md +868 -0
- package/CHANGELOG.md +255 -0
- package/CLAUDE.md +178 -0
- package/LICENSE +21 -0
- package/README.md +229 -0
- package/cli/art.js +127 -0
- package/cli/commands/a11y.js +61 -0
- package/cli/commands/audit.js +225 -0
- package/cli/commands/build.js +38 -0
- package/cli/commands/dev.js +18 -0
- package/cli/commands/doctor.js +197 -0
- package/cli/commands/figma-sync.js +48 -0
- package/cli/commands/figma-tokens.js +55 -0
- package/cli/commands/generate.js +26 -0
- package/cli/commands/init.js +116 -0
- package/cli/commands/lint.js +209 -0
- package/cli/commands/mcp.js +530 -0
- package/cli/commands/migrate.js +175 -0
- package/cli/commands/test.js +38 -0
- package/cli/commands/validate.js +354 -0
- package/cli/index.js +113 -0
- package/package.json +95 -0
- package/reference/atoms.md +517 -0
- package/reference/behaviors.md +384 -0
- package/reference/build-tooling.md +275 -0
- package/reference/color-guidelines.md +965 -0
- package/reference/component-lifecycle.md +137 -0
- package/reference/compound-spacing.md +95 -0
- package/reference/decantation-process.md +499 -0
- package/reference/dev-server-routes.md +93 -0
- package/reference/form-system.md +253 -0
- package/reference/i18n.md +336 -0
- package/reference/icons.md +576 -0
- package/reference/llm-primer.md +953 -0
- package/reference/plugins.md +252 -0
- package/reference/registry-consumption.md +76 -0
- package/reference/router.md +217 -0
- package/reference/shells.md +116 -0
- package/reference/spatial-guidelines.md +541 -0
- package/reference/ssr.md +234 -0
- package/reference/state-data.md +215 -0
- package/reference/state-patterns.md +166 -0
- package/reference/state.md +194 -0
- package/reference/style-system.md +110 -0
- package/reference/tokens.md +460 -0
- package/src/app.js +19 -0
- package/src/chart/_animate.js +266 -0
- package/src/chart/_base.js +109 -0
- package/src/chart/_data.js +209 -0
- package/src/chart/_format.js +106 -0
- package/src/chart/_interact.js +364 -0
- package/src/chart/_palette.js +105 -0
- package/src/chart/_renderer.js +52 -0
- package/src/chart/_scene.js +262 -0
- package/src/chart/_shared.js +371 -0
- package/src/chart/index.js +637 -0
- package/src/chart/layouts/_layout-base.js +328 -0
- package/src/chart/layouts/cartesian.js +148 -0
- package/src/chart/layouts/hierarchy.js +562 -0
- package/src/chart/layouts/polar.js +101 -0
- package/src/chart/renderers/canvas.js +179 -0
- package/src/chart/renderers/svg.js +256 -0
- package/src/chart/renderers/webgpu.js +715 -0
- package/src/chart/types/_type-base.js +26 -0
- package/src/chart/types/area.js +134 -0
- package/src/chart/types/bar.js +173 -0
- package/src/chart/types/box-plot.js +125 -0
- package/src/chart/types/bubble.js +63 -0
- package/src/chart/types/candlestick.js +115 -0
- package/src/chart/types/chord.js +85 -0
- package/src/chart/types/combination.js +108 -0
- package/src/chart/types/funnel.js +68 -0
- package/src/chart/types/gauge.js +163 -0
- package/src/chart/types/heatmap.js +98 -0
- package/src/chart/types/histogram.js +71 -0
- package/src/chart/types/line.js +111 -0
- package/src/chart/types/org-chart.js +93 -0
- package/src/chart/types/pie.js +81 -0
- package/src/chart/types/radar.js +96 -0
- package/src/chart/types/radial.js +68 -0
- package/src/chart/types/range-area.js +55 -0
- package/src/chart/types/range-bar.js +61 -0
- package/src/chart/types/sankey.js +73 -0
- package/src/chart/types/scatter.js +66 -0
- package/src/chart/types/sparkline.js +81 -0
- package/src/chart/types/sunburst.js +69 -0
- package/src/chart/types/swimlane.js +88 -0
- package/src/chart/types/treemap.js +62 -0
- package/src/chart/types/waterfall.js +100 -0
- package/src/components/_base.js +1658 -0
- package/src/components/_behaviors.js +1140 -0
- package/src/components/_primitives.js +534 -0
- package/src/components/_qr-encoder.js +539 -0
- package/src/components/accordion.js +207 -0
- package/src/components/affix.js +62 -0
- package/src/components/alert-dialog.js +75 -0
- package/src/components/alert.js +47 -0
- package/src/components/aspect-ratio.js +24 -0
- package/src/components/avatar-group.js +55 -0
- package/src/components/avatar.js +38 -0
- package/src/components/back-top.js +75 -0
- package/src/components/badge.js +74 -0
- package/src/components/banner.js +68 -0
- package/src/components/breadcrumb.js +162 -0
- package/src/components/button.js +115 -0
- package/src/components/calendar.js +131 -0
- package/src/components/card.js +192 -0
- package/src/components/carousel.js +98 -0
- package/src/components/cascader.js +261 -0
- package/src/components/checkbox.js +80 -0
- package/src/components/chip.js +81 -0
- package/src/components/code-block.js +82 -0
- package/src/components/collapsible.js +50 -0
- package/src/components/color-palette.js +438 -0
- package/src/components/color-picker.js +314 -0
- package/src/components/combobox.js +181 -0
- package/src/components/command.js +174 -0
- package/src/components/comment.js +206 -0
- package/src/components/context-menu.js +76 -0
- package/src/components/data-table.js +724 -0
- package/src/components/date-picker.js +217 -0
- package/src/components/date-range-picker.js +244 -0
- package/src/components/datetime-picker.js +271 -0
- package/src/components/descriptions.js +68 -0
- package/src/components/drawer.js +179 -0
- package/src/components/dropdown.js +88 -0
- package/src/components/empty.js +41 -0
- package/src/components/float-button.js +90 -0
- package/src/components/form.js +106 -0
- package/src/components/hover-card.js +49 -0
- package/src/components/icon.js +87 -0
- package/src/components/image.js +97 -0
- package/src/components/index.js +117 -0
- package/src/components/input-group.js +75 -0
- package/src/components/input-number.js +155 -0
- package/src/components/input-otp.js +178 -0
- package/src/components/input.js +91 -0
- package/src/components/kbd.js +36 -0
- package/src/components/label.js +25 -0
- package/src/components/list.js +118 -0
- package/src/components/masked-input.js +236 -0
- package/src/components/mentions.js +165 -0
- package/src/components/menu.js +259 -0
- package/src/components/message.js +80 -0
- package/src/components/modal.js +147 -0
- package/src/components/navigation-menu.js +166 -0
- package/src/components/notification.js +84 -0
- package/src/components/pagination.js +104 -0
- package/src/components/placeholder.js +132 -0
- package/src/components/popconfirm.js +70 -0
- package/src/components/popover.js +58 -0
- package/src/components/progress.js +61 -0
- package/src/components/qrcode.js +251 -0
- package/src/components/radiogroup.js +120 -0
- package/src/components/range-slider.js +176 -0
- package/src/components/rate.js +186 -0
- package/src/components/resizable.js +83 -0
- package/src/components/result.js +57 -0
- package/src/components/scroll-area.js +43 -0
- package/src/components/segmented.js +97 -0
- package/src/components/select.js +165 -0
- package/src/components/separator.js +31 -0
- package/src/components/shell.js +407 -0
- package/src/components/skeleton.js +39 -0
- package/src/components/slider.js +141 -0
- package/src/components/sortable-list.js +176 -0
- package/src/components/space.js +42 -0
- package/src/components/spinner.js +112 -0
- package/src/components/splitter.js +147 -0
- package/src/components/statistic.js +136 -0
- package/src/components/steps.js +99 -0
- package/src/components/switch.js +95 -0
- package/src/components/table.js +44 -0
- package/src/components/tabs.js +216 -0
- package/src/components/tag.js +115 -0
- package/src/components/textarea.js +82 -0
- package/src/components/time-picker.js +153 -0
- package/src/components/time-range-picker.js +170 -0
- package/src/components/timeline.js +226 -0
- package/src/components/toast.js +71 -0
- package/src/components/toggle.js +213 -0
- package/src/components/tooltip.js +57 -0
- package/src/components/tour.js +159 -0
- package/src/components/transfer.js +163 -0
- package/src/components/tree-select.js +274 -0
- package/src/components/tree.js +141 -0
- package/src/components/typography.js +136 -0
- package/src/components/upload.js +118 -0
- package/src/components/visually-hidden.js +20 -0
- package/src/components/watermark.js +124 -0
- package/src/core/index.js +539 -0
- package/src/core/lifecycle.js +69 -0
- package/src/css/atoms.js +651 -0
- package/src/css/components.js +940 -0
- package/src/css/derive.js +1296 -0
- package/src/css/index.js +265 -0
- package/src/css/runtime.js +268 -0
- package/src/css/styles/addons/bioluminescent.js +93 -0
- package/src/css/styles/addons/clay.js +70 -0
- package/src/css/styles/addons/clean.js +57 -0
- package/src/css/styles/addons/command-center.js +143 -0
- package/src/css/styles/addons/dopamine.js +83 -0
- package/src/css/styles/addons/editorial.js +80 -0
- package/src/css/styles/addons/glassmorphism.js +99 -0
- package/src/css/styles/addons/liquid-glass.js +105 -0
- package/src/css/styles/addons/prismatic.js +100 -0
- package/src/css/styles/addons/retro.js +63 -0
- package/src/css/styles/auradecantism.js +96 -0
- package/src/css/theme-registry.js +444 -0
- package/src/data/entity.js +281 -0
- package/src/data/index.js +13 -0
- package/src/data/persist.js +225 -0
- package/src/data/query.js +839 -0
- package/src/data/realtime.js +299 -0
- package/src/data/url.js +177 -0
- package/src/data/worker.js +134 -0
- package/src/explorer/archetypes.js +243 -0
- package/src/explorer/atoms.js +228 -0
- package/src/explorer/charts.js +497 -0
- package/src/explorer/components.js +129 -0
- package/src/explorer/foundations.js +949 -0
- package/src/explorer/icons.js +178 -0
- package/src/explorer/patterns.js +247 -0
- package/src/explorer/recipes.js +194 -0
- package/src/explorer/shared/pattern-examples.js +1337 -0
- package/src/explorer/shared/showcase-renderer.js +958 -0
- package/src/explorer/shared/spec-table.js +41 -0
- package/src/explorer/shared/usage-links.js +87 -0
- package/src/explorer/shell-config.js +10 -0
- package/src/explorer/shells.js +551 -0
- package/src/explorer/styles.js +161 -0
- package/src/explorer/tokens.js +262 -0
- package/src/explorer/tools.js +525 -0
- package/src/form/index.js +804 -0
- package/src/i18n/index.js +251 -0
- package/src/icons/essential.js +479 -0
- package/src/icons/index.js +53 -0
- package/src/plugins/index.js +282 -0
- package/src/registry/archetypes/content-site.json +71 -0
- package/src/registry/archetypes/docs-explorer.json +23 -0
- package/src/registry/archetypes/ecommerce.json +104 -0
- package/src/registry/archetypes/financial-dashboard.json +77 -0
- package/src/registry/archetypes/index.json +41 -0
- package/src/registry/archetypes/portfolio.json +82 -0
- package/src/registry/archetypes/recipe-community.json +159 -0
- package/src/registry/archetypes/saas-dashboard.json +86 -0
- package/src/registry/architect/cross-cutting.json +45 -0
- package/src/registry/architect/domains/ecommerce.json +294 -0
- package/src/registry/architect/domains/financial-services.json +302 -0
- package/src/registry/architect/index.json +26 -0
- package/src/registry/architect/traits.json +379 -0
- package/src/registry/atoms.json +16 -0
- package/src/registry/chart-showcase.json +160 -0
- package/src/registry/chart.json +136 -0
- package/src/registry/components.json +8616 -0
- package/src/registry/core.json +216 -0
- package/src/registry/css.json +319 -0
- package/src/registry/data.json +135 -0
- package/src/registry/foundations.json +11 -0
- package/src/registry/icons.json +463 -0
- package/src/registry/index.json +101 -0
- package/src/registry/patterns/activity-feed.json +37 -0
- package/src/registry/patterns/article-content.json +27 -0
- package/src/registry/patterns/auth-form.json +37 -0
- package/src/registry/patterns/author-card.json +20 -0
- package/src/registry/patterns/card-grid.json +127 -0
- package/src/registry/patterns/category-nav.json +26 -0
- package/src/registry/patterns/chart-grid.json +36 -0
- package/src/registry/patterns/chat-interface.json +37 -0
- package/src/registry/patterns/checklist-card.json +55 -0
- package/src/registry/patterns/comparison-panel.json +27 -0
- package/src/registry/patterns/component-showcase.json +24 -0
- package/src/registry/patterns/contact-form.json +31 -0
- package/src/registry/patterns/cta-section.json +20 -0
- package/src/registry/patterns/data-table.json +37 -0
- package/src/registry/patterns/detail-header.json +83 -0
- package/src/registry/patterns/detail-panel.json +27 -0
- package/src/registry/patterns/explorer-shell.json +22 -0
- package/src/registry/patterns/filter-bar.json +33 -0
- package/src/registry/patterns/filter-sidebar.json +27 -0
- package/src/registry/patterns/form-sections.json +110 -0
- package/src/registry/patterns/goal-tracker.json +27 -0
- package/src/registry/patterns/hero.json +107 -0
- package/src/registry/patterns/index.json +47 -0
- package/src/registry/patterns/kpi-grid.json +36 -0
- package/src/registry/patterns/media-gallery.json +20 -0
- package/src/registry/patterns/order-history.json +20 -0
- package/src/registry/patterns/pagination.json +19 -0
- package/src/registry/patterns/photo-to-recipe.json +36 -0
- package/src/registry/patterns/pipeline-tracker.json +28 -0
- package/src/registry/patterns/post-list.json +27 -0
- package/src/registry/patterns/pricing-table.json +32 -0
- package/src/registry/patterns/scorecard.json +28 -0
- package/src/registry/patterns/search-bar.json +20 -0
- package/src/registry/patterns/specimen-grid.json +19 -0
- package/src/registry/patterns/stat-card.json +55 -0
- package/src/registry/patterns/stats-bar.json +55 -0
- package/src/registry/patterns/steps-card.json +55 -0
- package/src/registry/patterns/table-of-contents.json +19 -0
- package/src/registry/patterns/testimonials.json +21 -0
- package/src/registry/patterns/timeline.json +27 -0
- package/src/registry/patterns/token-inspector.json +21 -0
- package/src/registry/patterns/wizard.json +27 -0
- package/src/registry/recipe-auradecantism.json +69 -0
- package/src/registry/recipe-clean.json +65 -0
- package/src/registry/recipe-command-center.json +78 -0
- package/src/registry/router.json +73 -0
- package/src/registry/schema/README.md +197 -0
- package/src/registry/skeletons.json +259 -0
- package/src/registry/state.json +137 -0
- package/src/registry/tokens.json +40 -0
- package/src/router/hash.js +17 -0
- package/src/router/history.js +18 -0
- package/src/router/index.js +598 -0
- package/src/ssr/index.js +922 -0
- package/src/state/arrays.js +181 -0
- package/src/state/devtools.js +647 -0
- package/src/state/index.js +498 -0
- package/src/state/middleware.js +288 -0
- package/src/state/scheduler.js +206 -0
- package/src/state/store.js +300 -0
- package/src/tags/index.js +19 -0
- package/src/tannins/auth.js +396 -0
- package/src/test/dom.js +352 -0
- package/src/test/index.js +62 -0
- package/src/test/state.js +306 -0
- package/tools/a11y-audit.js +487 -0
- package/tools/analyzer.js +315 -0
- package/tools/audit.js +706 -0
- package/tools/builder.js +1422 -0
- package/tools/css-extract.js +188 -0
- package/tools/dev-server.js +316 -0
- package/tools/dts-gen.js +1260 -0
- package/tools/figma-components.js +329 -0
- package/tools/figma-patterns.js +516 -0
- package/tools/figma-plugin/code.js +453 -0
- package/tools/figma-plugin/manifest.json +14 -0
- package/tools/figma-plugin/ui.html +268 -0
- package/tools/figma-render.js +293 -0
- package/tools/figma-tokens.js +712 -0
- package/tools/figma-upload.js +318 -0
- package/tools/generate.js +738 -0
- package/tools/icons.js +133 -0
- package/tools/init-templates.js +265 -0
- package/tools/install-hooks.sh +5 -0
- package/tools/migrations/0.5.0.js +53 -0
- package/tools/migrations/0.6.0.js +95 -0
- package/tools/minify.js +170 -0
- package/tools/pre-commit +4 -0
- package/tools/registry.js +662 -0
- package/tools/reset-playground.js +61 -0
- package/tools/starter-templates/content-site/app.js +49 -0
- package/tools/starter-templates/content-site/essence.js +19 -0
- package/tools/starter-templates/content-site/pages.js +31 -0
- package/tools/starter-templates/ecommerce/app.js +50 -0
- package/tools/starter-templates/ecommerce/essence.js +19 -0
- package/tools/starter-templates/ecommerce/pages.js +31 -0
- package/tools/starter-templates/landing-page/app.js +38 -0
- package/tools/starter-templates/landing-page/essence.js +18 -0
- package/tools/starter-templates/landing-page/pages.js +21 -0
- package/tools/starter-templates/portfolio/app.js +45 -0
- package/tools/starter-templates/portfolio/essence.js +19 -0
- package/tools/starter-templates/portfolio/pages.js +33 -0
- package/tools/starter-templates/saas-dashboard/app.js +70 -0
- package/tools/starter-templates/saas-dashboard/essence.js +19 -0
- package/tools/starter-templates/saas-dashboard/pages.js +31 -0
- package/tools/verify-pack.js +203 -0
- package/types/chart.d.ts +77 -0
- package/types/components.d.ts +587 -0
- package/types/core.d.ts +89 -0
- package/types/css.d.ts +149 -0
- package/types/data.d.ts +238 -0
- package/types/form.d.ts +164 -0
- package/types/i18n.d.ts +51 -0
- package/types/icons.d.ts +27 -0
- package/types/index.d.ts +13 -0
- package/types/router.d.ts +116 -0
- package/types/ssr.d.ts +102 -0
- package/types/state.d.ts +83 -0
- package/types/tags.d.ts +62 -0
- package/types/tannins.d.ts +63 -0
- package/types/test.d.ts +48 -0
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decantr Accessibility Audit
|
|
3
|
+
*
|
|
4
|
+
* Static analysis for common WCAG violations.
|
|
5
|
+
* Parses JS source strings for component patterns and validates
|
|
6
|
+
* accessibility attributes. Zero third-party dependencies.
|
|
7
|
+
*
|
|
8
|
+
* Rules:
|
|
9
|
+
* button-label — Buttons must have text content, aria-label, or aria-labelledby
|
|
10
|
+
* input-label — Inputs must have associated label or aria-label
|
|
11
|
+
* img-alt — Images must have alt attribute
|
|
12
|
+
* focus-visible — Interactive elements need focus indicator
|
|
13
|
+
* keyboard-handler — Elements with onclick should also have onkeydown/onkeyup
|
|
14
|
+
* role-valid — Check role values against WAI-ARIA spec
|
|
15
|
+
* heading-order — No skipped heading levels
|
|
16
|
+
* contrast-ratio — Reminder about contrast when using color atoms
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { readFile } from 'node:fs/promises';
|
|
20
|
+
|
|
21
|
+
// ─── Valid WAI-ARIA Roles ────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
const VALID_ARIA_ROLES = new Set([
|
|
24
|
+
// Widget roles
|
|
25
|
+
'alert', 'alertdialog', 'application', 'button', 'cell', 'checkbox',
|
|
26
|
+
'columnheader', 'combobox', 'command', 'comment', 'complementary',
|
|
27
|
+
'composite', 'definition', 'dialog', 'directory', 'document',
|
|
28
|
+
'feed', 'figure', 'form', 'generic', 'grid', 'gridcell', 'group',
|
|
29
|
+
'heading', 'img', 'input', 'insertion', 'landmark', 'link', 'list',
|
|
30
|
+
'listbox', 'listitem', 'log', 'main', 'mark', 'marquee', 'math',
|
|
31
|
+
'menu', 'menubar', 'menuitem', 'menuitemcheckbox', 'menuitemradio',
|
|
32
|
+
'meter', 'navigation', 'none', 'note', 'option', 'paragraph',
|
|
33
|
+
'presentation', 'progressbar', 'radio', 'radiogroup', 'range',
|
|
34
|
+
'region', 'roletype', 'row', 'rowgroup', 'rowheader', 'scrollbar',
|
|
35
|
+
'search', 'searchbox', 'section', 'sectionhead', 'select',
|
|
36
|
+
'separator', 'slider', 'spinbutton', 'status', 'structure',
|
|
37
|
+
'suggestion', 'switch', 'tab', 'table', 'tablist', 'tabpanel',
|
|
38
|
+
'term', 'textbox', 'timer', 'toolbar', 'tooltip', 'tree',
|
|
39
|
+
'treegrid', 'treeitem', 'widget', 'window',
|
|
40
|
+
// Landmark roles
|
|
41
|
+
'banner', 'contentinfo',
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
// ─── Inherently keyboard-accessible elements ────────────────────
|
|
45
|
+
|
|
46
|
+
const KEYBOARD_ACCESSIBLE_ELEMENTS = new Set([
|
|
47
|
+
'button', 'a', 'input', 'select', 'textarea', 'summary', 'details',
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
// ─── Helpers ─────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Find the line number for a character index in source.
|
|
54
|
+
* @param {string} source
|
|
55
|
+
* @param {number} index
|
|
56
|
+
* @returns {number}
|
|
57
|
+
*/
|
|
58
|
+
function findLineNumber(source, index) {
|
|
59
|
+
return source.substring(0, index).split('\n').length;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Extract a context window around a match index.
|
|
64
|
+
* Returns the surrounding source from the current statement/call.
|
|
65
|
+
* @param {string} source
|
|
66
|
+
* @param {number} index
|
|
67
|
+
* @param {number} [before=200]
|
|
68
|
+
* @param {number} [after=300]
|
|
69
|
+
* @returns {string}
|
|
70
|
+
*/
|
|
71
|
+
function getContext(source, index, before = 200, after = 300) {
|
|
72
|
+
const start = Math.max(0, index - before);
|
|
73
|
+
const end = Math.min(source.length, index + after);
|
|
74
|
+
return source.substring(start, end);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ─── Rules ───────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Rule: button-label
|
|
81
|
+
* Buttons must have text content, aria-label, or aria-labelledby.
|
|
82
|
+
*/
|
|
83
|
+
function ruleButtonLabel(source, filename) {
|
|
84
|
+
const issues = [];
|
|
85
|
+
|
|
86
|
+
// Detect Button({...}) component calls
|
|
87
|
+
const buttonCompRe = /\bButton\s*\(\s*\{/g;
|
|
88
|
+
let match;
|
|
89
|
+
while ((match = buttonCompRe.exec(source)) !== null) {
|
|
90
|
+
const ctx = getContext(source, match.index, 50, 400);
|
|
91
|
+
const hasAriaLabel = /['"]aria-label['"]/.test(ctx) || /aria-label\s*:/.test(ctx);
|
|
92
|
+
const hasAriaLabelledBy = /['"]aria-labelledby['"]/.test(ctx) || /aria-labelledby\s*:/.test(ctx);
|
|
93
|
+
// Check for text content: text('...') or children with text, or label prop
|
|
94
|
+
const hasTextChild = /\btext\s*\(/.test(ctx);
|
|
95
|
+
const hasLabel = /\blabel\s*:/.test(ctx);
|
|
96
|
+
const hasChildren = /\bchildren\s*:/.test(ctx);
|
|
97
|
+
|
|
98
|
+
if (!hasAriaLabel && !hasAriaLabelledBy && !hasTextChild && !hasLabel && !hasChildren) {
|
|
99
|
+
issues.push({
|
|
100
|
+
rule: 'button-label',
|
|
101
|
+
severity: 'error',
|
|
102
|
+
message: 'Button component missing accessible label (text content, label prop, aria-label, or aria-labelledby)',
|
|
103
|
+
file: filename,
|
|
104
|
+
line: findLineNumber(source, match.index),
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Detect h('button', {...}) calls
|
|
110
|
+
const hButtonRe = /\bh\s*\(\s*['"]button['"]\s*,/g;
|
|
111
|
+
while ((match = hButtonRe.exec(source)) !== null) {
|
|
112
|
+
const ctx = getContext(source, match.index, 50, 400);
|
|
113
|
+
const hasAriaLabel = /['"]aria-label['"]/.test(ctx) || /aria-label\s*:/.test(ctx);
|
|
114
|
+
const hasAriaLabelledBy = /['"]aria-labelledby['"]/.test(ctx) || /aria-labelledby\s*:/.test(ctx);
|
|
115
|
+
const hasTextChild = /\btext\s*\(/.test(ctx);
|
|
116
|
+
|
|
117
|
+
if (!hasAriaLabel && !hasAriaLabelledBy && !hasTextChild) {
|
|
118
|
+
issues.push({
|
|
119
|
+
rule: 'button-label',
|
|
120
|
+
severity: 'error',
|
|
121
|
+
message: 'h(\'button\') missing accessible label (text child, aria-label, or aria-labelledby)',
|
|
122
|
+
file: filename,
|
|
123
|
+
line: findLineNumber(source, match.index),
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return issues;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Rule: input-label
|
|
133
|
+
* Inputs must have associated label or aria-label.
|
|
134
|
+
*/
|
|
135
|
+
function ruleInputLabel(source, filename) {
|
|
136
|
+
const issues = [];
|
|
137
|
+
|
|
138
|
+
// Detect Input({...}) component calls
|
|
139
|
+
const inputCompRe = /\bInput\s*\(\s*\{/g;
|
|
140
|
+
let match;
|
|
141
|
+
while ((match = inputCompRe.exec(source)) !== null) {
|
|
142
|
+
const ctx = getContext(source, match.index, 50, 400);
|
|
143
|
+
const hasAriaLabel = /['"]aria-label['"]/.test(ctx) || /aria-label\s*:/.test(ctx);
|
|
144
|
+
const hasAriaLabelledBy = /['"]aria-labelledby['"]/.test(ctx) || /aria-labelledby\s*:/.test(ctx);
|
|
145
|
+
const hasLabel = /\blabel\s*:/.test(ctx);
|
|
146
|
+
const hasId = /\bid\s*:/.test(ctx);
|
|
147
|
+
const hasPlaceholder = /\bplaceholder\s*:/.test(ctx);
|
|
148
|
+
|
|
149
|
+
if (!hasAriaLabel && !hasAriaLabelledBy && !hasLabel && !hasId) {
|
|
150
|
+
issues.push({
|
|
151
|
+
rule: 'input-label',
|
|
152
|
+
severity: 'error',
|
|
153
|
+
message: 'Input component missing accessible label (label prop, aria-label, aria-labelledby, or id for external label)',
|
|
154
|
+
file: filename,
|
|
155
|
+
line: findLineNumber(source, match.index),
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Detect h('input', {...}) calls
|
|
161
|
+
const hInputRe = /\bh\s*\(\s*['"]input['"]\s*,/g;
|
|
162
|
+
while ((match = hInputRe.exec(source)) !== null) {
|
|
163
|
+
const ctx = getContext(source, match.index, 50, 400);
|
|
164
|
+
const hasAriaLabel = /['"]aria-label['"]/.test(ctx) || /aria-label\s*:/.test(ctx);
|
|
165
|
+
const hasAriaLabelledBy = /['"]aria-labelledby['"]/.test(ctx) || /aria-labelledby\s*:/.test(ctx);
|
|
166
|
+
const hasId = /\bid\s*:/.test(ctx);
|
|
167
|
+
|
|
168
|
+
if (!hasAriaLabel && !hasAriaLabelledBy && !hasId) {
|
|
169
|
+
issues.push({
|
|
170
|
+
rule: 'input-label',
|
|
171
|
+
severity: 'error',
|
|
172
|
+
message: 'h(\'input\') missing accessible label (aria-label, aria-labelledby, or id for external label)',
|
|
173
|
+
file: filename,
|
|
174
|
+
line: findLineNumber(source, match.index),
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return issues;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Rule: img-alt
|
|
184
|
+
* Images must have alt attribute.
|
|
185
|
+
*/
|
|
186
|
+
function ruleImgAlt(source, filename) {
|
|
187
|
+
const issues = [];
|
|
188
|
+
|
|
189
|
+
// Detect Image({...}) component calls
|
|
190
|
+
const imageCompRe = /\bImage\s*\(\s*\{/g;
|
|
191
|
+
let match;
|
|
192
|
+
while ((match = imageCompRe.exec(source)) !== null) {
|
|
193
|
+
const ctx = getContext(source, match.index, 50, 400);
|
|
194
|
+
const hasAlt = /\balt\s*:/.test(ctx);
|
|
195
|
+
const hasAriaLabel = /['"]aria-label['"]/.test(ctx) || /aria-label\s*:/.test(ctx);
|
|
196
|
+
const hasRole = /role\s*:\s*['"]presentation['"]/.test(ctx) || /role\s*:\s*['"]none['"]/.test(ctx);
|
|
197
|
+
|
|
198
|
+
if (!hasAlt && !hasAriaLabel && !hasRole) {
|
|
199
|
+
issues.push({
|
|
200
|
+
rule: 'img-alt',
|
|
201
|
+
severity: 'error',
|
|
202
|
+
message: 'Image component missing alt attribute (provide alt, aria-label, or role="presentation" for decorative images)',
|
|
203
|
+
file: filename,
|
|
204
|
+
line: findLineNumber(source, match.index),
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Detect h('img', {...}) calls
|
|
210
|
+
const hImgRe = /\bh\s*\(\s*['"]img['"]\s*,/g;
|
|
211
|
+
while ((match = hImgRe.exec(source)) !== null) {
|
|
212
|
+
const ctx = getContext(source, match.index, 50, 400);
|
|
213
|
+
const hasAlt = /\balt\s*:/.test(ctx);
|
|
214
|
+
const hasAriaLabel = /['"]aria-label['"]/.test(ctx) || /aria-label\s*:/.test(ctx);
|
|
215
|
+
const hasRole = /role\s*:\s*['"]presentation['"]/.test(ctx) || /role\s*:\s*['"]none['"]/.test(ctx);
|
|
216
|
+
|
|
217
|
+
if (!hasAlt && !hasAriaLabel && !hasRole) {
|
|
218
|
+
issues.push({
|
|
219
|
+
rule: 'img-alt',
|
|
220
|
+
severity: 'error',
|
|
221
|
+
message: 'h(\'img\') missing alt attribute (provide alt, aria-label, or role="presentation")',
|
|
222
|
+
file: filename,
|
|
223
|
+
line: findLineNumber(source, match.index),
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return issues;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Rule: focus-visible
|
|
233
|
+
* Interactive elements with onclick should have focus indicator classes.
|
|
234
|
+
*/
|
|
235
|
+
function ruleFocusVisible(source, filename) {
|
|
236
|
+
const issues = [];
|
|
237
|
+
|
|
238
|
+
// Find elements with onclick handlers
|
|
239
|
+
const onclickRe = /\bonclick\s*:/g;
|
|
240
|
+
let match;
|
|
241
|
+
while ((match = onclickRe.exec(source)) !== null) {
|
|
242
|
+
const ctx = getContext(source, match.index, 300, 300);
|
|
243
|
+
const hasFocusVisible = /_focusVisible/.test(ctx) || /_ring/.test(ctx) || /_focusRing/.test(ctx);
|
|
244
|
+
const hasOutline = /outline/.test(ctx);
|
|
245
|
+
const hasFocusClass = /focus/.test(ctx) && /class/.test(ctx);
|
|
246
|
+
// Skip if this is inside a Button/Input/etc. component (they handle focus internally)
|
|
247
|
+
const isComponentProp = /\b(Button|Input|Select|Checkbox|Switch|Radio)\s*\(\s*\{/.test(ctx);
|
|
248
|
+
|
|
249
|
+
if (!hasFocusVisible && !hasOutline && !hasFocusClass && !isComponentProp) {
|
|
250
|
+
issues.push({
|
|
251
|
+
rule: 'focus-visible',
|
|
252
|
+
severity: 'warning',
|
|
253
|
+
message: 'Element with onclick handler may need focus indicator (_focusVisible or _ring atom)',
|
|
254
|
+
file: filename,
|
|
255
|
+
line: findLineNumber(source, match.index),
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return issues;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Rule: keyboard-handler
|
|
265
|
+
* Non-button/link elements with onclick should also have onkeydown/onkeyup.
|
|
266
|
+
*/
|
|
267
|
+
function ruleKeyboardHandler(source, filename) {
|
|
268
|
+
const issues = [];
|
|
269
|
+
|
|
270
|
+
// Find onclick handlers and check context for element type and keyboard handler
|
|
271
|
+
const onclickRe = /\bonclick\s*:/g;
|
|
272
|
+
let match;
|
|
273
|
+
while ((match = onclickRe.exec(source)) !== null) {
|
|
274
|
+
const ctx = getContext(source, match.index, 300, 300);
|
|
275
|
+
|
|
276
|
+
// Check if this is inside a natively keyboard-accessible element
|
|
277
|
+
let isNativelyAccessible = false;
|
|
278
|
+
for (const el of KEYBOARD_ACCESSIBLE_ELEMENTS) {
|
|
279
|
+
// Check for h('button', or h('a', or Button( etc.
|
|
280
|
+
const hPattern = new RegExp(`\\bh\\s*\\(\\s*['"]${el}['"]`);
|
|
281
|
+
if (hPattern.test(ctx)) {
|
|
282
|
+
isNativelyAccessible = true;
|
|
283
|
+
break;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
// Check for component calls like Button(, Select(, etc.
|
|
287
|
+
if (/\b(Button|Input|Select|Checkbox|Switch|Radio|Combobox|Textarea)\s*\(\s*\{/.test(ctx)) {
|
|
288
|
+
isNativelyAccessible = true;
|
|
289
|
+
}
|
|
290
|
+
// Check for role="button" which implies keyboard handling
|
|
291
|
+
if (/role\s*:\s*['"]button['"]/.test(ctx)) {
|
|
292
|
+
isNativelyAccessible = true;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (isNativelyAccessible) continue;
|
|
296
|
+
|
|
297
|
+
const hasKeyboard = /\bonkeydown\s*:/.test(ctx) || /\bonkeyup\s*:/.test(ctx) || /\bonkeypress\s*:/.test(ctx);
|
|
298
|
+
const hasTabindex = /\btabindex\s*:/.test(ctx) || /\btabIndex\s*:/.test(ctx);
|
|
299
|
+
|
|
300
|
+
if (!hasKeyboard) {
|
|
301
|
+
issues.push({
|
|
302
|
+
rule: 'keyboard-handler',
|
|
303
|
+
severity: 'error',
|
|
304
|
+
message: 'Non-interactive element with onclick missing keyboard handler (add onkeydown/onkeyup for keyboard accessibility)',
|
|
305
|
+
file: filename,
|
|
306
|
+
line: findLineNumber(source, match.index),
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return issues;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Rule: role-valid
|
|
316
|
+
* Check that role values are valid WAI-ARIA roles.
|
|
317
|
+
*/
|
|
318
|
+
function ruleRoleValid(source, filename) {
|
|
319
|
+
const issues = [];
|
|
320
|
+
|
|
321
|
+
// Match role: 'value' or role: "value" or 'role': 'value'
|
|
322
|
+
const roleRe = /(?:['"]?role['"]?\s*:\s*)['"]([a-zA-Z]+)['"]/g;
|
|
323
|
+
let match;
|
|
324
|
+
while ((match = roleRe.exec(source)) !== null) {
|
|
325
|
+
const role = match[1].toLowerCase();
|
|
326
|
+
if (!VALID_ARIA_ROLES.has(role)) {
|
|
327
|
+
issues.push({
|
|
328
|
+
rule: 'role-valid',
|
|
329
|
+
severity: 'error',
|
|
330
|
+
message: `Invalid ARIA role "${match[1]}" — must be a valid WAI-ARIA role`,
|
|
331
|
+
file: filename,
|
|
332
|
+
line: findLineNumber(source, match.index),
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return issues;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Rule: heading-order
|
|
342
|
+
* Heading levels should not be skipped (e.g. h1 -> h3 without h2).
|
|
343
|
+
*/
|
|
344
|
+
function ruleHeadingOrder(source, filename) {
|
|
345
|
+
const issues = [];
|
|
346
|
+
|
|
347
|
+
// Collect all heading references in order of appearance
|
|
348
|
+
const headings = [];
|
|
349
|
+
|
|
350
|
+
// Match h('h1') through h('h6')
|
|
351
|
+
const hCallRe = /\bh\s*\(\s*['"]h([1-6])['"]/g;
|
|
352
|
+
let match;
|
|
353
|
+
while ((match = hCallRe.exec(source)) !== null) {
|
|
354
|
+
headings.push({
|
|
355
|
+
level: parseInt(match[1], 10),
|
|
356
|
+
index: match.index,
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Match tags.h1 through tags.h6
|
|
361
|
+
const tagsRe = /\btags\.h([1-6])\b/g;
|
|
362
|
+
while ((match = tagsRe.exec(source)) !== null) {
|
|
363
|
+
headings.push({
|
|
364
|
+
level: parseInt(match[1], 10),
|
|
365
|
+
index: match.index,
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Sort by position in source
|
|
370
|
+
headings.sort((a, b) => a.index - b.index);
|
|
371
|
+
|
|
372
|
+
// Check for skipped levels
|
|
373
|
+
for (let i = 1; i < headings.length; i++) {
|
|
374
|
+
const prev = headings[i - 1].level;
|
|
375
|
+
const curr = headings[i].level;
|
|
376
|
+
// Only flag when going deeper and skipping a level (e.g. h1 -> h3)
|
|
377
|
+
if (curr > prev && curr - prev > 1) {
|
|
378
|
+
issues.push({
|
|
379
|
+
rule: 'heading-order',
|
|
380
|
+
severity: 'warning',
|
|
381
|
+
message: `Heading level skipped: h${prev} to h${curr} (missing h${prev + 1})`,
|
|
382
|
+
file: filename,
|
|
383
|
+
line: findLineNumber(source, headings[i].index),
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
return issues;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Rule: contrast-ratio
|
|
393
|
+
* Info-level reminder when foreground and background color atoms are used together.
|
|
394
|
+
* Full contrast checking requires runtime; this is a static reminder.
|
|
395
|
+
*/
|
|
396
|
+
function ruleContrastRatio(source, filename) {
|
|
397
|
+
const issues = [];
|
|
398
|
+
|
|
399
|
+
// Look for css() calls with both _fg* and _bg* atoms
|
|
400
|
+
const cssCallRe = /\bcss\s*\(\s*['"`]([^'"`]+)['"`]\s*\)/g;
|
|
401
|
+
let match;
|
|
402
|
+
while ((match = cssCallRe.exec(source)) !== null) {
|
|
403
|
+
const atomStr = match[1];
|
|
404
|
+
const hasFg = /_fg[a-zA-Z]/.test(atomStr);
|
|
405
|
+
const hasBg = /_bg[a-zA-Z]/.test(atomStr);
|
|
406
|
+
if (hasFg && hasBg) {
|
|
407
|
+
issues.push({
|
|
408
|
+
rule: 'contrast-ratio',
|
|
409
|
+
severity: 'info',
|
|
410
|
+
message: 'Foreground and background color atoms used together — verify WCAG AA contrast ratio (4.5:1 for text, 3:1 for large text)',
|
|
411
|
+
file: filename,
|
|
412
|
+
line: findLineNumber(source, match.index),
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
return issues;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// ─── Rule Registry ───────────────────────────────────────────────
|
|
421
|
+
|
|
422
|
+
const RULES = [
|
|
423
|
+
ruleButtonLabel,
|
|
424
|
+
ruleInputLabel,
|
|
425
|
+
ruleImgAlt,
|
|
426
|
+
ruleFocusVisible,
|
|
427
|
+
ruleKeyboardHandler,
|
|
428
|
+
ruleRoleValid,
|
|
429
|
+
ruleHeadingOrder,
|
|
430
|
+
ruleContrastRatio,
|
|
431
|
+
];
|
|
432
|
+
|
|
433
|
+
// ─── Public API ──────────────────────────────────────────────────
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Audit a single source string for accessibility issues.
|
|
437
|
+
* @param {string} source - JavaScript source code
|
|
438
|
+
* @param {string} [filename='unknown'] - Filename for issue reporting
|
|
439
|
+
* @returns {{rule: string, severity: 'error'|'warning'|'info', message: string, file: string, line?: number}[]}
|
|
440
|
+
*/
|
|
441
|
+
export function auditSource(source, filename = 'unknown') {
|
|
442
|
+
const issues = [];
|
|
443
|
+
for (const rule of RULES) {
|
|
444
|
+
issues.push(...rule(source, filename));
|
|
445
|
+
}
|
|
446
|
+
return issues;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Audit multiple files from disk.
|
|
451
|
+
* @param {string[]} files - Array of absolute file paths
|
|
452
|
+
* @returns {Promise<{rule: string, severity: 'error'|'warning'|'info', message: string, file: string, line?: number}[]>}
|
|
453
|
+
*/
|
|
454
|
+
export async function auditFiles(files) {
|
|
455
|
+
const allIssues = [];
|
|
456
|
+
for (const file of files) {
|
|
457
|
+
try {
|
|
458
|
+
const source = await readFile(file, 'utf-8');
|
|
459
|
+
allIssues.push(...auditSource(source, file));
|
|
460
|
+
} catch {
|
|
461
|
+
// Skip unreadable files
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
return allIssues;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Format audit issues into a human-readable report string.
|
|
469
|
+
* @param {{rule: string, severity: 'error'|'warning'|'info', message: string, file: string, line?: number}[]} issues
|
|
470
|
+
* @returns {string}
|
|
471
|
+
*/
|
|
472
|
+
export function formatIssues(issues) {
|
|
473
|
+
if (issues.length === 0) return ' No accessibility issues found.\n';
|
|
474
|
+
|
|
475
|
+
const errors = issues.filter(i => i.severity === 'error');
|
|
476
|
+
const warnings = issues.filter(i => i.severity === 'warning');
|
|
477
|
+
const info = issues.filter(i => i.severity === 'info');
|
|
478
|
+
|
|
479
|
+
let output = '\n';
|
|
480
|
+
for (const issue of issues) {
|
|
481
|
+
const loc = issue.line ? `:${issue.line}` : '';
|
|
482
|
+
const icon = issue.severity === 'error' ? '\u2717' : issue.severity === 'warning' ? '\u26A0' : '\u2139';
|
|
483
|
+
output += ` ${icon} [${issue.rule}] ${issue.file}${loc}: ${issue.message}\n`;
|
|
484
|
+
}
|
|
485
|
+
output += `\n ${errors.length} error(s), ${warnings.length} warning(s), ${info.length} info\n`;
|
|
486
|
+
return output;
|
|
487
|
+
}
|