oxlint-plugin-vue-sfc-a11y 0.1.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/LICENSE +21 -0
- package/README.md +102 -0
- package/configs/recommended.json +9 -0
- package/index.mjs +16 -0
- package/package.json +58 -0
- package/rules/alt-text.mjs +123 -0
- package/rules/aria-props.mjs +67 -0
- package/rules/no-static-element-interactions.mjs +169 -0
- package/rules/tabindex-no-positive.mjs +51 -0
- package/utils/a11y.mjs +225 -0
- package/utils/vue-sfc.mjs +202 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Togetic
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# oxlint-plugin-vue-sfc-a11y
|
|
2
|
+
|
|
3
|
+
Vue SFC accessibility rules for [oxlint](https://oxc.rs), as a JS plugin.
|
|
4
|
+
|
|
5
|
+
oxlint cannot read Vue templates natively ([oxc#15761](https://github.com/oxc-project/oxc/issues/15761)),
|
|
6
|
+
so projects keep ESLint around for `eslint-plugin-vuejs-accessibility`. These rules self-parse the
|
|
7
|
+
SFC with `@vue/compiler-sfc` and walk the real template AST, so the checks run under oxlint instead.
|
|
8
|
+
|
|
9
|
+
Role and element semantics come from [`aria-query`](https://github.com/A11yance/aria-query) — the
|
|
10
|
+
same data source upstream uses — so the interactive-role and interactive-element sets are identical
|
|
11
|
+
rather than re-derived.
|
|
12
|
+
|
|
13
|
+
Companion to [`oxlint-plugin-vue-sfc`](https://www.npmjs.com/package/oxlint-plugin-vue-sfc), which
|
|
14
|
+
ports the `eslint-plugin-vue` template rules.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npm i -D oxlint-plugin-vue-sfc-a11y
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
```jsonc
|
|
23
|
+
{
|
|
24
|
+
"extends": ["./node_modules/oxlint-plugin-vue-sfc-a11y/configs/recommended.json"]
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The npm package is `oxlint-plugin-vue-sfc-a11y` (npm rejects `oxlint-plugin-vue-a11y` as too
|
|
29
|
+
similar to `eslint-plugin-vue-a11y`); the plugin namespace is the shorter `vue-a11y`, so rule ids
|
|
30
|
+
read `vue-a11y/alt-text`. All four rules default to `warn`.
|
|
31
|
+
|
|
32
|
+
## Rules
|
|
33
|
+
|
|
34
|
+
| Rule | Default | Description |
|
|
35
|
+
| --- | --- | --- |
|
|
36
|
+
| `vue-a11y/alt-text` | warn | require a text alternative on `img`, `object`, `area`, `input[type="image"]` |
|
|
37
|
+
| `vue-a11y/aria-props` | warn | disallow `aria-*` attributes that are not in the ARIA spec |
|
|
38
|
+
| `vue-a11y/no-static-element-interactions` | warn | disallow interactive handlers on elements with no keyboard path |
|
|
39
|
+
| `vue-a11y/tabindex-no-positive` | warn | disallow positive `tabindex` |
|
|
40
|
+
|
|
41
|
+
Each is a port of the `vuejs-accessibility/*` rule of the same name, and **defaults to upstream
|
|
42
|
+
behaviour exactly**. Verified against a 1581-SFC production Nuxt codebase: every finding ESLint
|
|
43
|
+
reports, this reports, at identical `line:column`.
|
|
44
|
+
|
|
45
|
+
## `no-static-element-interactions` has options; upstream has none
|
|
46
|
+
|
|
47
|
+
Upstream declares `schema: []`. It fires on several shapes that are correct as written, and with no
|
|
48
|
+
options the only lever left is a project-wide warning ceiling — which suppresses genuine findings
|
|
49
|
+
just as readily as false ones.
|
|
50
|
+
|
|
51
|
+
```jsonc
|
|
52
|
+
"vue-a11y/no-static-element-interactions": ["warn", {
|
|
53
|
+
"allowDynamicRole": true,
|
|
54
|
+
"allowSharedHandlerChild": true,
|
|
55
|
+
"ignoreElements": ["canvas"],
|
|
56
|
+
"ignoreHandlers": ["mousemove"]
|
|
57
|
+
}]
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
| Option | Default | What it exempts |
|
|
61
|
+
| --- | --- | --- |
|
|
62
|
+
| `allowDynamicRole` | `false` | `:role="cond ? 'button' : undefined"` — a bound role upstream cannot read, so it treats it as absent |
|
|
63
|
+
| `allowSharedHandlerChild` | `false` | a wrapper whose `@click` expression is also run from a **keyboard** handler on a descendant — the real control inside |
|
|
64
|
+
| `ignoreElements` | `[]` | tag names to skip outright |
|
|
65
|
+
| `ignoreHandlers` | `[]` | handler names that should not count as interactive (`mousemove` is a pointer gesture, not an action) |
|
|
66
|
+
|
|
67
|
+
### Why `allowSharedHandlerChild` and not "has a focusable child"
|
|
68
|
+
|
|
69
|
+
The obvious version of that option — skip when the subtree contains anything focusable — is wrong,
|
|
70
|
+
and measurably so. On the codebase this was built against it silenced a real finding: a card whose
|
|
71
|
+
`@click` emitted `expand`, wrapping an unrelated text input. The input is tabbable, so the weak test
|
|
72
|
+
passed it; `expand` still had no keyboard path at all.
|
|
73
|
+
|
|
74
|
+
The question worth asking is not "is something in here focusable" but "can this action be reached
|
|
75
|
+
from the keyboard". So the option looks for a descendant running **the same handler expression**
|
|
76
|
+
from a keydown/keypress/keyup handler. That clears the genuine proxying wrapper
|
|
77
|
+
(`<div @click="switchValue">` around `<input @keydown.enter="switchValue">`) and leaves the card
|
|
78
|
+
reported. There is a regression test for both.
|
|
79
|
+
|
|
80
|
+
## Known limitation: diagnostic positions
|
|
81
|
+
|
|
82
|
+
Template diagnostics render on the `<script>` block, with the true position prepended to the
|
|
83
|
+
message:
|
|
84
|
+
|
|
85
|
+
```
|
|
86
|
+
Comp.vue:31:1 warning vue-a11y(no-static-element-interactions): [template 12:5] <div> has an interactive handler...
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
oxlint gives JS-plugin rules a script-relative view of an SFC, so a template position — negative in
|
|
90
|
+
a template-first file — cannot be expressed.
|
|
91
|
+
[oxc#26001](https://github.com/oxc-project/oxc/pull/26001) fixes this upstream with an
|
|
92
|
+
`actualRange`, and would give every rule here correct positions with no rule changes.
|
|
93
|
+
|
|
94
|
+
## Tests
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
npm test # node:test, 54 specs across 4 rules
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## License
|
|
101
|
+
|
|
102
|
+
MIT
|
package/index.mjs
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import altTextRule from "./rules/alt-text.mjs";
|
|
2
|
+
import ariaPropsRule from "./rules/aria-props.mjs";
|
|
3
|
+
import noStaticElementInteractionsRule from "./rules/no-static-element-interactions.mjs";
|
|
4
|
+
import tabindexNoPositiveRule from "./rules/tabindex-no-positive.mjs";
|
|
5
|
+
|
|
6
|
+
const plugin = {
|
|
7
|
+
meta: { name: "vue-a11y" },
|
|
8
|
+
rules: {
|
|
9
|
+
"alt-text": altTextRule,
|
|
10
|
+
"aria-props": ariaPropsRule,
|
|
11
|
+
"no-static-element-interactions": noStaticElementInteractionsRule,
|
|
12
|
+
"tabindex-no-positive": tabindexNoPositiveRule,
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export default plugin;
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "oxlint-plugin-vue-sfc-a11y",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Vue SFC accessibility rules for oxlint, as a JS plugin. Ports of eslint-plugin-vuejs-accessibility rules, with options upstream does not expose.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"oxlint",
|
|
9
|
+
"oxc",
|
|
10
|
+
"vue",
|
|
11
|
+
"sfc",
|
|
12
|
+
"a11y",
|
|
13
|
+
"accessibility",
|
|
14
|
+
"aria",
|
|
15
|
+
"linter",
|
|
16
|
+
"nuxt"
|
|
17
|
+
],
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/Togetic/oxlint-plugin-vue-sfc-a11y.git"
|
|
21
|
+
},
|
|
22
|
+
"bugs": {
|
|
23
|
+
"url": "https://github.com/Togetic/oxlint-plugin-vue-sfc-a11y/issues"
|
|
24
|
+
},
|
|
25
|
+
"homepage": "https://github.com/Togetic/oxlint-plugin-vue-sfc-a11y#readme",
|
|
26
|
+
"author": "Togetic",
|
|
27
|
+
"exports": {
|
|
28
|
+
".": "./index.mjs",
|
|
29
|
+
"./configs/recommended": "./configs/recommended.json"
|
|
30
|
+
},
|
|
31
|
+
"main": "./index.mjs",
|
|
32
|
+
"files": [
|
|
33
|
+
"index.mjs",
|
|
34
|
+
"rules",
|
|
35
|
+
"!rules/*.spec.mjs",
|
|
36
|
+
"utils",
|
|
37
|
+
"configs",
|
|
38
|
+
"README.md",
|
|
39
|
+
"LICENSE"
|
|
40
|
+
],
|
|
41
|
+
"scripts": {
|
|
42
|
+
"test": "node --test \"rules/*.spec.mjs\""
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"@vue/compiler-sfc": "^3.5.0",
|
|
46
|
+
"aria-query": "^5.3.2"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"oxc-parser": "^0.90.0",
|
|
50
|
+
"oxlint": "^1.79.0"
|
|
51
|
+
},
|
|
52
|
+
"peerDependencies": {
|
|
53
|
+
"oxlint": ">=1.0.0"
|
|
54
|
+
},
|
|
55
|
+
"engines": {
|
|
56
|
+
"node": ">=20.19.0"
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { NODE_ELEMENT, parseSfc, reportAtFileOffset, walkTemplate } from "../utils/vue-sfc.mjs";
|
|
2
|
+
import {
|
|
3
|
+
getElementAttribute,
|
|
4
|
+
getAttributeValue,
|
|
5
|
+
getElementAttributeValue,
|
|
6
|
+
getElementType,
|
|
7
|
+
hasAccessibleChild,
|
|
8
|
+
hasAriaLabel,
|
|
9
|
+
isPresentationRole,
|
|
10
|
+
makeKebabCase,
|
|
11
|
+
} from "../utils/a11y.mjs";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Port of `vuejs-accessibility/alt-text`.
|
|
15
|
+
*
|
|
16
|
+
* An image with no text alternative is announced by its filename, or skipped entirely —
|
|
17
|
+
* either way the content is lost. `alt=""` is a real answer, not a missing one: it marks
|
|
18
|
+
* the image decorative so it is skipped deliberately.
|
|
19
|
+
*
|
|
20
|
+
* Four element shapes, matching upstream:
|
|
21
|
+
* img needs `alt` (empty string allowed, and preferred over role="presentation")
|
|
22
|
+
* object needs aria-label / aria-labelledby / title / accessible inner content
|
|
23
|
+
* area needs `alt` or an aria label
|
|
24
|
+
* input[type="image"] needs `alt` or an aria label
|
|
25
|
+
*
|
|
26
|
+
* Options mirror upstream: `elements` narrows which shapes are checked, and a per-shape
|
|
27
|
+
* array maps your own components onto a shape (`{ img: ["CdnImage"] }`).
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const CHECKS = {
|
|
31
|
+
img(report, el) {
|
|
32
|
+
const attribute = getElementAttribute(el, "alt");
|
|
33
|
+
if (!attribute) {
|
|
34
|
+
report(
|
|
35
|
+
el,
|
|
36
|
+
isPresentationRole(el)
|
|
37
|
+
? `Prefer alt="" over role="presentation" — native HTML already expresses "decorative", and the role does not stop the filename being announced everywhere.`
|
|
38
|
+
: `<img> needs an alt attribute: meaningful text, or alt="" if it is decorative. Without one the filename gets announced instead.`,
|
|
39
|
+
);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const altValue = getAttributeValue(attribute);
|
|
43
|
+
if (!altValue && altValue !== "") {
|
|
44
|
+
report(el, `Invalid alt value for <img>. Use alt="" for a decorative image.`);
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
object(report, el) {
|
|
48
|
+
if (!hasAriaLabel(el) && !getElementAttributeValue(el, "title") && !hasAccessibleChild(el)) {
|
|
49
|
+
report(el, "Embedded <object> needs a text alternative — inner text, aria-label, or aria-labelledby.");
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
area(report, el) {
|
|
53
|
+
if (!hasAriaLabel(el) && !getElementAttributeValue(el, "alt")) {
|
|
54
|
+
report(el, "Each <area> of an image map needs a text alternative via alt, aria-label, or aria-labelledby.");
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
'input[type="image"]'(report, el) {
|
|
58
|
+
if (
|
|
59
|
+
getElementAttributeValue(el, "type") === "image" &&
|
|
60
|
+
!hasAriaLabel(el) &&
|
|
61
|
+
!getElementAttributeValue(el, "alt")
|
|
62
|
+
) {
|
|
63
|
+
report(el, `<input type="image"> needs a text alternative via alt, aria-label, or aria-labelledby.`);
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export default {
|
|
69
|
+
meta: {
|
|
70
|
+
type: "problem",
|
|
71
|
+
docs: { description: "require a text alternative on images", recommended: true },
|
|
72
|
+
schema: [
|
|
73
|
+
{
|
|
74
|
+
type: "object",
|
|
75
|
+
properties: Object.fromEntries(
|
|
76
|
+
["elements", ...Object.keys(CHECKS)].map((key) => [
|
|
77
|
+
key,
|
|
78
|
+
{ type: "array", items: { type: "string" }, uniqueItems: true },
|
|
79
|
+
]),
|
|
80
|
+
),
|
|
81
|
+
additionalProperties: false,
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
messages: { m: "" },
|
|
85
|
+
},
|
|
86
|
+
create(context) {
|
|
87
|
+
if (!context.filename.endsWith(".vue")) {
|
|
88
|
+
return {};
|
|
89
|
+
}
|
|
90
|
+
const options = context.options?.[0] ?? {};
|
|
91
|
+
const elements = options.elements ?? Object.keys(CHECKS);
|
|
92
|
+
|
|
93
|
+
// tag name -> which check to run. `input[type="image"]` is keyed by its bare tag.
|
|
94
|
+
const elementTypes = {};
|
|
95
|
+
for (const element of elements) {
|
|
96
|
+
const key = element === 'input[type="image"]' ? "input" : element;
|
|
97
|
+
elementTypes[key] = element;
|
|
98
|
+
for (const matched of options[element] ?? []) {
|
|
99
|
+
elementTypes[makeKebabCase(matched)] = element;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
Program() {
|
|
105
|
+
const entry = parseSfc(context.filename);
|
|
106
|
+
const ast = entry.descriptor.template?.ast;
|
|
107
|
+
if (!ast) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const report = (el, message) =>
|
|
111
|
+
reportAtFileOffset(context, entry, el.loc.start.offset, el.loc.end.offset, message);
|
|
112
|
+
|
|
113
|
+
walkTemplate(ast, (node) => {
|
|
114
|
+
if (node.type !== NODE_ELEMENT) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const check = CHECKS[elementTypes[getElementType(node)]];
|
|
118
|
+
check?.(report, node);
|
|
119
|
+
});
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
},
|
|
123
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { NODE_ELEMENT, PROP_ATTRIBUTE, PROP_DIRECTIVE, parseSfc, reportAtFileOffset, walkTemplate } from "../utils/vue-sfc.mjs";
|
|
2
|
+
import { isValidAriaAttribute } from "../utils/a11y.mjs";
|
|
3
|
+
|
|
4
|
+
/** @vue/compiler-dom NodeTypes.SIMPLE_EXPRESSION */
|
|
5
|
+
const SIMPLE_EXPRESSION = 4;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Port of `vuejs-accessibility/aria-props`.
|
|
9
|
+
*
|
|
10
|
+
* An `aria-*` attribute that is not in the ARIA spec does nothing — it is not a hint that
|
|
11
|
+
* assistive technology ignores gracefully, it is simply absent, so the intended semantics
|
|
12
|
+
* never reach the accessibility tree. Almost always a typo (`aria-labelledBy`,
|
|
13
|
+
* `aria-require`). The valid set comes from `aria-query`, the same source upstream uses.
|
|
14
|
+
*
|
|
15
|
+
* Both static (`aria-labell="x"`) and bound (`:aria-labell="x"`) forms are checked; the
|
|
16
|
+
* NAME is what matters, so the value never needs evaluating.
|
|
17
|
+
*/
|
|
18
|
+
export default {
|
|
19
|
+
meta: {
|
|
20
|
+
type: "problem",
|
|
21
|
+
docs: { description: "disallow invalid `aria-*` attributes", recommended: true },
|
|
22
|
+
schema: [],
|
|
23
|
+
messages: { m: "" },
|
|
24
|
+
},
|
|
25
|
+
create(context) {
|
|
26
|
+
if (!context.filename.endsWith(".vue")) {
|
|
27
|
+
return {};
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
Program() {
|
|
31
|
+
const entry = parseSfc(context.filename);
|
|
32
|
+
const ast = entry.descriptor.template?.ast;
|
|
33
|
+
if (!ast) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
walkTemplate(ast, (node) => {
|
|
37
|
+
if (node.type !== NODE_ELEMENT) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
for (const prop of node.props ?? []) {
|
|
41
|
+
let name = null;
|
|
42
|
+
if (prop.type === PROP_ATTRIBUTE) {
|
|
43
|
+
name = prop.name;
|
|
44
|
+
} else if (
|
|
45
|
+
prop.type === PROP_DIRECTIVE &&
|
|
46
|
+
prop.name === "bind" &&
|
|
47
|
+
prop.arg?.type === SIMPLE_EXPRESSION &&
|
|
48
|
+
prop.arg.isStatic !== false
|
|
49
|
+
) {
|
|
50
|
+
name = prop.arg.content;
|
|
51
|
+
}
|
|
52
|
+
const lowered = name?.toLowerCase();
|
|
53
|
+
if (lowered?.startsWith("aria-") && !isValidAriaAttribute(lowered)) {
|
|
54
|
+
reportAtFileOffset(
|
|
55
|
+
context,
|
|
56
|
+
entry,
|
|
57
|
+
prop.loc.start.offset,
|
|
58
|
+
prop.loc.end.offset,
|
|
59
|
+
`\`${name}\` is not an ARIA attribute, so it reaches the accessibility tree as nothing at all. Check the spelling against the ARIA spec.`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
},
|
|
67
|
+
};
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { NODE_ELEMENT, parseSfc, reportAtFileOffset, walkTemplate } from "../utils/vue-sfc.mjs";
|
|
2
|
+
import {
|
|
3
|
+
INTERACTIVE_HANDLERS,
|
|
4
|
+
getElementAttribute,
|
|
5
|
+
getElementAttributeValue,
|
|
6
|
+
getElementType,
|
|
7
|
+
hasOnDirectives,
|
|
8
|
+
isCustomComponent,
|
|
9
|
+
isHiddenFromScreenReader,
|
|
10
|
+
isInteractiveElement,
|
|
11
|
+
isInteractiveRole,
|
|
12
|
+
isPresentationRole,
|
|
13
|
+
} from "../utils/a11y.mjs";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Port of `vuejs-accessibility/no-static-element-interactions`.
|
|
17
|
+
*
|
|
18
|
+
* A `<div @click>` is reachable with a mouse and invisible to a keyboard: it takes no focus,
|
|
19
|
+
* so there is no way to tab to it and no key that activates it. Either use the element that
|
|
20
|
+
* already means "activate me" (`<button>`), or give this one a role and a tab stop.
|
|
21
|
+
*
|
|
22
|
+
* WHY THIS PORT EXISTS. Upstream declares `schema: []` — no options at all — and fires on
|
|
23
|
+
* several shapes that are correct as written: a wrapper whose click proxies to a real control
|
|
24
|
+
* inside (adding role/tabindex there would double the tab stops), a `<label>` that delegates
|
|
25
|
+
* its semantics to the input it wraps, a pointer-only overlay, and an element whose `:role` is
|
|
26
|
+
* bound so upstream cannot read it and treats it as absent. With no way to express any of
|
|
27
|
+
* that, the only lever left is a project-wide warning ceiling, which suppresses genuine
|
|
28
|
+
* findings just as readily as false ones.
|
|
29
|
+
*
|
|
30
|
+
* The four options below name those shapes. Every one DEFAULTS TO UPSTREAM BEHAVIOUR, so the
|
|
31
|
+
* rule is byte-for-byte upstream until a project opts in.
|
|
32
|
+
*
|
|
33
|
+
* allowSharedHandlerChild
|
|
34
|
+
* skip when a descendant carries a KEYBOARD handler running the same
|
|
35
|
+
* expression as this element's interactive handler — the proxying
|
|
36
|
+
* wrapper, where the click and the keyboard path invoke one function.
|
|
37
|
+
* Deliberately NOT "contains something focusable": measured on a
|
|
38
|
+
* 1581-SFC codebase, that weaker test silenced a real finding (a card
|
|
39
|
+
* whose @click emitted `expand`, containing an unrelated text input —
|
|
40
|
+
* tabbable, but with no way to trigger `expand`).
|
|
41
|
+
* allowDynamicRole skip when `role` is bound (`:role="x"`) and so cannot be read
|
|
42
|
+
* statically. Upstream treats an unreadable role as no role at all.
|
|
43
|
+
* ignoreElements tag names to skip outright (e.g. a pointer-only `canvas` overlay).
|
|
44
|
+
* ignoreHandlers handler names that should not count as "interactive". A `mousemove`
|
|
45
|
+
* is a pointer gesture, not an action a keyboard user can be expected
|
|
46
|
+
* to invoke; `click` is.
|
|
47
|
+
*/
|
|
48
|
+
/** Expressions bound to this element's interactive (pointer) handlers. */
|
|
49
|
+
function pointerHandlerExpressions(el, handlers) {
|
|
50
|
+
const found = new Set();
|
|
51
|
+
for (const prop of el.props ?? []) {
|
|
52
|
+
if (prop.type === 7 && prop.name === "on" && prop.arg?.content && prop.exp?.content) {
|
|
53
|
+
if (handlers.includes(prop.arg.content)) {
|
|
54
|
+
found.add(prop.exp.content.trim());
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return found;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const KEYBOARD_HANDLERS = ["keydown", "keypress", "keyup"];
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Does some descendant run one of this element's pointer-handler expressions from a KEYBOARD
|
|
65
|
+
* handler? That is the proxying wrapper: the click is a convenience, and the real control
|
|
66
|
+
* inside is what a keyboard user reaches and activates.
|
|
67
|
+
*/
|
|
68
|
+
function hasDescendantSharingHandler(el, handlers) {
|
|
69
|
+
const expressions = pointerHandlerExpressions(el, handlers);
|
|
70
|
+
if (!expressions.size) {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
const walk = (node) =>
|
|
74
|
+
(node.children ?? []).some((child) => {
|
|
75
|
+
if (child.type !== NODE_ELEMENT) {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
const keyed = child.props?.some(
|
|
79
|
+
(p) =>
|
|
80
|
+
p.type === 7 &&
|
|
81
|
+
p.name === "on" &&
|
|
82
|
+
KEYBOARD_HANDLERS.includes(p.arg?.content) &&
|
|
83
|
+
expressions.has(p.exp?.content?.trim()),
|
|
84
|
+
);
|
|
85
|
+
return keyed || walk(child);
|
|
86
|
+
});
|
|
87
|
+
return walk(el);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export default {
|
|
91
|
+
meta: {
|
|
92
|
+
type: "problem",
|
|
93
|
+
docs: {
|
|
94
|
+
description: "disallow interactive handlers on non-interactive elements",
|
|
95
|
+
recommended: true,
|
|
96
|
+
},
|
|
97
|
+
schema: [
|
|
98
|
+
{
|
|
99
|
+
type: "object",
|
|
100
|
+
properties: {
|
|
101
|
+
allowSharedHandlerChild: { type: "boolean" },
|
|
102
|
+
allowDynamicRole: { type: "boolean" },
|
|
103
|
+
ignoreElements: { type: "array", items: { type: "string" }, uniqueItems: true },
|
|
104
|
+
ignoreHandlers: { type: "array", items: { type: "string" }, uniqueItems: true },
|
|
105
|
+
},
|
|
106
|
+
additionalProperties: false,
|
|
107
|
+
},
|
|
108
|
+
],
|
|
109
|
+
messages: { m: "" },
|
|
110
|
+
},
|
|
111
|
+
create(context) {
|
|
112
|
+
if (!context.filename.endsWith(".vue")) {
|
|
113
|
+
return {};
|
|
114
|
+
}
|
|
115
|
+
const {
|
|
116
|
+
allowSharedHandlerChild = false,
|
|
117
|
+
allowDynamicRole = false,
|
|
118
|
+
ignoreElements = [],
|
|
119
|
+
ignoreHandlers = [],
|
|
120
|
+
} = context.options?.[0] ?? {};
|
|
121
|
+
|
|
122
|
+
const ignoredElements = new Set(ignoreElements);
|
|
123
|
+
const handlers = ignoreHandlers.length
|
|
124
|
+
? INTERACTIVE_HANDLERS.filter((name) => !ignoreHandlers.includes(name))
|
|
125
|
+
: INTERACTIVE_HANDLERS;
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
Program() {
|
|
129
|
+
const entry = parseSfc(context.filename);
|
|
130
|
+
const ast = entry.descriptor.template?.ast;
|
|
131
|
+
if (!ast) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
walkTemplate(ast, (node) => {
|
|
135
|
+
if (node.type !== NODE_ELEMENT) {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (isCustomComponent(node) || isHiddenFromScreenReader(node) || isPresentationRole(node)) {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (ignoredElements.has(getElementType(node))) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const roleProp = getElementAttribute(node, "role");
|
|
146
|
+
const role = getElementAttributeValue(node, "role");
|
|
147
|
+
if (allowDynamicRole && roleProp && typeof role !== "string") {
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (!hasOnDirectives(node, handlers) || isInteractiveElement(node) || isInteractiveRole(role)) {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (allowSharedHandlerChild && hasDescendantSharingHandler(node, handlers)) {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
reportAtFileOffset(
|
|
159
|
+
context,
|
|
160
|
+
entry,
|
|
161
|
+
node.loc.start.offset,
|
|
162
|
+
node.loc.end.offset,
|
|
163
|
+
`<${node.tag}> has an interactive handler but takes no keyboard focus, so it can only be operated with a pointer. Use a <button>, or add a role and a tab stop.`,
|
|
164
|
+
);
|
|
165
|
+
});
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
},
|
|
169
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { NODE_ELEMENT, parseSfc, reportAtFileOffset, walkTemplate } from "../utils/vue-sfc.mjs";
|
|
2
|
+
import { getLiteralAttributeValue } from "../utils/a11y.mjs";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Port of `vuejs-accessibility/tabindex-no-positive`.
|
|
6
|
+
*
|
|
7
|
+
* A positive tabindex pulls the element out of document order into its own priority tier
|
|
8
|
+
* ahead of every natural tab stop, so it reorders the whole page's keyboard path, not just
|
|
9
|
+
* this element. `0` (focusable, in order) or `-1` (programmatic focus only) are the usable
|
|
10
|
+
* values.
|
|
11
|
+
*
|
|
12
|
+
* Only STATICALLY knowable values are judged — `tabindex="1"` and `:tabindex="1"`, but not
|
|
13
|
+
* `:tabindex="n"`. That matches upstream, which reads only literal bound expressions.
|
|
14
|
+
*/
|
|
15
|
+
export default {
|
|
16
|
+
meta: {
|
|
17
|
+
type: "problem",
|
|
18
|
+
docs: { description: "disallow positive `tabindex` values", recommended: true },
|
|
19
|
+
schema: [],
|
|
20
|
+
messages: { m: "" },
|
|
21
|
+
},
|
|
22
|
+
create(context) {
|
|
23
|
+
if (!context.filename.endsWith(".vue")) {
|
|
24
|
+
return {};
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
Program() {
|
|
28
|
+
const entry = parseSfc(context.filename);
|
|
29
|
+
const ast = entry.descriptor.template?.ast;
|
|
30
|
+
if (!ast) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
walkTemplate(ast, (node) => {
|
|
34
|
+
if (node.type !== NODE_ELEMENT) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const tabIndex = getLiteralAttributeValue(node, "tabindex");
|
|
38
|
+
if ((typeof tabIndex === "string" || typeof tabIndex === "number") && Number(tabIndex) > 0) {
|
|
39
|
+
reportAtFileOffset(
|
|
40
|
+
context,
|
|
41
|
+
entry,
|
|
42
|
+
node.loc.start.offset,
|
|
43
|
+
node.loc.end.offset,
|
|
44
|
+
`tabindex="${tabIndex}" moves this element ahead of every natural tab stop and reorders the page's keyboard path. Use 0 to make it focusable in document order, or -1 for programmatic focus only.`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
},
|
|
51
|
+
};
|
package/utils/a11y.mjs
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import ariaQuery from "aria-query";
|
|
2
|
+
|
|
3
|
+
import { ELEMENT_COMPONENT, NODE_ELEMENT, NODE_TEXT, PROP_ATTRIBUTE, PROP_DIRECTIVE } from "./vue-sfc.mjs";
|
|
4
|
+
|
|
5
|
+
const { aria, dom, elementRoles, roles } = ariaQuery;
|
|
6
|
+
|
|
7
|
+
/** @vue/compiler-dom NodeTypes.SIMPLE_EXPRESSION */
|
|
8
|
+
const SIMPLE_EXPRESSION = 4;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Accessibility helpers, ported from eslint-plugin-vuejs-accessibility's `utils/`.
|
|
12
|
+
*
|
|
13
|
+
* Upstream reads vue-eslint-parser's VElement AST (`node.startTag.attributes`, with
|
|
14
|
+
* `attribute.directive` / `attribute.key.name.name === "bind"`). We read @vue/compiler-sfc's
|
|
15
|
+
* template AST instead, so every attribute accessor below re-expresses the same question
|
|
16
|
+
* against `el.props` — a static ATTRIBUTE prop, or a DIRECTIVE prop named "bind" whose
|
|
17
|
+
* static argument is the attribute name (`:role` / `v-bind:role`).
|
|
18
|
+
*
|
|
19
|
+
* The role/element semantics come from `aria-query`, the same data source upstream uses, so
|
|
20
|
+
* the interactive-role and interactive-element sets are identical rather than re-derived.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const LITERALS = new Map([
|
|
24
|
+
["true", true],
|
|
25
|
+
["false", false],
|
|
26
|
+
["null", null],
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Value of a bound expression when it is a plain JS literal (`:tabindex="1"`, `:alt="''"`).
|
|
31
|
+
* Returns `undefined` for anything that needs runtime evaluation. Mirrors upstream's
|
|
32
|
+
* `expression.type === "Literal"` check, which is the only bound form it reads a value from.
|
|
33
|
+
*/
|
|
34
|
+
function parseLiteral(source) {
|
|
35
|
+
const text = (source ?? "").trim();
|
|
36
|
+
if (LITERALS.has(text)) {
|
|
37
|
+
return LITERALS.get(text);
|
|
38
|
+
}
|
|
39
|
+
if (/^-?\d+(\.\d+)?$/.test(text)) {
|
|
40
|
+
return Number(text);
|
|
41
|
+
}
|
|
42
|
+
const quoted = /^(['"])(.*)\1$/s.exec(text);
|
|
43
|
+
return quoted ? quoted[2] : undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const isBindOf = (prop, name) =>
|
|
47
|
+
prop.type === PROP_DIRECTIVE &&
|
|
48
|
+
prop.name === "bind" &&
|
|
49
|
+
prop.arg?.type === SIMPLE_EXPRESSION &&
|
|
50
|
+
prop.arg.isStatic !== false &&
|
|
51
|
+
prop.arg.content === name;
|
|
52
|
+
|
|
53
|
+
/** The attribute node named `name`, static (`role=`) or bound (`:role=`). */
|
|
54
|
+
export function getElementAttribute(el, name) {
|
|
55
|
+
return el.props?.find((p) => (p.type === PROP_ATTRIBUTE && p.name === name) || isBindOf(p, name)) ?? null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Attribute value. A static attribute yields its string; a bound literal yields the literal.
|
|
60
|
+
* A bound expression yields the EXPRESSION NODE — opaque but truthy, which is exactly how
|
|
61
|
+
* upstream signals "a value is present but not statically knowable" (its comment calls this a
|
|
62
|
+
* placeholder). Callers that compare against a string therefore correctly decline to match.
|
|
63
|
+
*/
|
|
64
|
+
export function getAttributeValue(prop) {
|
|
65
|
+
if (!prop) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
if (prop.type === PROP_ATTRIBUTE) {
|
|
69
|
+
return prop.value ? prop.value.content : null;
|
|
70
|
+
}
|
|
71
|
+
if (!prop.exp) {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
const literal = parseLiteral(prop.exp.content);
|
|
75
|
+
return literal === undefined ? prop.exp : literal;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export const getElementAttributeValue = (el, name) => getAttributeValue(getElementAttribute(el, name));
|
|
79
|
+
|
|
80
|
+
/** Only statically knowable values — a bound non-literal yields null, never a placeholder. */
|
|
81
|
+
export function getLiteralAttributeValue(el, name) {
|
|
82
|
+
const prop = getElementAttribute(el, name);
|
|
83
|
+
if (!prop) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
const value = getAttributeValue(prop);
|
|
87
|
+
return typeof value === "object" && value !== null ? null : value;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export const makeKebabCase = (text) =>
|
|
91
|
+
text.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/[\s_]+/g, "-").toLowerCase();
|
|
92
|
+
|
|
93
|
+
/** Kebab-cased tag name, or the `is` value when it resolves to a string. */
|
|
94
|
+
export function getElementType(el) {
|
|
95
|
+
const is = getElementAttributeValue(el, "is");
|
|
96
|
+
return makeKebabCase(typeof is === "string" ? is : el.tag);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export const isCustomComponent = (el) =>
|
|
100
|
+
el.tagType === ELEMENT_COMPONENT || Boolean(getElementAttribute(el, "is"));
|
|
101
|
+
|
|
102
|
+
export function isPresentationRole(el) {
|
|
103
|
+
const role = getElementAttributeValue(el, "role");
|
|
104
|
+
return role === "presentation" || role === "none";
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function isHiddenFromScreenReader(el) {
|
|
108
|
+
const prop = getElementAttribute(el, "aria-hidden");
|
|
109
|
+
if (!prop) {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
return String(getAttributeValue(prop) ?? "") !== "false";
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export const hasAriaLabel = (el) =>
|
|
116
|
+
Boolean(getElementAttributeValue(el, "aria-label") || getElementAttributeValue(el, "aria-labelledby"));
|
|
117
|
+
|
|
118
|
+
export function hasAccessibleChild(el, accessibleChildTypes = []) {
|
|
119
|
+
return (el.children ?? []).some((child) => {
|
|
120
|
+
if (child.type === NODE_TEXT) {
|
|
121
|
+
return child.content.trim().length > 0;
|
|
122
|
+
}
|
|
123
|
+
if (child.type !== NODE_ELEMENT) {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
return (
|
|
127
|
+
accessibleChildTypes.includes(getElementType(child)) ||
|
|
128
|
+
child.tag === "slot" ||
|
|
129
|
+
(!isHiddenFromScreenReader(child) && hasAccessibleChild(child, accessibleChildTypes))
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Event names that imply the element is meant to be operated by the user. */
|
|
135
|
+
export const INTERACTIVE_HANDLERS = [
|
|
136
|
+
"click", "contextmenu", "dblclick", "doubleclick",
|
|
137
|
+
"drag", "dragend", "dragenter", "dragexit", "dragleave", "dragover", "dragstart", "drop",
|
|
138
|
+
"keydown", "keypress", "keyup",
|
|
139
|
+
"mousedown", "mouseenter", "mouseleave", "mousemove", "mouseout", "mouseover", "mouseup",
|
|
140
|
+
];
|
|
141
|
+
|
|
142
|
+
/** `@click="fn"` / `v-on:click="..."` — must carry an expression, as upstream requires. */
|
|
143
|
+
export const hasOnDirective = (el, name) =>
|
|
144
|
+
Boolean(
|
|
145
|
+
el.props?.some(
|
|
146
|
+
(p) =>
|
|
147
|
+
p.type === PROP_DIRECTIVE &&
|
|
148
|
+
p.name === "on" &&
|
|
149
|
+
p.arg?.type === SIMPLE_EXPRESSION &&
|
|
150
|
+
p.arg.isStatic !== false &&
|
|
151
|
+
p.arg.content === name &&
|
|
152
|
+
p.exp?.content,
|
|
153
|
+
),
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
export const hasOnDirectives = (el, names) => names.some((name) => hasOnDirective(el, name));
|
|
157
|
+
|
|
158
|
+
const interactiveRoles = new Set(["toolbar"]);
|
|
159
|
+
for (const [name, definition] of roles.entries()) {
|
|
160
|
+
if (!definition.abstract && definition.superClass.some((classes) => classes.includes("widget"))) {
|
|
161
|
+
interactiveRoles.add(name);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const interactiveElements = [{ name: "input" }];
|
|
166
|
+
for (const [element, names] of elementRoles.entries()) {
|
|
167
|
+
if ([...names].some((name) => interactiveRoles.has(name))) {
|
|
168
|
+
interactiveElements.push(element);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function isInteractiveRole(value) {
|
|
173
|
+
if (typeof value !== "string") {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
return value
|
|
177
|
+
.toLowerCase()
|
|
178
|
+
.split(" ")
|
|
179
|
+
.some((role) => roles.has(role) && interactiveRoles.has(role));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function matchesElementRole(el, elementRole) {
|
|
183
|
+
const { name, attributes } = elementRole;
|
|
184
|
+
if (name !== getElementType(el)) {
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
return (attributes ?? []).every((attribute) => {
|
|
188
|
+
const value = getElementAttributeValue(el, attribute.name);
|
|
189
|
+
if (attribute.value !== undefined) {
|
|
190
|
+
return value === attribute.value;
|
|
191
|
+
}
|
|
192
|
+
const constraint = attribute.constraints?.[0];
|
|
193
|
+
if (constraint === "set") {
|
|
194
|
+
return Boolean(value);
|
|
195
|
+
}
|
|
196
|
+
if (constraint === "undefined") {
|
|
197
|
+
return !value;
|
|
198
|
+
}
|
|
199
|
+
return Boolean(value);
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function isInteractiveElement(el) {
|
|
204
|
+
if (!dom.has(getElementType(el))) {
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
return interactiveElements.some((elementRole) => matchesElementRole(el, elementRole));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Does this subtree already contain something keyboard-reachable? Upstream ships this helper
|
|
212
|
+
* but does NOT use it in `no-static-element-interactions`; our `allowFocusableChild` option does.
|
|
213
|
+
*/
|
|
214
|
+
export function hasFocusableElement(el) {
|
|
215
|
+
const tabindex = getElementAttributeValue(el, "tabindex");
|
|
216
|
+
if (isInteractiveElement(el)) {
|
|
217
|
+
return String(tabindex) !== "-1";
|
|
218
|
+
}
|
|
219
|
+
if (tabindex !== null && String(tabindex) !== "-1") {
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
return (el.children ?? []).some((child) => child.type === NODE_ELEMENT && hasFocusableElement(child));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export const isValidAriaAttribute = (name) => aria.has(name);
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
|
|
3
|
+
import { parse } from "@vue/compiler-sfc";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Helpers for writing oxlint JS-plugin rules against the Vue TEMPLATE AST.
|
|
7
|
+
*
|
|
8
|
+
* oxlint parses only the script block of an SFC and gives JS-plugin rules a
|
|
9
|
+
* script-only view: `context.sourceCode.text` is the script content and
|
|
10
|
+
* line/column locations passed to `context.report()` are validated against the
|
|
11
|
+
* script's line count (template lines are rejected with a RangeError).
|
|
12
|
+
*
|
|
13
|
+
* Two empirically verified escape hatches make template rules possible anyway:
|
|
14
|
+
* 1. `fs.readFileSync(context.filename)` works inside the sidecar, so a rule
|
|
15
|
+
* can parse the FULL SFC with @vue/compiler-sfc.
|
|
16
|
+
* 2. `context.report({ node: { range: [start, end] } })` accepts raw
|
|
17
|
+
* script-relative byte offsets WITHOUT line validation and maps them to
|
|
18
|
+
* file positions by adding the script block's offset. A template offset is
|
|
19
|
+
* therefore reachable only as `fileOffset - scriptStartOffset` when that is
|
|
20
|
+
* >= 0 — i.e. when <script> precedes <template>.
|
|
21
|
+
*
|
|
22
|
+
* THIS REPO IS ENTIRELY THE OPPOSITE: every SFC is template-first (verified:
|
|
23
|
+
* 1414 template-first, 0 script-first), so template offsets are always
|
|
24
|
+
* NEGATIVE. oxlint rejects negative ranges (and rejects line/column that fall
|
|
25
|
+
* outside the script block), so a template diagnostic CANNOT be rendered on
|
|
26
|
+
* its real template line — the code frame always falls back to the script
|
|
27
|
+
* block's first line (see reportAtFileOffset). This is an inherent limit of
|
|
28
|
+
* oxlint's script-only JS-plugin view of .vue files, not a bug we can fix
|
|
29
|
+
* here; we surface the true template line:column in the message text instead.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
// One parse per file per lint run, shared across rules (module state lives for
|
|
33
|
+
// the lifetime of oxlint's JS-plugin sidecar process).
|
|
34
|
+
const cache = new Map();
|
|
35
|
+
|
|
36
|
+
export function parseSfc(filename) {
|
|
37
|
+
let entry = cache.get(filename);
|
|
38
|
+
if (!entry) {
|
|
39
|
+
const text = fs.readFileSync(filename, "utf8");
|
|
40
|
+
const { descriptor, errors } = parse(text, { filename });
|
|
41
|
+
entry = { text, descriptor, errors };
|
|
42
|
+
cache.set(filename, entry);
|
|
43
|
+
}
|
|
44
|
+
return entry;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Script block content start offset in the file (script setup preferred). */
|
|
48
|
+
export function scriptStartOffset(descriptor) {
|
|
49
|
+
const block = descriptor.scriptSetup ?? descriptor.script;
|
|
50
|
+
return block ? block.loc.start.offset : 0;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Report a diagnostic at an absolute FILE offset range by translating it into
|
|
55
|
+
* the script-relative range oxlint expects. Template positions BEFORE the
|
|
56
|
+
* script block can't be represented as ranges (negative offsets) — the norm in
|
|
57
|
+
* this template-first repo — so those fall back to the script block's first
|
|
58
|
+
* line with the real template line:column PREPENDED to the message, so the
|
|
59
|
+
* editor's problem list and the CLI still point you at the right place even
|
|
60
|
+
* though the squiggle is stuck on the script block.
|
|
61
|
+
*/
|
|
62
|
+
export function reportAtFileOffset(context, entry, fileStart, fileEnd, message) {
|
|
63
|
+
const base = scriptStartOffset(entry.descriptor);
|
|
64
|
+
const start = fileStart - base;
|
|
65
|
+
if (start >= 0) {
|
|
66
|
+
context.report({
|
|
67
|
+
node: { type: "TemplateDiagnostic", range: [start, fileEnd - base] },
|
|
68
|
+
message,
|
|
69
|
+
});
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const before = entry.text.slice(0, fileStart);
|
|
73
|
+
const line = before.split("\n").length;
|
|
74
|
+
const column = fileStart - (before.lastIndexOf("\n") + 1) + 1;
|
|
75
|
+
context.report({
|
|
76
|
+
node: { type: "TemplateDiagnostic", range: [0, 1] },
|
|
77
|
+
message: `[template ${line}:${column}] ${message}`,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Depth-first walk over @vue/compiler-dom template AST element nodes. */
|
|
82
|
+
export function walkTemplate(node, visit) {
|
|
83
|
+
if (!node) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
visit(node);
|
|
87
|
+
if (Array.isArray(node.children)) {
|
|
88
|
+
for (const child of node.children) {
|
|
89
|
+
walkTemplate(child, visit);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// v-if/v-else chains parsed standalone keep branches as siblings, but walk
|
|
93
|
+
// branches too in case a transformed AST is ever passed in.
|
|
94
|
+
if (Array.isArray(node.branches)) {
|
|
95
|
+
for (const branch of node.branches) {
|
|
96
|
+
walkTemplate(branch, visit);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** @vue/compiler-dom NodeTypes used by rules (avoid importing internals). */
|
|
102
|
+
export const NODE_ELEMENT = 1;
|
|
103
|
+
export const NODE_TEXT = 2;
|
|
104
|
+
export const NODE_INTERPOLATION = 5;
|
|
105
|
+
export const PROP_ATTRIBUTE = 6;
|
|
106
|
+
export const PROP_DIRECTIVE = 7;
|
|
107
|
+
/** @vue/compiler-dom ElementTypes.COMPONENT */
|
|
108
|
+
export const ELEMENT_COMPONENT = 1;
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Visit every expression in the template: interpolations (directiveName null)
|
|
112
|
+
* and directive expressions (directiveName e.g. "model", "bind", "if").
|
|
113
|
+
*/
|
|
114
|
+
export function eachTemplateExpression(ast, visit) {
|
|
115
|
+
walkTemplate(ast, (node) => {
|
|
116
|
+
if (node.type === NODE_INTERPOLATION && node.content?.content) {
|
|
117
|
+
visit(node.content, null);
|
|
118
|
+
}
|
|
119
|
+
if (node.type === NODE_ELEMENT) {
|
|
120
|
+
for (const p of node.props ?? []) {
|
|
121
|
+
if (p.type === PROP_DIRECTIVE && p.exp?.content) {
|
|
122
|
+
visit(p.exp, p.name);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function findDirective(el, name) {
|
|
130
|
+
return el.props?.find((p) => p.type === PROP_DIRECTIVE && p.name === name);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function findKeyProp(el) {
|
|
134
|
+
return el.props?.find(
|
|
135
|
+
(p) =>
|
|
136
|
+
(p.type === PROP_ATTRIBUTE && p.name === "key") ||
|
|
137
|
+
(p.type === PROP_DIRECTIVE &&
|
|
138
|
+
p.name === "bind" &&
|
|
139
|
+
p.arg?.type === 4 /* SIMPLE_EXPRESSION */ &&
|
|
140
|
+
p.arg.content === "key"),
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Extract variable names bound by a v-for / v-slot expression ("item",
|
|
146
|
+
* "(item, index)", "{ row }"). Returns null for shapes with renames or
|
|
147
|
+
* defaults (`{ a: b }`, `{ a = 1 }`) — identifying which side is the binding
|
|
148
|
+
* needs a real parser, and a wrong guess produces false positives downstream.
|
|
149
|
+
*/
|
|
150
|
+
export function extractBindingNames(exprText) {
|
|
151
|
+
const left = exprText.split(/\s+(?:in|of)\s+/)[0] ?? exprText;
|
|
152
|
+
if (/[:=]/.test(left)) {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
const names = [...left.matchAll(/[A-Za-z_$][\w$]*/g)].map((m) => m[0]);
|
|
156
|
+
return names.length ? names : null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Collect every identifier bound by a JS destructuring pattern
|
|
161
|
+
* (`a`, `{ a, b }`, `[a, b]`, `a = 1`, `...rest`) into `into`. Script-side rules use
|
|
162
|
+
* this to know which names a declaration / parameter / catch clause introduces.
|
|
163
|
+
*/
|
|
164
|
+
export const collectPatternNames = (pattern, into) => {
|
|
165
|
+
if (!pattern) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
switch (pattern.type) {
|
|
169
|
+
case "Identifier":
|
|
170
|
+
into.add(pattern.name);
|
|
171
|
+
break;
|
|
172
|
+
case "ObjectPattern":
|
|
173
|
+
for (const p of pattern.properties ?? []) {
|
|
174
|
+
collectPatternNames(p.value ?? p.argument, into);
|
|
175
|
+
}
|
|
176
|
+
break;
|
|
177
|
+
case "ArrayPattern":
|
|
178
|
+
for (const el of pattern.elements ?? []) {
|
|
179
|
+
collectPatternNames(el, into);
|
|
180
|
+
}
|
|
181
|
+
break;
|
|
182
|
+
case "AssignmentPattern":
|
|
183
|
+
collectPatternNames(pattern.left, into);
|
|
184
|
+
break;
|
|
185
|
+
case "RestElement":
|
|
186
|
+
collectPatternNames(pattern.argument, into);
|
|
187
|
+
break;
|
|
188
|
+
default:
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
/** Set of static `v-on` event names (`@click`, `v-on:click`) on a template element. */
|
|
194
|
+
export function eventNames(el) {
|
|
195
|
+
const names = new Set();
|
|
196
|
+
for (const p of el.props ?? []) {
|
|
197
|
+
if (p.type === PROP_DIRECTIVE && p.name === "on" && p.arg?.type === 4 /* SIMPLE */) {
|
|
198
|
+
names.add(p.arg.content);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return names;
|
|
202
|
+
}
|