@wix/agent-skills 1.19.0 → 1.19.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/package.json +1 -1
  2. package/skills/wix-app/SKILL.md +33 -31
  3. package/skills/wix-app/references/DASHBOARD_PAGE.md +6 -10
  4. package/skills/wix-app/references/EDITOR_REACT_COMPONENT.md +17 -8
  5. package/skills/wix-app/references/WIX_PATTERNS_DOCS.md +53 -131
  6. package/skills/wix-app/references/dashboard-page/UX_SUCCESS_MODEL.md +1 -2
  7. package/skills/wix-app/references/editor-react-component/ACCESSIBILITY.md +88 -73
  8. package/skills/wix-app/references/editor-react-component/ANIMATED-COMPONENTS.md +46 -22
  9. package/skills/wix-app/references/editor-react-component/COMPONENT-CONTRACT.md +20 -6
  10. package/skills/wix-app/references/editor-react-component/CSS-GUIDELINES.md +9 -0
  11. package/skills/wix-app/references/editor-react-component/FUNCTION-HANDLERS.md +71 -31
  12. package/skills/wix-app/references/editor-react-component/PARTS.md +5 -2
  13. package/skills/wix-app/references/editor-react-component/REACT-GUIDELINES.md +4 -5
  14. package/skills/wix-app/scripts/pkg-root.cjs +73 -0
  15. package/skills/wix-app/scripts/scan-a11y-code.cjs +310 -40
  16. package/skills/wix-app/scripts/scan-a11y-eslint.cjs +118 -24
  17. package/skills/wix-app/scripts/scan-a11y-render.cjs +551 -0
  18. package/skills/wix-app/scripts/scan-a11y-review.cjs +373 -0
  19. package/skills/wix-base44-connector/SKILL.md +67 -43
  20. package/skills/wix-base44-connector/scripts/utils.cjs +39 -3
  21. package/skills/wix-docs/SKILL.md +47 -20
  22. package/skills/wix-headless/SKILL.md +1 -0
  23. package/skills/wix-headless/entry/bootstrap.mjs +106 -50
  24. package/skills/wix-headless/entry/skill.md +5 -3
  25. package/skills/wix-headless/references/CAPABILITIES.md +7 -2
  26. package/skills/wix-headless/references/SDK_HANDOFF.md +2 -1
  27. package/skills/wix-headless/references/inline-recipes/experience-store.md +48 -0
  28. package/skills/wix-headless/references/inline-recipes/how-to-code-a-store.md +350 -114
  29. package/skills/wix-headless/references/inline-recipes/how-to-code-members-astro.md +1 -1
  30. package/skills/wix-headless/references/managed/AUTHENTICATION.md +25 -5
  31. package/skills/wix-headless/references/managed/CREATE.md +5 -0
  32. package/skills/wix-headless-fast/SKILL.md +25 -7
  33. package/skills/wix-headless-fast/entry/skill.md +23 -14
  34. package/skills/wix-headless-fast/install/fast-path.mjs +48 -5
  35. package/skills/wix-headless-fast/install/pin-agents-md.mjs +57 -0
  36. package/skills/wix-headless-fast/references/shared/CUSTOM_OPERATIONS.md +3 -3
  37. package/skills/wix-headless-fast/references/storefront/INSTRUCTIONS.md +204 -35
  38. package/skills/wix-headless-fast/references/storefront/app/hooks/storefront/useShop.ts +146 -36
  39. package/skills/wix-headless-fast/references/storefront/app/wix/storefront/cart-store.ts +8 -2
  40. package/skills/wix-headless-fast/references/storefront/app/wix/storefront/catalog.ts +95 -20
  41. package/skills/wix-headless-fast/references/storefront/seed/SEED.md +21 -2
  42. package/skills/wix-headless-fast/references/storefront/seed/seed-store.mjs +104 -18
  43. package/skills/wix-manage/SKILL.md +16 -10
  44. package/skills/wix-manage/references/bookings/create-and-update-booking-services.md +4 -1
  45. package/skills/wix-manage/references/bookings/create-appointment-service.md +3 -1
  46. package/skills/wix-manage/references/bookings/create-class-service.md +3 -1
  47. package/skills/wix-manage/references/bookings/create-course-service.md +4 -2
  48. package/skills/wix-manage/references/bookings/end-to-end-booking-flow.md +68 -3
  49. package/skills/wix-manage/references/ecommerce/pricing-promotions/ecom-pricing-flow-seasonal-promotion.md +1 -1
  50. package/skills/wix-manage/references/google-ads/billing-and-payment.md +3 -3
  51. package/skills/wix-manage/references/google-ads/create-performance-max-campaign.md +8 -8
  52. package/skills/wix-manage/references/google-ads/get-campaign-suggestions.md +4 -4
  53. package/skills/wix-manage/references/google-ads/google-ads-dashboard-navigation.md +1 -1
  54. package/skills/wix-manage/references/google-ads/install-and-create-account.md +9 -9
  55. package/skills/wix-manage/references/google-ads/manage-campaign-success-guide.md +108 -14
  56. package/skills/wix-manage/references/google-ads/query-campaign-analytics.md +7 -7
  57. package/skills/wix-manage/references/seo/manage-content-plan.md +182 -136
  58. package/skills/wix-manage/references/seo/manage-google-search-console.md +272 -0
  59. package/skills/wix-manage/references/sites/upload-static-site.md +283 -0
  60. package/skills/wix-manage/references/stores/change-store-currency.md +1 -1
  61. package/skills/wix-vibe-headless/platforms/base44-ecom-light-seed-with-install.md +3 -1
  62. package/skills/wix-vibe-headless/platforms/base44-ecom-light-seed.md +3 -1
  63. package/skills/wix-vibe-headless/platforms/base44-ecom-light.md +1 -1
  64. package/skills/wix-vibe-headless/references/storefront/INSTRUCTIONS.md +69 -27
  65. package/skills/wix-vibe-headless/references/storefront/app/components/CartDrawer.jsx +32 -14
  66. package/skills/wix-vibe-headless/references/storefront/app/context/CartContext.jsx +14 -3
  67. package/skills/wix-vibe-headless/references/storefront/app/hooks/useProductCard.js +41 -35
  68. package/skills/wix-vibe-headless/references/storefront/app/hooks/useProductDetail.js +77 -30
  69. package/skills/wix-vibe-headless/references/storefront/app/rest/wix-store-cart.js +31 -2
  70. package/skills/wix-vibe-headless/references/storefront/app/rest/wix-store-catalog.js +31 -6
  71. package/skills/wix-app/references/dashboard-page/COLLECTION_TOOLKIT.md +0 -80
  72. package/skills/wix-app/references/dashboard-page/ENTITY_PAGE_TOOLKIT.md +0 -140
  73. package/skills/wix-app/references/dashboard-page/PATTERNS_BUNDLE_READING.md +0 -112
  74. package/skills/wix-headless-fast/references/storefront/app/components/storefront/ProductCard.tsx +0 -100
  75. package/skills/wix-headless-fast/references/storefront/app/components/storefront/ProductDetailView.tsx +0 -130
  76. package/skills/wix-headless-fast/references/storefront/app/components/storefront/ProductGrid.tsx +0 -48
  77. package/skills/wix-headless-fast/references/storefront/app/components/storefront/ShopView.tsx +0 -66
  78. package/skills/wix-headless-fast/references/storefront/app/components/storefront/VariantPicker.tsx +0 -103
  79. package/skills/wix-headless-fast/references/storefront/app-astro/pages/products/[slug].astro +0 -56
  80. package/skills/wix-headless-fast/references/storefront/app-astro/pages/shop.astro +0 -23
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env node
2
+ // Prints the install directory of a package — the folder holding its
3
+ // package.json — resolved from the current working directory.
4
+ //
5
+ // node pkg-root.cjs @wix/patterns
6
+ //
7
+ // Takes the package name as an argument and knows nothing about any particular
8
+ // package, so it stays correct as the packages it is pointed at change.
9
+ //
10
+ // Three steps, because one is not enough in the repos this runs in:
11
+ // 1. Activate Yarn PnP if a .pnp.cjs exists at or above cwd. A bare
12
+ // require.resolve throws in a Yarn Berry project even when the package is
13
+ // installed, and in a monorepo the .pnp.cjs sits at the repo root rather
14
+ // than beside the project being built.
15
+ // 2. require.resolve, which handles npm, pnpm and workspace layouts.
16
+ // 3. Failing that, walk up looking for node_modules/<pkg> directly, which
17
+ // covers a partially-installed tree where resolution fails but the files
18
+ // are on disk.
19
+ const fs = require('fs');
20
+ const path = require('path');
21
+
22
+ const pkg = process.argv[2];
23
+ if (!pkg) {
24
+ console.error('usage: pkg-root.cjs <package-name>');
25
+ process.exit(2);
26
+ }
27
+
28
+ function activatePnp() {
29
+ let dir = process.cwd();
30
+ for (;;) {
31
+ const pnp = path.join(dir, '.pnp.cjs');
32
+ if (fs.existsSync(pnp)) {
33
+ try {
34
+ require(pnp).setup();
35
+ } catch {
36
+ // An unusable .pnp.cjs is not fatal: the plain resolve below may still
37
+ // succeed against a node_modules tree.
38
+ }
39
+ return;
40
+ }
41
+ const parent = path.dirname(dir);
42
+ if (parent === dir) return;
43
+ dir = parent;
44
+ }
45
+ }
46
+
47
+ activatePnp();
48
+
49
+ try {
50
+ console.log(
51
+ path.dirname(
52
+ require.resolve(`${pkg}/package.json`, { paths: [process.cwd()] }),
53
+ ),
54
+ );
55
+ process.exit(0);
56
+ } catch {
57
+ // fall through to the directory walk
58
+ }
59
+
60
+ let dir = process.cwd();
61
+ for (;;) {
62
+ const candidate = path.join(dir, 'node_modules', ...pkg.split('/'));
63
+ if (fs.existsSync(path.join(candidate, 'package.json'))) {
64
+ console.log(candidate);
65
+ process.exit(0);
66
+ }
67
+ const parent = path.dirname(dir);
68
+ if (parent === dir) {
69
+ console.error(`${pkg} not found from ${process.cwd()}`);
70
+ process.exit(1);
71
+ }
72
+ dir = parent;
73
+ }
@@ -157,6 +157,21 @@ const UNSUPPORTED_ARIA_ELEMENTS = new Set([
157
157
  ]);
158
158
 
159
159
  const FILE_EXTENSIONS = ['.tsx', '.ts', '.jsx', '.js', '.mjs', '.cjs'];
160
+ /** `A11y` fields a component may read. ZeroConfig turns each read into an editor control. */
161
+ const A11Y_FIELDS = new Set(['ariaLabel']);
162
+ const A11Y_FIELDS_LABEL = [...A11Y_FIELDS].join(', ');
163
+ const A11Y_CONVERTER = 'convertA11yKeysToHtmlFormat';
164
+ const HARDCODED_LABEL_ATTRIBUTES = new Set(['aria-label', 'aria-description']);
165
+ const SUPPORTED_RULES = [
166
+ 'alt-text',
167
+ 'anchor-is-valid',
168
+ 'aria-props',
169
+ 'aria-role',
170
+ 'aria-unsupported-elements',
171
+ 'a11y-whole-object',
172
+ 'a11y-disallowed-field',
173
+ 'hardcoded-aria-label',
174
+ ];
160
175
  const parseCache = new Map();
161
176
  const resolutionWarnings = [];
162
177
 
@@ -189,12 +204,8 @@ function parseFile(filePath) {
189
204
  'optionalChaining',
190
205
  'nullishCoalescingOperator',
191
206
  ],
192
- errorRecovery: true,
193
207
  });
194
- const recoverableErrors = Array.isArray(ast.errors)
195
- ? ast.errors.map((error) => error.message)
196
- : [];
197
- const parsed = { ok: true, ast, code, recoverableErrors };
208
+ const parsed = { ok: true, ast, code };
198
209
  parseCache.set(filePath, parsed);
199
210
  return parsed;
200
211
  } catch (error) {
@@ -557,6 +568,132 @@ function toRelative(filePath) {
557
568
  return path.relative(ROOT, filePath) || filePath;
558
569
  }
559
570
 
571
+ // ─────────────────────────────────────────────────────────────────────────────
572
+ // Per-part a11y contract helpers
573
+ // ─────────────────────────────────────────────────────────────────────────────
574
+
575
+ /** Peel type assertions, parentheses, and guards such as `a11y && x` or `x ?? {}`. */
576
+ function unwrapExpression(node) {
577
+ let current = node;
578
+ for (let guard = 0; current && guard < 20; guard++) {
579
+ if (
580
+ t.isTSAsExpression(current) ||
581
+ t.isTSNonNullExpression(current) ||
582
+ t.isTSTypeAssertion(current) ||
583
+ t.isParenthesizedExpression(current) ||
584
+ (t.isTSSatisfiesExpression && t.isTSSatisfiesExpression(current))
585
+ ) {
586
+ current = current.expression;
587
+ } else if (t.isLogicalExpression(current)) {
588
+ current = current.operator === '&&' ? current.right : current.left;
589
+ } else {
590
+ break;
591
+ }
592
+ }
593
+ return current;
594
+ }
595
+
596
+ function isA11yIdentifierName(name) {
597
+ return name === 'a11y' || name.endsWith('A11y');
598
+ }
599
+
600
+ /** `a11y`, `toggleA11y`, `props.a11y`, `elementProps?.toggle?.a11y`. */
601
+ function isA11yLike(node) {
602
+ const expr = unwrapExpression(node);
603
+ if (t.isIdentifier(expr)) return isA11yIdentifierName(expr.name);
604
+ if ((t.isMemberExpression(expr) || t.isOptionalMemberExpression(expr)) && !expr.computed) {
605
+ return t.isIdentifier(expr.property, { name: 'a11y' });
606
+ }
607
+ return false;
608
+ }
609
+
610
+ /** `props.elementProps?.toggle` → ['props', 'elementProps', 'toggle']; null for other shapes. */
611
+ function memberChain(node) {
612
+ const expr = unwrapExpression(node);
613
+ if (t.isIdentifier(expr)) return [expr.name];
614
+ if ((t.isMemberExpression(expr) || t.isOptionalMemberExpression(expr)) && !expr.computed) {
615
+ const parent = memberChain(expr.object);
616
+ if (!parent || !t.isIdentifier(expr.property)) return null;
617
+ return [...parent, expr.property.name];
618
+ }
619
+ return null;
620
+ }
621
+
622
+ function isElementPropsPartChain(chain, partsWithA11y) {
623
+ if (!chain || chain.length < 2) return false;
624
+ const index = chain.indexOf('elementProps');
625
+ return index !== -1 && index === chain.length - 2 && partsWithA11y.has(chain[chain.length - 1]);
626
+ }
627
+
628
+ function typeLiteralHasMember(typeNode, memberName) {
629
+ return (
630
+ t.isTSTypeLiteral(typeNode) &&
631
+ typeNode.members.some(
632
+ (member) =>
633
+ t.isTSPropertySignature(member) &&
634
+ ((t.isIdentifier(member.key) && member.key.name === memberName) ||
635
+ (t.isStringLiteral(member.key) && member.key.value === memberName)),
636
+ )
637
+ );
638
+ }
639
+
640
+ /**
641
+ * Names of `elementProps` parts whose declared type carries an `a11y` field,
642
+ * keyed by folder so one component's contract never applies to another.
643
+ * Reads every `*.props.ts` next to the scanned files plus the files themselves.
644
+ */
645
+ function collectPropsTypes(files) {
646
+ const partsByDir = new Map();
647
+ const candidates = new Set(files);
648
+ for (const file of files) {
649
+ const dir = path.dirname(file);
650
+ if (!partsByDir.has(dir)) partsByDir.set(dir, new Set());
651
+ if (!fs.existsSync(dir)) continue;
652
+ for (const name of fs.readdirSync(dir)) {
653
+ if (name.endsWith('.props.ts')) candidates.add(path.join(dir, name));
654
+ }
655
+ }
656
+
657
+ for (const file of candidates) {
658
+ const parsed = parseFile(file);
659
+ if (!parsed.ok) continue;
660
+ const partsWithA11y = partsByDir.get(path.dirname(file));
661
+ traverse(parsed.ast, {
662
+ TSPropertySignature(path) {
663
+ const { key, typeAnnotation } = path.node;
664
+ if (!t.isIdentifier(key, { name: 'elementProps' }) || !typeAnnotation) return;
665
+ const literal = typeAnnotation.typeAnnotation;
666
+ if (!t.isTSTypeLiteral(literal)) return;
667
+ for (const member of literal.members) {
668
+ if (!t.isTSPropertySignature(member) || !member.typeAnnotation) continue;
669
+ const partName = t.isIdentifier(member.key)
670
+ ? member.key.name
671
+ : t.isStringLiteral(member.key)
672
+ ? member.key.value
673
+ : null;
674
+ if (partName && typeLiteralHasMember(member.typeAnnotation.typeAnnotation, 'a11y')) {
675
+ partsWithA11y.add(partName);
676
+ }
677
+ }
678
+ },
679
+ });
680
+ }
681
+
682
+ return partsByDir;
683
+ }
684
+
685
+ function contractFinding(filePath, node, rule, confidence, message) {
686
+ return findingFromNode(filePath, node, {
687
+ rule,
688
+ confidence,
689
+ message,
690
+ componentName: null,
691
+ semanticType: 'a11y-contract',
692
+ evidence: 'Per-part accessibility contract (ACCESSIBILITY.md).',
693
+ sourceKind: 'contract',
694
+ });
695
+ }
696
+
560
697
  function findingFromNode(filePath, node, data) {
561
698
  return {
562
699
  file: toRelative(filePath),
@@ -572,7 +709,7 @@ function findingFromNode(filePath, node, data) {
572
709
  };
573
710
  }
574
711
 
575
- function scanFile(filePath) {
712
+ function scanFile(filePath, options = {}) {
576
713
  const parsed = parseFile(filePath);
577
714
  if (!parsed.ok) {
578
715
  return {
@@ -586,9 +723,124 @@ function scanFile(filePath) {
586
723
 
587
724
  const imports = getImportMap(parsed.ast);
588
725
  const findings = [];
589
- const parseWarnings = parsed.recoverableErrors || [];
726
+ const partsWithA11y = options.partsWithA11y || new Set();
727
+
728
+ const reportDisallowedField = (node, fieldName) => {
729
+ findings.push(
730
+ contractFinding(
731
+ filePath,
732
+ node,
733
+ 'a11y-disallowed-field',
734
+ 'high',
735
+ `a11y.${fieldName} is read; only ${A11Y_FIELDS_LABEL} may be read. Keep roles, state, and structure in component code.`,
736
+ ),
737
+ );
738
+ };
590
739
 
591
740
  traverse(parsed.ast, {
741
+ JSXSpreadAttribute(path) {
742
+ const argument = unwrapExpression(path.node.argument);
743
+
744
+ if (isA11yLike(argument)) {
745
+ findings.push(
746
+ contractFinding(
747
+ filePath,
748
+ path.node,
749
+ 'a11y-whole-object',
750
+ 'high',
751
+ 'The whole a11y object is spread onto an element; every field becomes an editor control.',
752
+ ),
753
+ );
754
+ return;
755
+ }
756
+
757
+ // A spread identifier may alias `a11y` or an `elementProps` part; follow its declaration.
758
+ let chain = memberChain(argument);
759
+ if (t.isIdentifier(argument)) {
760
+ const binding = path.scope.getBinding(argument.name);
761
+ const declarator = binding && binding.path && binding.path.node;
762
+ if (declarator && t.isVariableDeclarator(declarator) && t.isIdentifier(declarator.id)) {
763
+ if (declarator.init && isA11yLike(declarator.init)) {
764
+ findings.push(
765
+ contractFinding(
766
+ filePath,
767
+ path.node,
768
+ 'a11y-whole-object',
769
+ 'high',
770
+ `${argument.name} aliases the a11y object and is spread onto an element; read a11y.ariaLabel instead.`,
771
+ ),
772
+ );
773
+ return;
774
+ }
775
+ chain = memberChain(declarator.init);
776
+ }
777
+ }
778
+ if (isElementPropsPartChain(chain, partsWithA11y)) {
779
+ findings.push(
780
+ contractFinding(
781
+ filePath,
782
+ path.node,
783
+ 'a11y-whole-object',
784
+ 'medium',
785
+ `elementProps.${chain[chain.length - 1]} is spread although its type declares a11y; destructure a11y out first.`,
786
+ ),
787
+ );
788
+ }
789
+ },
790
+
791
+ CallExpression(path) {
792
+ const callee = path.node.callee;
793
+ const isConverter =
794
+ t.isIdentifier(callee, { name: A11Y_CONVERTER }) ||
795
+ ((t.isMemberExpression(callee) || t.isOptionalMemberExpression(callee)) &&
796
+ t.isIdentifier(callee.property, { name: A11Y_CONVERTER }));
797
+ if (isConverter) {
798
+ findings.push(
799
+ contractFinding(
800
+ filePath,
801
+ path.node,
802
+ 'a11y-whole-object',
803
+ 'high',
804
+ `${A11Y_CONVERTER} writes every a11y field to the DOM; read only the field the part needs.`,
805
+ ),
806
+ );
807
+ }
808
+ },
809
+
810
+ 'MemberExpression|OptionalMemberExpression'(path) {
811
+ const { node } = path;
812
+ if (node.computed || !t.isIdentifier(node.property)) return;
813
+ if (!isA11yLike(node.object)) return;
814
+ if (A11Y_FIELDS.has(node.property.name)) return;
815
+ reportDisallowedField(node.property, node.property.name);
816
+ },
817
+
818
+ VariableDeclarator(path) {
819
+ const { id, init } = path.node;
820
+ if (!t.isObjectPattern(id) || !init || !isA11yLike(init)) return;
821
+ for (const property of id.properties) {
822
+ if (t.isRestElement(property)) {
823
+ findings.push(
824
+ contractFinding(
825
+ filePath,
826
+ property,
827
+ 'a11y-whole-object',
828
+ 'high',
829
+ 'Rest-destructuring a11y keeps every field; pick the single field the part needs.',
830
+ ),
831
+ );
832
+ continue;
833
+ }
834
+ if (!t.isObjectProperty(property) || property.computed) continue;
835
+ const fieldName = t.isIdentifier(property.key)
836
+ ? property.key.name
837
+ : t.isStringLiteral(property.key)
838
+ ? property.key.value
839
+ : null;
840
+ if (fieldName && !A11Y_FIELDS.has(fieldName)) reportDisallowedField(property, fieldName);
841
+ }
842
+ },
843
+
592
844
  JSXOpeningElement(path) {
593
845
  const node = path.node;
594
846
  const name = getJsxName(node.name);
@@ -600,6 +852,23 @@ function scanFile(filePath) {
600
852
  );
601
853
  const roleAttr = getAttribute(node, 'role');
602
854
 
855
+ for (const attr of ariaAttrs) {
856
+ const attrName = getJsxName(attr.name);
857
+ if (!HARDCODED_LABEL_ATTRIBUTES.has(attrName)) continue;
858
+ const literal = getLiteralAttributeValue(attr);
859
+ if (typeof literal === 'string' && literal.trim() !== '') {
860
+ findings.push(
861
+ contractFinding(
862
+ filePath,
863
+ attr,
864
+ 'hardcoded-aria-label',
865
+ 'high',
866
+ `${attrName}="${literal}" is a hardcoded string; use visible text, a11y.ariaLabel, or a constants.ts label.`,
867
+ ),
868
+ );
869
+ }
870
+ }
871
+
603
872
  if (semantic.semanticType === 'img' && semantic.confidence !== 'low') {
604
873
  const altAttr = getAttribute(node, 'alt');
605
874
  if (!altAttr) {
@@ -714,35 +983,19 @@ function scanFile(filePath) {
714
983
  },
715
984
  });
716
985
 
717
- return { parseError: null, findings, parseWarnings };
986
+ return { parseError: null, findings };
718
987
  }
719
988
 
720
- function main() {
721
- const files = process.argv.slice(2);
722
- if (files.length === 0) {
723
- console.log(
724
- JSON.stringify(
725
- {
726
- error: 'No files specified.',
727
- usage: 'node <SKILL_ROOT>/scripts/scan-a11y-code.cjs <file1> [file2] ...',
728
- },
729
- null,
730
- 2,
731
- ),
732
- );
733
- process.exit(1);
734
- }
989
+ /** Scan the given files. Any syntax error lands in `meta.parseErrors`; the review treats it as fatal. */
990
+ function scan(files) {
991
+ const absoluteFiles = files.map((file) => path.resolve(file));
992
+ const partsByDir = collectPropsTypes(absoluteFiles);
735
993
 
736
994
  const meta = {
737
995
  filesScanned: files.length,
738
996
  parser: '@babel/parser',
739
- supportedRules: [
740
- 'alt-text',
741
- 'anchor-is-valid',
742
- 'aria-props',
743
- 'aria-role',
744
- 'aria-unsupported-elements',
745
- ],
997
+ supportedRules: SUPPORTED_RULES,
998
+ a11yFields: [...A11Y_FIELDS],
746
999
  confidenceModel: ['high', 'medium', 'low', 'unknown'],
747
1000
  parseErrors: [],
748
1001
  resolutionWarnings,
@@ -750,15 +1003,9 @@ function main() {
750
1003
 
751
1004
  const findings = [];
752
1005
 
753
- for (const file of files.map((file) => path.resolve(file))) {
754
- const result = scanFile(file);
1006
+ for (const file of absoluteFiles) {
1007
+ const result = scanFile(file, { partsWithA11y: partsByDir.get(path.dirname(file)) });
755
1008
  if (result.parseError) meta.parseErrors.push(result.parseError);
756
- for (const warning of result.parseWarnings || []) {
757
- meta.parseErrors.push({
758
- file: toRelative(file),
759
- message: warning,
760
- });
761
- }
762
1009
  findings.push(...result.findings);
763
1010
  }
764
1011
 
@@ -771,7 +1018,30 @@ function main() {
771
1018
  cleanFiles: files.length - new Set(findings.map((item) => item.file)).size,
772
1019
  };
773
1020
 
774
- console.log(JSON.stringify({ meta, findings, summary }, null, 2));
1021
+ return { meta, findings, summary };
775
1022
  }
776
1023
 
777
- main();
1024
+ function main() {
1025
+ const files = process.argv.slice(2);
1026
+ if (files.length === 0) {
1027
+ console.log(
1028
+ JSON.stringify(
1029
+ {
1030
+ error: 'No files specified.',
1031
+ usage: 'node <SKILL_ROOT>/scripts/scan-a11y-code.cjs <file1> [file2] ...',
1032
+ },
1033
+ null,
1034
+ 2,
1035
+ ),
1036
+ );
1037
+ process.exit(1);
1038
+ }
1039
+
1040
+ console.log(JSON.stringify(scan(files), null, 2));
1041
+ }
1042
+
1043
+ module.exports = { scan };
1044
+
1045
+ if (require.main === module) {
1046
+ main();
1047
+ }
@@ -3,6 +3,7 @@
3
3
 
4
4
  // oxlint-disable no-console preserve-caught-error
5
5
 
6
+ const fs = require('fs');
6
7
  const path = require('path');
7
8
  const { createRequire } = require('module');
8
9
 
@@ -34,10 +35,11 @@ const RULE_CONFIG = {
34
35
  'jsx-a11y/no-aria-hidden-on-focusable': 'error',
35
36
  'jsx-a11y/no-autofocus': 'error',
36
37
  'jsx-a11y/no-distracting-elements': 'error',
37
- 'jsx-a11y/no-interactive-element-to-noninteractive-role': 'error',
38
+ 'jsx-a11y/no-interactive-element-to-noninteractive-role': ['error', { canvas: ['img'] }],
38
39
  'jsx-a11y/no-noninteractive-element-interactions': 'error',
39
40
  'jsx-a11y/no-noninteractive-element-to-interactive-role': 'error',
40
- 'jsx-a11y/no-noninteractive-tabindex': 'error',
41
+ // A tabpanel with no focusable content takes tabIndex=0 (ARIA Authoring Practices).
42
+ 'jsx-a11y/no-noninteractive-tabindex': ['error', { roles: ['tabpanel'] }],
41
43
  'jsx-a11y/no-redundant-roles': 'error',
42
44
  'jsx-a11y/no-static-element-interactions': 'error',
43
45
  'jsx-a11y/prefer-tag-over-role': 'error',
@@ -51,6 +53,86 @@ function toRelative(filePath) {
51
53
  return path.relative(ROOT, filePath) || filePath;
52
54
  }
53
55
 
56
+ /** DOM handler → SDK prop it forwards (FUNCTION-HANDLERS.md). */
57
+ const SDK_HANDLER_PROPS = {
58
+ onClick: 'onClick',
59
+ onDoubleClick: 'onDblClick',
60
+ onMouseEnter: 'onMouseIn',
61
+ onMouseLeave: 'onMouseOut',
62
+ };
63
+ const isSdkRoot = (tag) => /\bid=\{\s*(?:props\.)?id\s*\}/.test(tag);
64
+
65
+ /** Every handler on the tag forwards its SDK prop, `onClick={onClick}` or `onMouseEnter={props.onMouseIn}`; anything else makes the root a control. */
66
+ function forwardsOnlySdkHandlers(tag) {
67
+ const handlers = [...tag.matchAll(/\b(on[A-Z]\w*)=\{([^}]*)\}/g)];
68
+ return (
69
+ handlers.length > 0 &&
70
+ handlers.every(([, name, value]) => {
71
+ const sdkProp = SDK_HANDLER_PROPS[name];
72
+ return Boolean(sdkProp) && value.trim().replace(/^props\./, '') === sdkProp;
73
+ })
74
+ );
75
+ }
76
+
77
+ /** Roles whose native element cannot express a styled component. */
78
+ const ROLES_WITHOUT_NATIVE_TAG = new Set(['img', 'presentation', 'none', 'group', 'status']);
79
+
80
+ /**
81
+ * `role="img"` or `role={cond ? 'img' : undefined}`: every string literal in the role
82
+ * attribute must be an exempt role, so `role={cond ? 'button' : 'img'}` stays flagged.
83
+ */
84
+ function roleHasNoNativeTag(tag) {
85
+ const attr = tag.match(/\brole=(?:"[^"]*"|'[^']*'|\{[^}]*\})/);
86
+ if (!attr) return false;
87
+ const literals = [...attr[0].slice(5).matchAll(/["']([^"']*)["']/g)].map((m) => m[1]);
88
+ return literals.length > 0 && literals.every((role) => ROLES_WITHOUT_NATIVE_TAG.has(role));
89
+ }
90
+
91
+ /**
92
+ * Editor React Component patterns that jsx-a11y reads as defects. A rule in
93
+ * `rules` is skipped when the opening tag of the reported element matches `tag`.
94
+ */
95
+ const EXEMPTIONS = [
96
+ {
97
+ // SDK handlers (`onClick`, `onMouseIn`, ...) are forwarded on the root
98
+ // element, which carries `id={id}`; the root itself is not the control.
99
+ rules: new Set([
100
+ 'jsx-a11y/click-events-have-key-events',
101
+ 'jsx-a11y/mouse-events-have-key-events',
102
+ 'jsx-a11y/no-noninteractive-element-interactions',
103
+ 'jsx-a11y/no-static-element-interactions',
104
+ ]),
105
+ tag: (tag) => isSdkRoot(tag) && forwardsOnlySdkHandlers(tag),
106
+ },
107
+ {
108
+ // Roles whose native element cannot express a styled component: graphics
109
+ // (svg, canvas, star ratings), decorative wrappers, widget groups, live
110
+ // regions. axe still requires their names (`role-img-alt`).
111
+ rules: new Set(['jsx-a11y/prefer-tag-over-role']),
112
+ tag: roleHasNoNativeTag,
113
+ },
114
+ ];
115
+
116
+ /** Text of the opening tag that contains the reported range. */
117
+ function openingTag(source, msg) {
118
+ const lines = source.split('\n');
119
+ const offset =
120
+ lines.slice(0, msg.line - 1).reduce((n, line) => n + line.length + 1, 0) + msg.column - 1;
121
+ const start = source.lastIndexOf('<', offset);
122
+ if (start === -1) return '';
123
+ // The tag ends at the first `>` outside braces, so `=>` inside handlers does not count.
124
+ let depth = 0;
125
+ for (let i = start; i < source.length; i++) {
126
+ if (source[i] === '{') depth++;
127
+ else if (source[i] === '}') depth--;
128
+ else if (source[i] === '>' && depth === 0) return source.slice(start, i);
129
+ }
130
+ return '';
131
+ }
132
+
133
+ const isExempt = (source, msg) =>
134
+ EXEMPTIONS.some((x) => x.rules.has(msg.ruleId) && x.tag(openingTag(source, msg)));
135
+
54
136
  function severityLabel(severity) {
55
137
  if (severity === 2) return 'error';
56
138
  if (severity === 1) return 'warning';
@@ -117,22 +199,11 @@ function createEslint() {
117
199
  }
118
200
  }
119
201
 
120
- async function main() {
121
- const files = process.argv.slice(2);
122
- if (files.length === 0) {
123
- console.log(
124
- JSON.stringify(
125
- {
126
- error: 'No files specified.',
127
- usage: 'node <SKILL_ROOT>/scripts/scan-a11y-eslint.cjs <file1> [file2] ...',
128
- },
129
- null,
130
- 2,
131
- ),
132
- );
133
- process.exit(1);
134
- }
135
-
202
+ /**
203
+ * Lint the given files with the jsx-a11y rule set.
204
+ * Returns the same report shape the CLI prints.
205
+ */
206
+ async function scan(files) {
136
207
  const absoluteFiles = files.map((f) => path.resolve(f));
137
208
 
138
209
  const eslint = createEslint();
@@ -144,6 +215,7 @@ async function main() {
144
215
 
145
216
  for (const result of results) {
146
217
  const relFile = toRelative(result.filePath);
218
+ const source = () => result.source || fs.readFileSync(result.filePath, 'utf8');
147
219
 
148
220
  for (const msg of result.messages) {
149
221
  if (msg.fatal) {
@@ -157,6 +229,7 @@ async function main() {
157
229
  }
158
230
 
159
231
  if (!msg.ruleId || !msg.ruleId.startsWith('jsx-a11y/')) continue;
232
+ if (isExempt(source(), msg)) continue;
160
233
 
161
234
  findings.push({
162
235
  file: relFile,
@@ -176,7 +249,7 @@ async function main() {
176
249
  ruleBreakdown[f.rule] = (ruleBreakdown[f.rule] || 0) + 1;
177
250
  }
178
251
 
179
- const output = {
252
+ return {
180
253
  meta: {
181
254
  filesScanned: files.length,
182
255
  engine: 'eslint + eslint-plugin-jsx-a11y',
@@ -191,11 +264,32 @@ async function main() {
191
264
  ruleBreakdown,
192
265
  },
193
266
  };
267
+ }
268
+
269
+ async function main() {
270
+ const files = process.argv.slice(2);
271
+ if (files.length === 0) {
272
+ console.log(
273
+ JSON.stringify(
274
+ {
275
+ error: 'No files specified.',
276
+ usage: 'node <SKILL_ROOT>/scripts/scan-a11y-eslint.cjs <file1> [file2] ...',
277
+ },
278
+ null,
279
+ 2,
280
+ ),
281
+ );
282
+ process.exit(1);
283
+ }
194
284
 
195
- console.log(JSON.stringify(output, null, 2));
285
+ console.log(JSON.stringify(await scan(files), null, 2));
196
286
  }
197
287
 
198
- main().catch((err) => {
199
- console.error(JSON.stringify({ error: err.message }, null, 2));
200
- process.exit(1);
201
- });
288
+ module.exports = { scan, RULE_CONFIG };
289
+
290
+ if (require.main === module) {
291
+ main().catch((err) => {
292
+ console.error(JSON.stringify({ error: err.message }, null, 2));
293
+ process.exit(1);
294
+ });
295
+ }