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,68 @@
|
|
|
1
|
+
import {
|
|
2
|
+
NODE_ELEMENT,
|
|
3
|
+
PROP_DIRECTIVE,
|
|
4
|
+
parseSfc,
|
|
5
|
+
reportAtFileOffset,
|
|
6
|
+
walkTemplate,
|
|
7
|
+
} from "../utils/vue-sfc.mjs";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Replacement for `vue/no-lone-template` (no native oxlint equivalent — oxc#15761).
|
|
11
|
+
*
|
|
12
|
+
* `<template>` renders nothing itself; it exists to carry `v-if` / `v-for` / `v-slot` for a
|
|
13
|
+
* group of children. Without one of those it is a no-op wrapper — harmless at runtime, but
|
|
14
|
+
* it misleads the reader into thinking something is conditional or slotted, and it is usually
|
|
15
|
+
* the residue of deleting the directive that justified it.
|
|
16
|
+
*
|
|
17
|
+
* "Has a directive" is the test, not "has attributes": a static attribute on `<template>` is
|
|
18
|
+
* itself useless (that is vue-no-useless-template-attributes' job), so it does not rescue a
|
|
19
|
+
* lone template. Any directive counts — `v-if`, `v-for`, `v-slot`/`#name`, and also less
|
|
20
|
+
* common ones like `v-pre` — because each gives the element a reason to exist.
|
|
21
|
+
*
|
|
22
|
+
* The SFC's own `<template>` block is never flagged: `descriptor.template.ast` is a ROOT node
|
|
23
|
+
* (type 0), not an element, so the walk never sees it as a `<template>` element.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export default {
|
|
27
|
+
meta: {
|
|
28
|
+
type: "suggestion",
|
|
29
|
+
docs: {
|
|
30
|
+
description: "disallow unnecessary `<template>`",
|
|
31
|
+
recommended: false,
|
|
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 || node.tag !== "template") {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const hasDirective = (node.props ?? []).some(
|
|
52
|
+
(prop) => prop.type === PROP_DIRECTIVE,
|
|
53
|
+
);
|
|
54
|
+
if (hasDirective) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
reportAtFileOffset(
|
|
58
|
+
context,
|
|
59
|
+
entry,
|
|
60
|
+
node.loc.start.offset,
|
|
61
|
+
node.loc.end.offset,
|
|
62
|
+
"`<template>` with no `v-if` / `v-for` / `v-slot` renders nothing and groups nothing. Remove the wrapper, or add the directive it was meant to carry.",
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
},
|
|
68
|
+
};
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import {
|
|
2
|
+
NODE_ELEMENT,
|
|
3
|
+
NODE_INTERPOLATION,
|
|
4
|
+
PROP_DIRECTIVE,
|
|
5
|
+
parseSfc,
|
|
6
|
+
reportAtFileOffset,
|
|
7
|
+
walkTemplate,
|
|
8
|
+
} from "../utils/vue-sfc.mjs";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Replacement for `vue/no-mutating-props` (no native oxlint equivalent — oxc#15761).
|
|
12
|
+
*
|
|
13
|
+
* Props are the parent's state. Writing to one changes the child's copy until the parent next
|
|
14
|
+
* re-renders, at which point the value snaps back — so the bug shows up as a field that "won't
|
|
15
|
+
* stay changed", which is a miserable thing to debug. Vue warns at runtime for the direct case and
|
|
16
|
+
* says nothing at all for nested mutation (`props.obj.a = 1`).
|
|
17
|
+
*
|
|
18
|
+
* A CORRELATION rule covering BOTH halves: script assignments via the AST, and template mutations
|
|
19
|
+
* (`@click="props.x++"`, `v-model="props.x"`) via a lexical scan.
|
|
20
|
+
*
|
|
21
|
+
* Deliberately conservative, because a false positive on a prop read would be intolerable — the
|
|
22
|
+
* template side reports ONLY two unambiguous shapes:
|
|
23
|
+
* 1. `v-model` (or `v-model:arg`) whose expression is rooted at a prop. v-model IS an assignment,
|
|
24
|
+
* so there is no ambiguity about intent.
|
|
25
|
+
* 2. An expression containing a prop root followed by an assignment or increment operator
|
|
26
|
+
* (`=` not part of `==`/`===`/`!=`/`>=`/`<=`, or `++`/`--`/`+=`/`-=`/`*=`/`/=`/`%=`).
|
|
27
|
+
* Anything else — a read, a method call, a comparison — is never reported. String literals and
|
|
28
|
+
* comments are blanked before the scan so prose cannot trigger it.
|
|
29
|
+
*
|
|
30
|
+
* The script side is exact rather than lexical: it walks for `AssignmentExpression` and
|
|
31
|
+
* `UpdateExpression` whose target's ROOT object is the props variable or a destructured prop.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
const PROP_FACTORIES = new Set(["defineProps"]);
|
|
35
|
+
const MUTATION_AFTER_ROOT = /^\s*(?:\.\s*[\w$]+|\[[^\]]*\])*\s*(?:\+\+|--|[+\-*/%]=|=(?!=))/;
|
|
36
|
+
|
|
37
|
+
/** Blank string-literal and comment CONTENTS, preserving length so offsets stay valid. */
|
|
38
|
+
function blankStringsAndComments(source) {
|
|
39
|
+
const out = [...source];
|
|
40
|
+
let quote = null;
|
|
41
|
+
let comment = null;
|
|
42
|
+
for (let i = 0; i < out.length; i += 1) {
|
|
43
|
+
const ch = out[i];
|
|
44
|
+
if (comment === "line") {
|
|
45
|
+
out[i] = " ";
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (comment === "block") {
|
|
49
|
+
if (ch === "*" && out[i + 1] === "/") {
|
|
50
|
+
out[i] = " ";
|
|
51
|
+
out[i + 1] = " ";
|
|
52
|
+
i += 1;
|
|
53
|
+
comment = null;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
out[i] = " ";
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (quote === null) {
|
|
60
|
+
if (ch === "/" && out[i + 1] === "/") {
|
|
61
|
+
comment = "line";
|
|
62
|
+
out[i] = " ";
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (ch === "/" && out[i + 1] === "*") {
|
|
66
|
+
comment = "block";
|
|
67
|
+
out[i] = " ";
|
|
68
|
+
out[i + 1] = " ";
|
|
69
|
+
i += 1;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (ch === "'" || ch === '"' || ch === "`") {
|
|
73
|
+
quote = ch;
|
|
74
|
+
}
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (ch === "\\") {
|
|
78
|
+
out[i] = " ";
|
|
79
|
+
if (i + 1 < out.length) {
|
|
80
|
+
out[i + 1] = " ";
|
|
81
|
+
}
|
|
82
|
+
i += 1;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (ch === quote) {
|
|
86
|
+
quote = null;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
out[i] = " ";
|
|
90
|
+
}
|
|
91
|
+
return out.join("");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The props variable name and any destructured prop names. */
|
|
95
|
+
function analyzeProps(program) {
|
|
96
|
+
let propsVar = null;
|
|
97
|
+
const destructured = new Set();
|
|
98
|
+
const isPropsCall = (node) => {
|
|
99
|
+
if (node?.type !== "CallExpression") {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
if (node.callee?.type === "Identifier" && PROP_FACTORIES.has(node.callee.name)) {
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
// `withDefaults(defineProps<…>(), { … })`
|
|
106
|
+
return (
|
|
107
|
+
node.callee?.type === "Identifier" &&
|
|
108
|
+
node.callee.name === "withDefaults" &&
|
|
109
|
+
isPropsCall(node.arguments?.[0])
|
|
110
|
+
);
|
|
111
|
+
};
|
|
112
|
+
const walk = (node) => {
|
|
113
|
+
if (!node || typeof node !== "object") {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (Array.isArray(node)) {
|
|
117
|
+
for (const item of node) {
|
|
118
|
+
walk(item);
|
|
119
|
+
}
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (typeof node.type !== "string") {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (node.type === "VariableDeclarator" && isPropsCall(node.init)) {
|
|
126
|
+
if (node.id?.type === "Identifier") {
|
|
127
|
+
propsVar = node.id.name;
|
|
128
|
+
} else if (node.id?.type === "ObjectPattern") {
|
|
129
|
+
for (const property of node.id.properties ?? []) {
|
|
130
|
+
const target = property.value ?? property.argument;
|
|
131
|
+
if (target?.type === "Identifier") {
|
|
132
|
+
destructured.add(target.name);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
for (const key of Object.keys(node)) {
|
|
138
|
+
if (key === "parent" || key === "loc" || key === "range") {
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
walk(node[key]);
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
walk(program);
|
|
145
|
+
return { propsVar, destructured };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The root identifier of a member chain (`a.b.c` -> `a`), or null. */
|
|
149
|
+
function rootIdentifier(node) {
|
|
150
|
+
let current = node;
|
|
151
|
+
while (current?.type === "MemberExpression") {
|
|
152
|
+
current = current.object;
|
|
153
|
+
}
|
|
154
|
+
return current?.type === "Identifier" ? current : null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export default {
|
|
158
|
+
meta: {
|
|
159
|
+
type: "problem",
|
|
160
|
+
docs: {
|
|
161
|
+
description: "disallow mutation of component props",
|
|
162
|
+
recommended: true,
|
|
163
|
+
},
|
|
164
|
+
schema: [],
|
|
165
|
+
messages: {
|
|
166
|
+
mutating:
|
|
167
|
+
"`{{name}}` is a prop, so it belongs to the parent. Writing to it is overwritten on the parent's next render — emit an event and let the parent own the change.",
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
create(context) {
|
|
171
|
+
if (!context.filename.endsWith(".vue")) {
|
|
172
|
+
return {};
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
Program(program) {
|
|
176
|
+
const { propsVar, destructured } = analyzeProps(program);
|
|
177
|
+
if (!propsVar && !destructured.size) {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const isPropRoot = (name) =>
|
|
181
|
+
(propsVar !== null && name === propsVar) || destructured.has(name);
|
|
182
|
+
|
|
183
|
+
// --- script side: exact AST walk --------------------------------------------
|
|
184
|
+
const walk = (node) => {
|
|
185
|
+
if (!node || typeof node !== "object") {
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (Array.isArray(node)) {
|
|
189
|
+
for (const item of node) {
|
|
190
|
+
walk(item);
|
|
191
|
+
}
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (typeof node.type !== "string") {
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
const target =
|
|
198
|
+
node.type === "AssignmentExpression"
|
|
199
|
+
? node.left
|
|
200
|
+
: node.type === "UpdateExpression"
|
|
201
|
+
? node.argument
|
|
202
|
+
: null;
|
|
203
|
+
if (target) {
|
|
204
|
+
const root = rootIdentifier(target);
|
|
205
|
+
if (root && isPropRoot(root.name)) {
|
|
206
|
+
context.report({
|
|
207
|
+
node: root,
|
|
208
|
+
messageId: "mutating",
|
|
209
|
+
data: { name: root.name },
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
for (const key of Object.keys(node)) {
|
|
214
|
+
if (key === "parent" || key === "loc" || key === "range") {
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
walk(node[key]);
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
walk(program);
|
|
221
|
+
|
|
222
|
+
// --- template side: conservative lexical scan -------------------------------
|
|
223
|
+
const entry = parseSfc(context.filename);
|
|
224
|
+
const ast = entry.descriptor.template?.ast;
|
|
225
|
+
if (!ast) {
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
const roots = [...(propsVar ? [propsVar] : []), ...destructured];
|
|
229
|
+
if (!roots.length) {
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
const rootPattern = new RegExp(
|
|
233
|
+
`\\b(${roots.map((r) => r.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})\\b`,
|
|
234
|
+
"g",
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
const report = (expression, offsetInExpression, name) => {
|
|
238
|
+
const start = expression.loc.start.offset + offsetInExpression;
|
|
239
|
+
reportAtFileOffset(
|
|
240
|
+
context,
|
|
241
|
+
entry,
|
|
242
|
+
start,
|
|
243
|
+
start + name.length,
|
|
244
|
+
`\`${name}\` is a prop, so it belongs to the parent. Writing to it in the template is overwritten on the parent's next render — emit an event and let the parent own the change.`,
|
|
245
|
+
);
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
const scan = (expression, alwaysMutation) => {
|
|
249
|
+
if (!expression?.content) {
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
const text = blankStringsAndComments(expression.content);
|
|
253
|
+
rootPattern.lastIndex = 0;
|
|
254
|
+
let match = rootPattern.exec(text);
|
|
255
|
+
while (match !== null) {
|
|
256
|
+
const after = text.slice(match.index + match[1].length);
|
|
257
|
+
if (alwaysMutation || MUTATION_AFTER_ROOT.test(after)) {
|
|
258
|
+
report(expression, match.index, match[1]);
|
|
259
|
+
}
|
|
260
|
+
match = rootPattern.exec(text);
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
walkTemplate(ast, (node) => {
|
|
265
|
+
if (node.type === NODE_INTERPOLATION) {
|
|
266
|
+
scan(node.content, false);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
if (node.type !== NODE_ELEMENT) {
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
for (const prop of node.props ?? []) {
|
|
273
|
+
if (prop.type !== PROP_DIRECTIVE) {
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
// `v-model` IS an assignment, so its target is unambiguously mutated.
|
|
277
|
+
scan(prop.exp, prop.name === "model");
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
},
|
|
281
|
+
};
|
|
282
|
+
},
|
|
283
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { parseSfc, reportAtFileOffset } from "../utils/vue-sfc.mjs";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Replacement for `vue/no-parsing-error` (no native oxlint equivalent — oxc#15761).
|
|
5
|
+
*
|
|
6
|
+
* Uniquely cheap among the ported rules: we already run `@vue/compiler-sfc`'s parser to get
|
|
7
|
+
* the template AST, and it already collects every syntax error it recovered from. This rule
|
|
8
|
+
* is just surfacing `errors` instead of discarding them — no traversal, no heuristics.
|
|
9
|
+
*
|
|
10
|
+
* Why it earns its place rather than being redundant with the build: the compiler RECOVERS
|
|
11
|
+
* from these and carries on, so a malformed attribute or an unclosed tag can ship silently
|
|
12
|
+
* and only show up as markup that renders slightly wrong. Reporting at lint time makes it a
|
|
13
|
+
* gate instead of a surprise.
|
|
14
|
+
*
|
|
15
|
+
* Errors without a location are still reported, anchored at the start of the file rather
|
|
16
|
+
* than dropped — a parse error we cannot place is more important to surface, not less.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export default {
|
|
20
|
+
meta: {
|
|
21
|
+
type: "problem",
|
|
22
|
+
docs: {
|
|
23
|
+
description: "disallow parsing errors in `<template>`",
|
|
24
|
+
recommended: true,
|
|
25
|
+
},
|
|
26
|
+
schema: [],
|
|
27
|
+
messages: { m: "" },
|
|
28
|
+
},
|
|
29
|
+
create(context) {
|
|
30
|
+
if (!context.filename.endsWith(".vue")) {
|
|
31
|
+
return {};
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
Program() {
|
|
35
|
+
const entry = parseSfc(context.filename);
|
|
36
|
+
for (const error of entry.errors ?? []) {
|
|
37
|
+
const start = error.loc?.start?.offset ?? 0;
|
|
38
|
+
const end = error.loc?.end?.offset ?? start + 1;
|
|
39
|
+
const message = error.message ?? String(error);
|
|
40
|
+
reportAtFileOffset(
|
|
41
|
+
context,
|
|
42
|
+
entry,
|
|
43
|
+
start,
|
|
44
|
+
end,
|
|
45
|
+
`Vue template parsing error: ${message} The compiler recovers from this, so it can ship as subtly wrong markup rather than a build failure.`,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
},
|
|
51
|
+
};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { NODE_ELEMENT, NODE_TEXT, parseSfc, reportAtFileOffset } from "../utils/vue-sfc.mjs";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Replacement for `@intlify/vue-i18n/no-raw-text` (no native oxlint equivalent —
|
|
5
|
+
* oxlint has no i18n plugin). Flags user-facing literal text in the template that
|
|
6
|
+
* is not wrapped in an i18n call, so untranslated strings are caught in review
|
|
7
|
+
* instead of shipping. This repo collects missing keys for translators, so raw
|
|
8
|
+
* text in a template is almost always an oversight.
|
|
9
|
+
*
|
|
10
|
+
* Scope & false-positive guards (template-first repo, so the squiggle lands on the
|
|
11
|
+
* script block — the real template line:column is prepended to the message by
|
|
12
|
+
* reportAtFileOffset):
|
|
13
|
+
* - TEXT nodes only. Interpolations (`{{ $t('…') }}`) are never flagged; static
|
|
14
|
+
* attributes (placeholder/title/…) are out of scope for now.
|
|
15
|
+
* - A text node is flagged only when it contains a 2+ letter word AND at least one
|
|
16
|
+
* lowercase letter. That skips currency tickers / acronyms (BTC, USDT, OK), pure
|
|
17
|
+
* numbers, symbols and punctuation — the dominant non-translatable noise — while
|
|
18
|
+
* still catching real prose ("Buy", "items", "Confirm").
|
|
19
|
+
* - `ignoreTags` skips text inside non-translatable elements (style/script/pre/code).
|
|
20
|
+
* - `ignorePattern` (regex source) lets a project allowlist brand names etc.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const DEFAULT_IGNORE_TAGS = ["style", "script", "pre", "code"];
|
|
24
|
+
const HAS_WORD = /\p{L}{2,}/u;
|
|
25
|
+
const HAS_LOWER = /\p{Ll}/u;
|
|
26
|
+
|
|
27
|
+
export default {
|
|
28
|
+
meta: {
|
|
29
|
+
type: "suggestion",
|
|
30
|
+
docs: {
|
|
31
|
+
description: "disallow untranslated raw text in Vue templates (use i18n)",
|
|
32
|
+
recommended: false,
|
|
33
|
+
},
|
|
34
|
+
schema: [
|
|
35
|
+
{
|
|
36
|
+
type: "object",
|
|
37
|
+
properties: {
|
|
38
|
+
ignorePattern: { type: "string" },
|
|
39
|
+
ignoreTags: { type: "array", items: { type: "string" } },
|
|
40
|
+
},
|
|
41
|
+
additionalProperties: false,
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
messages: { m: "" },
|
|
45
|
+
},
|
|
46
|
+
create(context) {
|
|
47
|
+
if (!context.filename.endsWith(".vue")) {
|
|
48
|
+
return {};
|
|
49
|
+
}
|
|
50
|
+
const opts = context.options?.[0] ?? {};
|
|
51
|
+
const ignoreTags = new Set(opts.ignoreTags ?? DEFAULT_IGNORE_TAGS);
|
|
52
|
+
const ignoreRe = opts.ignorePattern ? new RegExp(opts.ignorePattern, "u") : null;
|
|
53
|
+
return {
|
|
54
|
+
Program() {
|
|
55
|
+
const entry = parseSfc(context.filename);
|
|
56
|
+
const ast = entry.descriptor.template?.ast;
|
|
57
|
+
if (!ast) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const visit = (node) => {
|
|
61
|
+
if (!node) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
// Skip the entire subtree of a non-translatable element, not just its
|
|
65
|
+
// direct text children — text can be nested (`<pre><span>x</span></pre>`).
|
|
66
|
+
if (node.type === NODE_ELEMENT && ignoreTags.has(node.tag)) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (node.type === NODE_TEXT) {
|
|
70
|
+
const text = (node.content ?? "").trim();
|
|
71
|
+
const flag =
|
|
72
|
+
text &&
|
|
73
|
+
HAS_WORD.test(text) &&
|
|
74
|
+
HAS_LOWER.test(text) &&
|
|
75
|
+
!(ignoreRe && ignoreRe.test(text));
|
|
76
|
+
if (flag) {
|
|
77
|
+
const snippet = text.length > 40 ? `${text.slice(0, 39)}…` : text;
|
|
78
|
+
reportAtFileOffset(
|
|
79
|
+
context,
|
|
80
|
+
entry,
|
|
81
|
+
node.loc.start.offset,
|
|
82
|
+
node.loc.end.offset,
|
|
83
|
+
`Raw template text "${snippet}" should be wrapped in an i18n translation ($t).`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
for (const child of node.children ?? []) {
|
|
89
|
+
visit(child);
|
|
90
|
+
}
|
|
91
|
+
for (const branch of node.branches ?? []) {
|
|
92
|
+
visit(branch);
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
visit(ast);
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
},
|
|
99
|
+
};
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { walkWithScopes } from "../utils/js-scope.mjs";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Replacement for `vue/no-ref-as-operand` (no native oxlint equivalent — this one is on oxc's
|
|
5
|
+
* roadmap as script-based-and-feasible but had no PR as of oxlint 1.78; see OXLINT_MIGRATION.md).
|
|
6
|
+
*
|
|
7
|
+
* `const count = ref(0)` then `count + 1` operates on the ref OBJECT, not its value. There is no
|
|
8
|
+
* type error in plain JS and TypeScript often can't save you either (`Ref<number> + number`
|
|
9
|
+
* widens to `string` via valueOf/toString in some positions), so it ships as `"[object Object]1"`
|
|
10
|
+
* or `NaN`. The fix is always `.value`.
|
|
11
|
+
*
|
|
12
|
+
* Unlike the template rules in this plugin, this is a SCRIPT rule: oxlint hands JS plugins the
|
|
13
|
+
* real script AST, so there is no SFC self-parsing, no `reportAtFileOffset`, and positions are
|
|
14
|
+
* already correct — this rule does not depend on oxc#20501 at all.
|
|
15
|
+
*
|
|
16
|
+
* The reported contexts were derived EMPIRICALLY from eslint-plugin-vue, because the set is not
|
|
17
|
+
* what you would guess. Confirmed flagged: binary and logical operands (including `??`), unary
|
|
18
|
+
* arguments (`!`, `-`, `typeof`), a conditional's test, `if` test, `switch` discriminant,
|
|
19
|
+
* template-literal substitutions, and the right side of a COMPOUND assignment (`m += c`).
|
|
20
|
+
* Confirmed NOT flagged, and deliberately left alone:
|
|
21
|
+
* - `while (c)` and `for (;c;)` tests — surprising, but matching upstream matters more than
|
|
22
|
+
* consistency, and being stricter than ESLint would break the parity guarantee.
|
|
23
|
+
* - plain assignment `m = c` — assigning the ref itself is a normal thing to do.
|
|
24
|
+
* - call arguments `fn(c)`, array/object literals `[c]` / `{ k: c }` — passing the ref around
|
|
25
|
+
* is the idiomatic way to share reactivity.
|
|
26
|
+
* - `c.value` in any position.
|
|
27
|
+
*
|
|
28
|
+
* Ref detection resolves through LEXICAL SCOPE (utils/js-scope.mjs), not by name alone. That
|
|
29
|
+
* distinction is not academic: a name-only first cut produced 14 false positives on this repo,
|
|
30
|
+
* every one the same shape — a module-scope `const x = ref(…)` plus an inner-scope
|
|
31
|
+
* `const x = someCall(…)`, where the inner, non-ref binding was being reported. Shadowing has
|
|
32
|
+
* to be respected for this rule to be usable at all.
|
|
33
|
+
*
|
|
34
|
+
* Remaining limits, inherited from the scope helper and all failing toward false negatives:
|
|
35
|
+
* aliased/re-exported ref factories are not followed, `var` is treated as block-scoped, and
|
|
36
|
+
* there is no hoisting. See utils/js-scope.mjs.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
const REF_FACTORIES = new Set(["ref", "computed", "shallowRef", "customRef", "toRef"]);
|
|
40
|
+
|
|
41
|
+
function isRefFactoryCall(init) {
|
|
42
|
+
return (
|
|
43
|
+
init?.type === "CallExpression" &&
|
|
44
|
+
init.callee?.type === "Identifier" &&
|
|
45
|
+
REF_FACTORIES.has(init.callee.name)
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export default {
|
|
50
|
+
meta: {
|
|
51
|
+
type: "problem",
|
|
52
|
+
docs: {
|
|
53
|
+
description: "require `.value` when using a `ref` as an operand",
|
|
54
|
+
recommended: true,
|
|
55
|
+
},
|
|
56
|
+
schema: [],
|
|
57
|
+
messages: {
|
|
58
|
+
refAsOperand:
|
|
59
|
+
"`{{name}}` is a ref, so this operates on the ref object rather than its value. Use `{{name}}.value`.",
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
create(context) {
|
|
63
|
+
return {
|
|
64
|
+
Program(program) {
|
|
65
|
+
const report = (candidate, lookup) => {
|
|
66
|
+
if (candidate?.type !== "Identifier") {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
// `true` only when the NEAREST binding is a ref; a shadowing non-ref
|
|
70
|
+
// binding yields `false`, an unbound name `undefined`.
|
|
71
|
+
if (lookup(candidate.name) !== true) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
context.report({
|
|
75
|
+
node: candidate,
|
|
76
|
+
messageId: "refAsOperand",
|
|
77
|
+
data: { name: candidate.name },
|
|
78
|
+
});
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
walkWithScopes(program, isRefFactoryCall, (node, lookup) => {
|
|
82
|
+
switch (node.type) {
|
|
83
|
+
case "BinaryExpression":
|
|
84
|
+
case "LogicalExpression":
|
|
85
|
+
report(node.left, lookup);
|
|
86
|
+
report(node.right, lookup);
|
|
87
|
+
break;
|
|
88
|
+
case "UnaryExpression":
|
|
89
|
+
report(node.argument, lookup);
|
|
90
|
+
break;
|
|
91
|
+
case "ConditionalExpression":
|
|
92
|
+
report(node.test, lookup);
|
|
93
|
+
break;
|
|
94
|
+
case "IfStatement":
|
|
95
|
+
report(node.test, lookup);
|
|
96
|
+
break;
|
|
97
|
+
case "SwitchStatement":
|
|
98
|
+
report(node.discriminant, lookup);
|
|
99
|
+
break;
|
|
100
|
+
case "TemplateLiteral":
|
|
101
|
+
for (const expression of node.expressions ?? []) {
|
|
102
|
+
report(expression, lookup);
|
|
103
|
+
}
|
|
104
|
+
break;
|
|
105
|
+
case "AssignmentExpression":
|
|
106
|
+
// Compound only: `m += c` reads c's value, `m = c` stores the ref.
|
|
107
|
+
if (node.operator !== "=") {
|
|
108
|
+
report(node.right, lookup);
|
|
109
|
+
}
|
|
110
|
+
break;
|
|
111
|
+
default:
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
},
|
|
118
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import {
|
|
2
|
+
NODE_ELEMENT,
|
|
3
|
+
findDirective,
|
|
4
|
+
findKeyProp,
|
|
5
|
+
parseSfc,
|
|
6
|
+
reportAtFileOffset,
|
|
7
|
+
walkTemplate,
|
|
8
|
+
} from "../utils/vue-sfc.mjs";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Replacement for `vue/no-template-key` (no native oxlint equivalent — oxc#15761).
|
|
12
|
+
*
|
|
13
|
+
* `<template>` is not rendered, so a `key` on it has nothing to identify. Vue either
|
|
14
|
+
* ignores it or warns, and the developer's intent (keying the rendered children) is
|
|
15
|
+
* silently unmet.
|
|
16
|
+
*
|
|
17
|
+
* The one legitimate exception, and it is the common case in Vue 3: `<template v-for>`
|
|
18
|
+
* DOES take the key, precisely because the template stands in for each iteration. So
|
|
19
|
+
* this rule flags `key` on `<template>` only when the template carries no `v-for`.
|
|
20
|
+
* Flagging the v-for case would contradict vue-require-v-for-key, which requires it.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export default {
|
|
24
|
+
meta: {
|
|
25
|
+
type: "problem",
|
|
26
|
+
docs: {
|
|
27
|
+
description: "disallow `key` attribute on `<template>` without `v-for`",
|
|
28
|
+
recommended: true,
|
|
29
|
+
},
|
|
30
|
+
schema: [],
|
|
31
|
+
messages: { m: "" },
|
|
32
|
+
},
|
|
33
|
+
create(context) {
|
|
34
|
+
if (!context.filename.endsWith(".vue")) {
|
|
35
|
+
return {};
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
Program() {
|
|
39
|
+
const entry = parseSfc(context.filename);
|
|
40
|
+
const ast = entry.descriptor.template?.ast;
|
|
41
|
+
if (!ast) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
walkTemplate(ast, (node) => {
|
|
45
|
+
if (node.type !== NODE_ELEMENT || node.tag !== "template") {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const key = findKeyProp(node);
|
|
49
|
+
// `<template v-for :key>` is the Vue 3 idiom and is required by
|
|
50
|
+
// vue-require-v-for-key — never flag it.
|
|
51
|
+
if (!key || findDirective(node, "for")) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
reportAtFileOffset(
|
|
55
|
+
context,
|
|
56
|
+
entry,
|
|
57
|
+
key.loc.start.offset,
|
|
58
|
+
key.loc.end.offset,
|
|
59
|
+
"`<template>` is not rendered, so a `key` on it identifies nothing. Move the key to the rendered child element, or put `v-for` on this `<template>` if you meant to key the iteration.",
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
},
|
|
65
|
+
};
|