@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.
- package/package.json +1 -1
- package/skills/wix-app/SKILL.md +33 -31
- package/skills/wix-app/references/DASHBOARD_PAGE.md +6 -10
- package/skills/wix-app/references/EDITOR_REACT_COMPONENT.md +17 -8
- package/skills/wix-app/references/WIX_PATTERNS_DOCS.md +53 -131
- package/skills/wix-app/references/dashboard-page/UX_SUCCESS_MODEL.md +1 -2
- package/skills/wix-app/references/editor-react-component/ACCESSIBILITY.md +88 -73
- package/skills/wix-app/references/editor-react-component/ANIMATED-COMPONENTS.md +46 -22
- package/skills/wix-app/references/editor-react-component/COMPONENT-CONTRACT.md +20 -6
- package/skills/wix-app/references/editor-react-component/CSS-GUIDELINES.md +9 -0
- package/skills/wix-app/references/editor-react-component/FUNCTION-HANDLERS.md +71 -31
- package/skills/wix-app/references/editor-react-component/PARTS.md +5 -2
- package/skills/wix-app/references/editor-react-component/REACT-GUIDELINES.md +4 -5
- package/skills/wix-app/scripts/pkg-root.cjs +73 -0
- package/skills/wix-app/scripts/scan-a11y-code.cjs +310 -40
- package/skills/wix-app/scripts/scan-a11y-eslint.cjs +118 -24
- package/skills/wix-app/scripts/scan-a11y-render.cjs +551 -0
- package/skills/wix-app/scripts/scan-a11y-review.cjs +373 -0
- package/skills/wix-base44-connector/SKILL.md +67 -43
- package/skills/wix-base44-connector/scripts/utils.cjs +39 -3
- package/skills/wix-docs/SKILL.md +47 -20
- package/skills/wix-headless/SKILL.md +1 -0
- package/skills/wix-headless/entry/bootstrap.mjs +106 -50
- package/skills/wix-headless/entry/skill.md +5 -3
- package/skills/wix-headless/references/CAPABILITIES.md +7 -2
- package/skills/wix-headless/references/SDK_HANDOFF.md +2 -1
- package/skills/wix-headless/references/inline-recipes/experience-store.md +48 -0
- package/skills/wix-headless/references/inline-recipes/how-to-code-a-store.md +350 -114
- package/skills/wix-headless/references/inline-recipes/how-to-code-members-astro.md +1 -1
- package/skills/wix-headless/references/managed/AUTHENTICATION.md +25 -5
- package/skills/wix-headless/references/managed/CREATE.md +5 -0
- package/skills/wix-headless-fast/SKILL.md +25 -7
- package/skills/wix-headless-fast/entry/skill.md +23 -14
- package/skills/wix-headless-fast/install/fast-path.mjs +48 -5
- package/skills/wix-headless-fast/install/pin-agents-md.mjs +57 -0
- package/skills/wix-headless-fast/references/shared/CUSTOM_OPERATIONS.md +3 -3
- package/skills/wix-headless-fast/references/storefront/INSTRUCTIONS.md +204 -35
- package/skills/wix-headless-fast/references/storefront/app/hooks/storefront/useShop.ts +146 -36
- package/skills/wix-headless-fast/references/storefront/app/wix/storefront/cart-store.ts +8 -2
- package/skills/wix-headless-fast/references/storefront/app/wix/storefront/catalog.ts +95 -20
- package/skills/wix-headless-fast/references/storefront/seed/SEED.md +21 -2
- package/skills/wix-headless-fast/references/storefront/seed/seed-store.mjs +104 -18
- package/skills/wix-manage/SKILL.md +16 -10
- package/skills/wix-manage/references/bookings/create-and-update-booking-services.md +4 -1
- package/skills/wix-manage/references/bookings/create-appointment-service.md +3 -1
- package/skills/wix-manage/references/bookings/create-class-service.md +3 -1
- package/skills/wix-manage/references/bookings/create-course-service.md +4 -2
- package/skills/wix-manage/references/bookings/end-to-end-booking-flow.md +68 -3
- package/skills/wix-manage/references/ecommerce/pricing-promotions/ecom-pricing-flow-seasonal-promotion.md +1 -1
- package/skills/wix-manage/references/google-ads/billing-and-payment.md +3 -3
- package/skills/wix-manage/references/google-ads/create-performance-max-campaign.md +8 -8
- package/skills/wix-manage/references/google-ads/get-campaign-suggestions.md +4 -4
- package/skills/wix-manage/references/google-ads/google-ads-dashboard-navigation.md +1 -1
- package/skills/wix-manage/references/google-ads/install-and-create-account.md +9 -9
- package/skills/wix-manage/references/google-ads/manage-campaign-success-guide.md +108 -14
- package/skills/wix-manage/references/google-ads/query-campaign-analytics.md +7 -7
- package/skills/wix-manage/references/seo/manage-content-plan.md +182 -136
- package/skills/wix-manage/references/seo/manage-google-search-console.md +272 -0
- package/skills/wix-manage/references/sites/upload-static-site.md +283 -0
- package/skills/wix-manage/references/stores/change-store-currency.md +1 -1
- package/skills/wix-vibe-headless/platforms/base44-ecom-light-seed-with-install.md +3 -1
- package/skills/wix-vibe-headless/platforms/base44-ecom-light-seed.md +3 -1
- package/skills/wix-vibe-headless/platforms/base44-ecom-light.md +1 -1
- package/skills/wix-vibe-headless/references/storefront/INSTRUCTIONS.md +69 -27
- package/skills/wix-vibe-headless/references/storefront/app/components/CartDrawer.jsx +32 -14
- package/skills/wix-vibe-headless/references/storefront/app/context/CartContext.jsx +14 -3
- package/skills/wix-vibe-headless/references/storefront/app/hooks/useProductCard.js +41 -35
- package/skills/wix-vibe-headless/references/storefront/app/hooks/useProductDetail.js +77 -30
- package/skills/wix-vibe-headless/references/storefront/app/rest/wix-store-cart.js +31 -2
- package/skills/wix-vibe-headless/references/storefront/app/rest/wix-store-catalog.js +31 -6
- package/skills/wix-app/references/dashboard-page/COLLECTION_TOOLKIT.md +0 -80
- package/skills/wix-app/references/dashboard-page/ENTITY_PAGE_TOOLKIT.md +0 -140
- package/skills/wix-app/references/dashboard-page/PATTERNS_BUNDLE_READING.md +0 -112
- package/skills/wix-headless-fast/references/storefront/app/components/storefront/ProductCard.tsx +0 -100
- package/skills/wix-headless-fast/references/storefront/app/components/storefront/ProductDetailView.tsx +0 -130
- package/skills/wix-headless-fast/references/storefront/app/components/storefront/ProductGrid.tsx +0 -48
- package/skills/wix-headless-fast/references/storefront/app/components/storefront/ShopView.tsx +0 -66
- package/skills/wix-headless-fast/references/storefront/app/components/storefront/VariantPicker.tsx +0 -103
- package/skills/wix-headless-fast/references/storefront/app-astro/pages/products/[slug].astro +0 -56
- 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
|
|
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
|
|
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
|
|
986
|
+
return { parseError: null, findings };
|
|
718
987
|
}
|
|
719
988
|
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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(
|
|
285
|
+
console.log(JSON.stringify(await scan(files), null, 2));
|
|
196
286
|
}
|
|
197
287
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
+
}
|