oxlint-plugin-vue-sfc 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 +124 -0
- package/configs/complement.json +20 -0
- package/configs/recommended.json +31 -0
- package/index.mjs +56 -0
- package/package.json +57 -0
- package/rules/no-child-content.mjs +80 -0
- package/rules/no-dupe-v-else-if.mjs +114 -0
- package/rules/no-duplicate-attributes.mjs +106 -0
- package/rules/no-lone-template.mjs +68 -0
- package/rules/no-mutating-props.mjs +283 -0
- package/rules/no-parsing-error.mjs +51 -0
- package/rules/no-raw-text.mjs +99 -0
- package/rules/no-ref-as-operand.mjs +118 -0
- package/rules/no-template-key.mjs +65 -0
- package/rules/no-template-shadow.mjs +108 -0
- package/rules/no-textarea-mustache.mjs +63 -0
- package/rules/no-unused-components.mjs +133 -0
- package/rules/no-unused-vars.mjs +167 -0
- package/rules/no-use-v-if-with-v-for.mjs +69 -0
- package/rules/no-useless-template-attributes.mjs +98 -0
- package/rules/no-v-text-v-html-on-component.mjs +69 -0
- package/rules/require-component-is.mjs +82 -0
- package/rules/require-explicit-emits.mjs +365 -0
- package/rules/require-toggle-inside-transition.mjs +133 -0
- package/rules/require-v-for-key.mjs +80 -0
- package/rules/require-valid-default-prop.mjs +282 -0
- package/rules/this-in-template.mjs +141 -0
- package/rules/use-v-on-exact.mjs +112 -0
- package/rules/v-on-event-hyphenation.mjs +88 -0
- package/utils/js-scope.mjs +172 -0
- package/utils/vue-bindings.mjs +154 -0
- package/utils/vue-sfc.mjs +202 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { NODE_ELEMENT, PROP_DIRECTIVE, parseSfc, reportAtFileOffset } from "../utils/vue-sfc.mjs";
|
|
2
|
+
import {
|
|
3
|
+
bindingsWithOffsets,
|
|
4
|
+
declaredAlias,
|
|
5
|
+
scriptTopLevelNames,
|
|
6
|
+
} from "../utils/vue-bindings.mjs";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Replacement for `vue/no-template-shadow` (no native oxlint equivalent — oxc#15761).
|
|
10
|
+
*
|
|
11
|
+
* A `v-for` alias or `v-slot` binding that reuses a name already in scope hides the outer one for
|
|
12
|
+
* the whole subtree. `<ul v-for="row in rows"><li v-for="row in row.kids">` reads as if the inner
|
|
13
|
+
* loop iterates the outer row, and it does — once. After that every `row` in the subtree means
|
|
14
|
+
* the inner one, so a later reference to the outer row silently resolves to the wrong value.
|
|
15
|
+
* Renaming is free; debugging this is not.
|
|
16
|
+
*
|
|
17
|
+
* This is the plugin's first CORRELATION rule: it needs both halves of the SFC. The template side
|
|
18
|
+
* comes from `parseSfc` as usual, and the script side from oxlint's own `Program(node)` AST, whose
|
|
19
|
+
* top-level bindings are exactly what `<script setup>` exposes to the template. A template alias
|
|
20
|
+
* is reported when it collides with EITHER an enclosing template binding or a script binding —
|
|
21
|
+
* both confirmed against eslint-plugin-vue.
|
|
22
|
+
*
|
|
23
|
+
* Scoping detail that matters: only ANCESTOR template bindings shadow. Two sibling subtrees may
|
|
24
|
+
* each bind `item` without conflict, which is why this walks with an explicit scope stack rather
|
|
25
|
+
* than using the flat `walkTemplate` helper the other rules share.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
export default {
|
|
29
|
+
meta: {
|
|
30
|
+
type: "suggestion",
|
|
31
|
+
docs: {
|
|
32
|
+
description: "disallow variable declarations from shadowing variables declared in the outer scope",
|
|
33
|
+
recommended: true,
|
|
34
|
+
},
|
|
35
|
+
schema: [],
|
|
36
|
+
messages: { m: "" },
|
|
37
|
+
},
|
|
38
|
+
create(context) {
|
|
39
|
+
if (!context.filename.endsWith(".vue")) {
|
|
40
|
+
return {};
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
Program(program) {
|
|
44
|
+
const entry = parseSfc(context.filename);
|
|
45
|
+
const ast = entry.descriptor.template?.ast;
|
|
46
|
+
if (!ast) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const scriptNames = scriptTopLevelNames(program);
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @param node current template node
|
|
53
|
+
* @param outer Set of names visible from ancestors (template aliases only;
|
|
54
|
+
* script names are checked separately so they are never "shadowed away")
|
|
55
|
+
*/
|
|
56
|
+
const visit = (node, outer) => {
|
|
57
|
+
if (!node) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
let scope = outer;
|
|
61
|
+
if (node.type === NODE_ELEMENT) {
|
|
62
|
+
const declared = [];
|
|
63
|
+
for (const prop of node.props ?? []) {
|
|
64
|
+
if (prop.type !== PROP_DIRECTIVE) {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const alias = declaredAlias(prop);
|
|
68
|
+
if (!alias) {
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
for (const binding of bindingsWithOffsets(alias.text)) {
|
|
72
|
+
const collides =
|
|
73
|
+
outer.has(binding.name) || scriptNames.has(binding.name);
|
|
74
|
+
if (collides) {
|
|
75
|
+
const start =
|
|
76
|
+
prop.exp.loc.start.offset + alias.offset + binding.offset;
|
|
77
|
+
reportAtFileOffset(
|
|
78
|
+
context,
|
|
79
|
+
entry,
|
|
80
|
+
start,
|
|
81
|
+
start + binding.name.length,
|
|
82
|
+
`\`${binding.name}\` shadows a ${outer.has(binding.name) ? "variable declared by an enclosing template scope" : "binding from <script>"}. Every reference in this subtree resolves to the inner one — rename it.`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
declared.push(binding.name);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (declared.length) {
|
|
89
|
+
scope = new Set(outer);
|
|
90
|
+
for (const name of declared) {
|
|
91
|
+
scope.add(name);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
for (const child of node.children ?? []) {
|
|
96
|
+
visit(child, scope);
|
|
97
|
+
}
|
|
98
|
+
// v-if chains keep branches off `children` in a transformed AST; harmless here.
|
|
99
|
+
for (const branch of node.branches ?? []) {
|
|
100
|
+
visit(branch, scope);
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
visit(ast, new Set());
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
},
|
|
108
|
+
};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import {
|
|
2
|
+
NODE_ELEMENT,
|
|
3
|
+
NODE_INTERPOLATION,
|
|
4
|
+
parseSfc,
|
|
5
|
+
reportAtFileOffset,
|
|
6
|
+
walkTemplate,
|
|
7
|
+
} from "../utils/vue-sfc.mjs";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Replacement for `vue/no-textarea-mustache` (no native oxlint equivalent — oxc#15761).
|
|
11
|
+
*
|
|
12
|
+
* `<textarea>{{ value }}</textarea>` does not work the way it reads. The interpolation
|
|
13
|
+
* sets the element's initial child text, but a textarea's displayed value comes from its
|
|
14
|
+
* `value` property, and Vue does not keep the two in sync — so the field stops updating
|
|
15
|
+
* as soon as `value` changes. `v-model` (or `:value`) is the working form.
|
|
16
|
+
*
|
|
17
|
+
* Scope: only interpolations that are DIRECT children of the `<textarea>`. A nested
|
|
18
|
+
* element cannot legally appear inside a textarea, so there is nothing deeper to check,
|
|
19
|
+
* and walking deeper would risk flagging malformed markup twice.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export default {
|
|
23
|
+
meta: {
|
|
24
|
+
type: "problem",
|
|
25
|
+
docs: {
|
|
26
|
+
description: "disallow mustaches in `<textarea>`",
|
|
27
|
+
recommended: true,
|
|
28
|
+
},
|
|
29
|
+
schema: [],
|
|
30
|
+
messages: { m: "" },
|
|
31
|
+
},
|
|
32
|
+
create(context) {
|
|
33
|
+
if (!context.filename.endsWith(".vue")) {
|
|
34
|
+
return {};
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
Program() {
|
|
38
|
+
const entry = parseSfc(context.filename);
|
|
39
|
+
const ast = entry.descriptor.template?.ast;
|
|
40
|
+
if (!ast) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
walkTemplate(ast, (node) => {
|
|
44
|
+
if (node.type !== NODE_ELEMENT || node.tag !== "textarea") {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
for (const child of node.children ?? []) {
|
|
48
|
+
if (child.type !== NODE_INTERPOLATION) {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
reportAtFileOffset(
|
|
52
|
+
context,
|
|
53
|
+
entry,
|
|
54
|
+
child.loc.start.offset,
|
|
55
|
+
child.loc.end.offset,
|
|
56
|
+
"Interpolation inside `<textarea>` only sets the initial text and then stops tracking the value. Use `v-model` (or `:value`) instead.",
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
},
|
|
63
|
+
};
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { NODE_ELEMENT, PROP_DIRECTIVE, parseSfc, walkTemplate } from "../utils/vue-sfc.mjs";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Replacement for `vue/no-unused-components` (no native oxlint equivalent — oxc#15761).
|
|
5
|
+
*
|
|
6
|
+
* IMPORTANT — this rule is currently INERT in this repo, by design of the upstream rule rather
|
|
7
|
+
* than by any choice here. `vue/no-unused-components` only inspects Options API registration
|
|
8
|
+
* (`export default { components: { … } }`); it says nothing about `<script setup>`, where an
|
|
9
|
+
* unused component import is caught by `@typescript-eslint/no-unused-vars` instead. This repo has
|
|
10
|
+
* **zero** SFCs with a `components:` block and **zero** non-`setup` `<script>` blocks, so upstream
|
|
11
|
+
* reports nothing here and neither does this port — verified both ways.
|
|
12
|
+
*
|
|
13
|
+
* It is ported anyway, rather than dropped, so the check does not silently disappear the moment
|
|
14
|
+
* someone adds an Options API component. That does mean its correctness rests on synthetic probes
|
|
15
|
+
* matched against eslint-plugin-vue, NOT on the 1581-SFC corpus — the corpus cannot exercise a
|
|
16
|
+
* rule whose trigger does not occur. Recorded in OXLINT_MIGRATION.md.
|
|
17
|
+
*
|
|
18
|
+
* Semantics confirmed against upstream:
|
|
19
|
+
* - Registration is matched case-insensitively across naming styles: `<used />` and `<used-two />`
|
|
20
|
+
* satisfy `Used` and `UsedTwo`, because Vue resolves kebab-case to the PascalCase registration.
|
|
21
|
+
* - `ignoreWhenBindingPresent` defaults to true: if the template contains ANY
|
|
22
|
+
* `<component :is="binding">`, the rule suppresses itself entirely, since the binding could
|
|
23
|
+
* resolve to any registered component. A static `is="Foo"` does not suppress.
|
|
24
|
+
* - The report anchors on the registration property in the script, not on anything in the template.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** `UsedTwo` / `used-two` / `used_two` all normalize to `usedtwo`. */
|
|
28
|
+
function normalizeName(name) {
|
|
29
|
+
return name.replace(/[-_]/g, "").toLowerCase();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The `components: { … }` object expression from an Options API default export, or null. */
|
|
33
|
+
function findComponentsObject(program) {
|
|
34
|
+
let found = null;
|
|
35
|
+
const inspectObject = (object) => {
|
|
36
|
+
for (const property of object.properties ?? []) {
|
|
37
|
+
const key = property.key;
|
|
38
|
+
const name = key?.name ?? key?.value;
|
|
39
|
+
if (name === "components" && property.value?.type === "ObjectExpression") {
|
|
40
|
+
found = property.value;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
for (const statement of program?.body ?? []) {
|
|
45
|
+
if (statement.type !== "ExportDefaultDeclaration") {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const declaration = statement.declaration;
|
|
49
|
+
if (declaration?.type === "ObjectExpression") {
|
|
50
|
+
inspectObject(declaration);
|
|
51
|
+
} else if (
|
|
52
|
+
declaration?.type === "CallExpression" &&
|
|
53
|
+
declaration.arguments?.[0]?.type === "ObjectExpression"
|
|
54
|
+
) {
|
|
55
|
+
// `export default defineComponent({ … })`
|
|
56
|
+
inspectObject(declaration.arguments[0]);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return found;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export default {
|
|
63
|
+
meta: {
|
|
64
|
+
type: "suggestion",
|
|
65
|
+
docs: {
|
|
66
|
+
description: "disallow registering components that are not used inside templates",
|
|
67
|
+
recommended: true,
|
|
68
|
+
},
|
|
69
|
+
schema: [],
|
|
70
|
+
messages: {
|
|
71
|
+
unusedComponent:
|
|
72
|
+
'The "{{name}}" component is registered but never used in the template. Remove the registration, or use the component.',
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
create(context) {
|
|
76
|
+
if (!context.filename.endsWith(".vue")) {
|
|
77
|
+
return {};
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
Program(program) {
|
|
81
|
+
const componentsObject = findComponentsObject(program);
|
|
82
|
+
if (!componentsObject) {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const entry = parseSfc(context.filename);
|
|
86
|
+
const ast = entry.descriptor.template?.ast;
|
|
87
|
+
if (!ast) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const usedTags = new Set();
|
|
92
|
+
let hasDynamicIs = false;
|
|
93
|
+
walkTemplate(ast, (node) => {
|
|
94
|
+
if (node.type !== NODE_ELEMENT) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
usedTags.add(normalizeName(node.tag));
|
|
98
|
+
if (node.tag !== "component") {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
for (const prop of node.props ?? []) {
|
|
102
|
+
const isBoundIs =
|
|
103
|
+
prop.type === PROP_DIRECTIVE &&
|
|
104
|
+
prop.name === "bind" &&
|
|
105
|
+
prop.arg?.content === "is";
|
|
106
|
+
if (isBoundIs) {
|
|
107
|
+
hasDynamicIs = true;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
// A dynamic `:is` could resolve to any registration, so upstream stays quiet.
|
|
112
|
+
if (hasDynamicIs) {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
for (const property of componentsObject.properties ?? []) {
|
|
117
|
+
const name = property.key?.name ?? property.key?.value;
|
|
118
|
+
if (typeof name !== "string") {
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (usedTags.has(normalizeName(name))) {
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
context.report({
|
|
125
|
+
node: property,
|
|
126
|
+
messageId: "unusedComponent",
|
|
127
|
+
data: { name },
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
},
|
|
133
|
+
};
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import {
|
|
2
|
+
NODE_ELEMENT,
|
|
3
|
+
NODE_INTERPOLATION,
|
|
4
|
+
PROP_DIRECTIVE,
|
|
5
|
+
parseSfc,
|
|
6
|
+
reportAtFileOffset,
|
|
7
|
+
walkTemplate,
|
|
8
|
+
} from "../utils/vue-sfc.mjs";
|
|
9
|
+
import {
|
|
10
|
+
bindingsWithOffsets,
|
|
11
|
+
declaredAlias,
|
|
12
|
+
positionalGroups,
|
|
13
|
+
} from "../utils/vue-bindings.mjs";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Replacement for `vue/no-unused-vars` (no native oxlint equivalent — oxc#15761).
|
|
17
|
+
*
|
|
18
|
+
* Note this is the TEMPLATE rule, not `@typescript-eslint/no-unused-vars`: it flags variables
|
|
19
|
+
* introduced BY the template — `v-for` aliases and `v-slot` destructuring — that the template
|
|
20
|
+
* then never reads. An unused alias is usually a leftover from a refactor, and an unused slot
|
|
21
|
+
* prop often means the consumer is reading the wrong name and silently rendering nothing.
|
|
22
|
+
*
|
|
23
|
+
* Semantics confirmed against eslint-plugin-vue case by case:
|
|
24
|
+
* - `v-for="item in items"` with no use of `item` → reported, anchored on `item` itself.
|
|
25
|
+
* - `v-for` aliases are POSITIONAL — `(value, key, index)` — so upstream applies `after-used`
|
|
26
|
+
* logic to them: an unused alias BEFORE a used one is unavoidable and is not reported.
|
|
27
|
+
* `v-for="(_, index) in items"` with `index` used is therefore clean, while
|
|
28
|
+
* `v-for="(item, i)"` with only `item` used does report `i`. Getting this wrong produced 5
|
|
29
|
+
* false positives here, all of the `(_, index)` shape.
|
|
30
|
+
* - `v-slot`/`#name="{ a, b }"` destructuring is NOT positional, so every unused binding is
|
|
31
|
+
* reported regardless of order (confirmed: `{ a, b }` with only `b` used reports `a`).
|
|
32
|
+
* - `_` is NOT exempt. eslint-plugin-vue has an `ignorePattern` option but its default ignores
|
|
33
|
+
* nothing, and our ESLint config never set one — so `v-for="_ in items"` IS reported, and
|
|
34
|
+
* adding an underscore escape hatch here would silently diverge.
|
|
35
|
+
* - A use anywhere in the element's subtree counts, including inside attributes (`:key="o.id"`)
|
|
36
|
+
* and nested children.
|
|
37
|
+
*
|
|
38
|
+
* Binding extraction lives in utils/vue-bindings.mjs (shared with vue-no-template-shadow) and is
|
|
39
|
+
* a positioned scan rather than a full pattern parse. Usage detection here is a word-boundary
|
|
40
|
+
* scan over the subtree's expression sources, so a name that appears only inside a string
|
|
41
|
+
* literal counts as used. Both approximations fail toward FALSE NEGATIVES (a missed unused
|
|
42
|
+
* variable), never toward reporting a variable that is genuinely used.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/** Concatenated expression sources inside `element`'s subtree, excluding `skip`. */
|
|
46
|
+
function subtreeExpressionText(element, skip) {
|
|
47
|
+
const parts = [];
|
|
48
|
+
walkTemplate(element, (node) => {
|
|
49
|
+
if (node.type === NODE_INTERPOLATION && node.content?.content) {
|
|
50
|
+
parts.push(node.content.content);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (node.type !== NODE_ELEMENT) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
for (const prop of node.props ?? []) {
|
|
57
|
+
if (prop.type !== PROP_DIRECTIVE) {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (prop.exp?.content && prop.exp !== skip) {
|
|
61
|
+
parts.push(prop.exp.content);
|
|
62
|
+
}
|
|
63
|
+
// A dynamic argument (`:[key]`, `@[evt]`) reads a binding too.
|
|
64
|
+
if (prop.arg?.content && prop.arg.isStatic === false) {
|
|
65
|
+
parts.push(prop.arg.content);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
return parts.join("\n");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function usesName(text, name) {
|
|
73
|
+
return new RegExp(`\\b${name}\\b`).test(text);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export default {
|
|
77
|
+
meta: {
|
|
78
|
+
type: "suggestion",
|
|
79
|
+
docs: {
|
|
80
|
+
description: "disallow unused variable definitions of v-for directives or scope attributes",
|
|
81
|
+
recommended: true,
|
|
82
|
+
},
|
|
83
|
+
schema: [],
|
|
84
|
+
messages: { m: "" },
|
|
85
|
+
},
|
|
86
|
+
create(context) {
|
|
87
|
+
if (!context.filename.endsWith(".vue")) {
|
|
88
|
+
return {};
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
Program() {
|
|
92
|
+
const entry = parseSfc(context.filename);
|
|
93
|
+
const ast = entry.descriptor.template?.ast;
|
|
94
|
+
if (!ast) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
walkTemplate(ast, (node) => {
|
|
98
|
+
if (node.type !== NODE_ELEMENT) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
for (const prop of node.props ?? []) {
|
|
102
|
+
if (prop.type !== PROP_DIRECTIVE || !prop.exp?.content) {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const alias = declaredAlias(prop);
|
|
106
|
+
if (!alias) {
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
const usage = subtreeExpressionText(node, prop.exp);
|
|
110
|
+
// For `v-for`, the right-hand side is evaluated in the OUTER scope, so it
|
|
111
|
+
// is not a use of the alias — but it lives in the same expression node,
|
|
112
|
+
// which `skip` removed wholesale. Add it back so `v-for="a in a"` and
|
|
113
|
+
// similar do not read as unused.
|
|
114
|
+
const isFor = prop.name === "for";
|
|
115
|
+
const rhs = isFor ? prop.exp.content.slice(alias.text.length) : "";
|
|
116
|
+
const haystack = `${usage}\n${rhs}`;
|
|
117
|
+
const report = (binding, groupOffset) => {
|
|
118
|
+
const start =
|
|
119
|
+
prop.exp.loc.start.offset +
|
|
120
|
+
alias.offset +
|
|
121
|
+
groupOffset +
|
|
122
|
+
binding.offset;
|
|
123
|
+
reportAtFileOffset(
|
|
124
|
+
context,
|
|
125
|
+
entry,
|
|
126
|
+
start,
|
|
127
|
+
start + binding.name.length,
|
|
128
|
+
`\`${binding.name}\` is defined by this ${isFor ? "v-for" : "v-slot"} but never used in the template.`,
|
|
129
|
+
);
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
if (!isFor) {
|
|
133
|
+
// v-slot destructuring is not positional: report every unused name.
|
|
134
|
+
for (const binding of bindingsWithOffsets(alias.text)) {
|
|
135
|
+
if (!usesName(haystack, binding.name)) {
|
|
136
|
+
report(binding, 0);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// v-for: positional, so apply `after-used` — only groups AFTER the last
|
|
143
|
+
// group containing a used binding can be reported.
|
|
144
|
+
const groups = positionalGroups(alias.text).map((group) => ({
|
|
145
|
+
...group,
|
|
146
|
+
bindings: bindingsWithOffsets(group.text),
|
|
147
|
+
}));
|
|
148
|
+
let lastUsed = -1;
|
|
149
|
+
groups.forEach((group, index) => {
|
|
150
|
+
if (group.bindings.some((b) => usesName(haystack, b.name))) {
|
|
151
|
+
lastUsed = index;
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
for (let index = lastUsed + 1; index < groups.length; index += 1) {
|
|
155
|
+
const group = groups[index];
|
|
156
|
+
for (const binding of group.bindings) {
|
|
157
|
+
if (!usesName(haystack, binding.name)) {
|
|
158
|
+
report(binding, group.offset);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
},
|
|
167
|
+
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import {
|
|
2
|
+
NODE_ELEMENT,
|
|
3
|
+
findDirective,
|
|
4
|
+
parseSfc,
|
|
5
|
+
reportAtFileOffset,
|
|
6
|
+
walkTemplate,
|
|
7
|
+
} from "../utils/vue-sfc.mjs";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Replacement for `vue/no-use-v-if-with-v-for` (no native oxlint equivalent — oxc#15761).
|
|
11
|
+
*
|
|
12
|
+
* `v-if` and `v-for` on the SAME element: in Vue 3 `v-if` has the higher priority, so it is
|
|
13
|
+
* evaluated BEFORE the loop variable exists. `<li v-for="u in users" v-if="u.active">` does
|
|
14
|
+
* not filter the list — it throws or silently reads `undefined`, because `u` is not in scope
|
|
15
|
+
* when the condition runs. (Vue 2 had the opposite precedence, which is why this pattern
|
|
16
|
+
* survives in migrated code and reads as if it works.)
|
|
17
|
+
*
|
|
18
|
+
* The fix is either a computed filtered list, or moving `v-if` to a wrapping `<template>`.
|
|
19
|
+
*
|
|
20
|
+
* Reported unconditionally when both are present, matching eslint-plugin-vue's default
|
|
21
|
+
* (`allowUsingIterationVar: false`). The option that permits it exists for Vue 2 semantics;
|
|
22
|
+
* on Vue 3 the pattern is broken regardless of whether the condition touches the iteration
|
|
23
|
+
* variable, so there is nothing to allow.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export default {
|
|
27
|
+
meta: {
|
|
28
|
+
type: "problem",
|
|
29
|
+
docs: {
|
|
30
|
+
description: "disallow use of `v-if` on the same element as `v-for`",
|
|
31
|
+
recommended: true,
|
|
32
|
+
},
|
|
33
|
+
schema: [],
|
|
34
|
+
messages: { m: "" },
|
|
35
|
+
},
|
|
36
|
+
create(context) {
|
|
37
|
+
if (!context.filename.endsWith(".vue")) {
|
|
38
|
+
return {};
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
Program() {
|
|
42
|
+
const entry = parseSfc(context.filename);
|
|
43
|
+
const ast = entry.descriptor.template?.ast;
|
|
44
|
+
if (!ast) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
walkTemplate(ast, (node) => {
|
|
48
|
+
if (node.type !== NODE_ELEMENT) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const vFor = findDirective(node, "for");
|
|
52
|
+
const vIf = findDirective(node, "if");
|
|
53
|
+
if (!vFor || !vIf) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
// Anchor on the `v-if` DIRECTIVE, not its expression: that is where
|
|
57
|
+
// eslint-plugin-vue points (verified at exact column parity).
|
|
58
|
+
reportAtFileOffset(
|
|
59
|
+
context,
|
|
60
|
+
entry,
|
|
61
|
+
vIf.loc.start.offset,
|
|
62
|
+
vIf.loc.end.offset,
|
|
63
|
+
`\`v-if\` on the same element as \`v-for\` runs BEFORE the loop variable exists (v-if has higher priority in Vue 3), so this does not filter the list. Use a computed filtered list, or move the \`v-if\` to a wrapping <template>.`,
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
},
|
|
69
|
+
};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import {
|
|
2
|
+
NODE_ELEMENT,
|
|
3
|
+
PROP_ATTRIBUTE,
|
|
4
|
+
PROP_DIRECTIVE,
|
|
5
|
+
parseSfc,
|
|
6
|
+
reportAtFileOffset,
|
|
7
|
+
walkTemplate,
|
|
8
|
+
} from "../utils/vue-sfc.mjs";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Replacement for `vue/no-useless-template-attributes` (no native oxlint equivalent — oxc#15761).
|
|
12
|
+
*
|
|
13
|
+
* `<template>` is not rendered, so most attributes on it go nowhere. `<template v-if="x"
|
|
14
|
+
* class="row">` looks like it styles the row; the class is silently dropped and the author is
|
|
15
|
+
* left debugging CSS that was never applied.
|
|
16
|
+
*
|
|
17
|
+
* The attributes that DO have meaning on a `<template>`:
|
|
18
|
+
* - the structural directives themselves: `v-if`, `v-else-if`, `v-else`, `v-for`, `v-slot`
|
|
19
|
+
* (`#name`), and `v-pre`.
|
|
20
|
+
* - `key`, but only together with `v-for` — that is the Vue 3 iteration key, required by
|
|
21
|
+
* vue-require-v-for-key. A `key` WITHOUT `v-for` is handled by vue-no-template-key, so it
|
|
22
|
+
* is left alone here to avoid two rules reporting the same token.
|
|
23
|
+
*
|
|
24
|
+
* Everything else — static attributes, `v-bind`, `v-on`, `v-model`, `v-show` — is reported.
|
|
25
|
+
* A lone `<template>` with no directive at all is vue-no-lone-template's finding, not this
|
|
26
|
+
* rule's, so this rule only speaks when the template has at least one meaningful directive.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const MEANINGFUL_DIRECTIVES = new Set(["if", "else-if", "else", "for", "slot", "pre"]);
|
|
30
|
+
|
|
31
|
+
function isKeyProp(prop) {
|
|
32
|
+
if (prop.type === PROP_ATTRIBUTE) {
|
|
33
|
+
return prop.name === "key";
|
|
34
|
+
}
|
|
35
|
+
return (
|
|
36
|
+
prop.name === "bind" &&
|
|
37
|
+
prop.arg?.type === 4 /* SIMPLE_EXPRESSION */ &&
|
|
38
|
+
prop.arg.content === "key"
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export default {
|
|
43
|
+
meta: {
|
|
44
|
+
type: "problem",
|
|
45
|
+
docs: {
|
|
46
|
+
description: "disallow useless attribute on `<template>`",
|
|
47
|
+
recommended: true,
|
|
48
|
+
},
|
|
49
|
+
schema: [],
|
|
50
|
+
messages: { m: "" },
|
|
51
|
+
},
|
|
52
|
+
create(context) {
|
|
53
|
+
if (!context.filename.endsWith(".vue")) {
|
|
54
|
+
return {};
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
Program() {
|
|
58
|
+
const entry = parseSfc(context.filename);
|
|
59
|
+
const ast = entry.descriptor.template?.ast;
|
|
60
|
+
if (!ast) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
walkTemplate(ast, (node) => {
|
|
64
|
+
if (node.type !== NODE_ELEMENT || node.tag !== "template") {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const props = node.props ?? [];
|
|
68
|
+
const structural = props.filter(
|
|
69
|
+
(prop) =>
|
|
70
|
+
prop.type === PROP_DIRECTIVE && MEANINGFUL_DIRECTIVES.has(prop.name),
|
|
71
|
+
);
|
|
72
|
+
// No structural directive at all → vue-no-lone-template's finding.
|
|
73
|
+
if (!structural.length) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
for (const prop of props) {
|
|
77
|
+
if (structural.includes(prop)) {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
// `key` is meaningful with v-for; without it, vue-no-template-key owns it.
|
|
81
|
+
if (isKeyProp(prop)) {
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
const label =
|
|
85
|
+
prop.type === PROP_ATTRIBUTE ? prop.name : `v-${prop.name}`;
|
|
86
|
+
reportAtFileOffset(
|
|
87
|
+
context,
|
|
88
|
+
entry,
|
|
89
|
+
prop.loc.start.offset,
|
|
90
|
+
prop.loc.end.offset,
|
|
91
|
+
`\`${label}\` has no effect on \`<template>\` — the element is not rendered, so the attribute is silently dropped. Move it to the child element you meant to target.`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
},
|
|
98
|
+
};
|