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.
@@ -0,0 +1,69 @@
1
+ import {
2
+ ELEMENT_COMPONENT,
3
+ NODE_ELEMENT,
4
+ findDirective,
5
+ parseSfc,
6
+ reportAtFileOffset,
7
+ walkTemplate,
8
+ } from "../utils/vue-sfc.mjs";
9
+
10
+ /**
11
+ * Replacement for `vue/no-v-text-v-html-on-component` (no native oxlint equivalent — oxc#15761).
12
+ *
13
+ * `v-text` / `v-html` set the host element's textContent/innerHTML. On a COMPONENT that means
14
+ * Vue passes the value as the default slot and the component's own rendered output is thrown
15
+ * away — or, worse, the component renders normally and the directive appears to do nothing.
16
+ * Either way the intent (put this text inside the component) is not what happens; a prop or an
17
+ * explicit slot is.
18
+ *
19
+ * `v-html` on a component carries the additional XSS surface of `v-html` generally, with the
20
+ * component boundary making it easier to miss in review — hence keeping this rule rather than
21
+ * treating it as style.
22
+ *
23
+ * Component detection uses the parser's own `tagType` (`ElementTypes.COMPONENT`), not a
24
+ * casing heuristic: `@vue/compiler-sfc` already resolves `<MyThing>`, `<my-thing>` and
25
+ * `<component>` correctly, and re-deriving it from the tag name would misjudge kebab-case
26
+ * components and custom elements.
27
+ */
28
+
29
+ export default {
30
+ meta: {
31
+ type: "suggestion",
32
+ docs: {
33
+ description: "disallow `v-text` / `v-html` on component",
34
+ recommended: false,
35
+ },
36
+ schema: [],
37
+ messages: { m: "" },
38
+ },
39
+ create(context) {
40
+ if (!context.filename.endsWith(".vue")) {
41
+ return {};
42
+ }
43
+ return {
44
+ Program() {
45
+ const entry = parseSfc(context.filename);
46
+ const ast = entry.descriptor.template?.ast;
47
+ if (!ast) {
48
+ return;
49
+ }
50
+ walkTemplate(ast, (node) => {
51
+ if (node.type !== NODE_ELEMENT || node.tagType !== ELEMENT_COMPONENT) {
52
+ return;
53
+ }
54
+ const directive = findDirective(node, "text") ?? findDirective(node, "html");
55
+ if (!directive) {
56
+ return;
57
+ }
58
+ reportAtFileOffset(
59
+ context,
60
+ entry,
61
+ directive.loc.start.offset,
62
+ directive.loc.end.offset,
63
+ `\`v-${directive.name}\` on the component <${node.tag}> discards its own rendered output instead of putting content inside it. Pass a prop or use an explicit slot.`,
64
+ );
65
+ });
66
+ },
67
+ };
68
+ },
69
+ };
@@ -0,0 +1,82 @@
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/require-component-is` (no native oxlint equivalent — oxc#15761).
12
+ *
13
+ * `<component>` renders whatever `is` names. Without `is` it renders nothing at all —
14
+ * silently, with no warning — so the subtree just disappears. Almost always a rename or
15
+ * a bad merge that dropped the binding.
16
+ *
17
+ * What counts as satisfying the rule:
18
+ * - `:is` / `v-bind:is` — the normal dynamic form.
19
+ * - a static `is="MyComponent"` — unusual but valid and does render, so not flagged.
20
+ * - `v-bind="obj"` (argument-less spread) — `is` may well come from the object. We cannot
21
+ * see inside it, so we treat a spread as satisfying the rule rather than emit a report
22
+ * we cannot substantiate; a false positive here would be on working code.
23
+ */
24
+
25
+ function hasIs(node) {
26
+ for (const prop of node.props ?? []) {
27
+ if (prop.type === PROP_ATTRIBUTE && prop.name === "is") {
28
+ return true;
29
+ }
30
+ if (prop.type === PROP_DIRECTIVE && prop.name === "bind") {
31
+ // Argument-less `v-bind="obj"` spread: `is` might be in there.
32
+ if (!prop.arg) {
33
+ return true;
34
+ }
35
+ if (prop.arg.type === 4 /* SIMPLE_EXPRESSION */ && prop.arg.content === "is") {
36
+ return true;
37
+ }
38
+ }
39
+ }
40
+ return false;
41
+ }
42
+
43
+ export default {
44
+ meta: {
45
+ type: "problem",
46
+ docs: {
47
+ description: "require `v-bind:is` of `<component>` elements",
48
+ recommended: true,
49
+ },
50
+ schema: [],
51
+ messages: { m: "" },
52
+ },
53
+ create(context) {
54
+ if (!context.filename.endsWith(".vue")) {
55
+ return {};
56
+ }
57
+ return {
58
+ Program() {
59
+ const entry = parseSfc(context.filename);
60
+ const ast = entry.descriptor.template?.ast;
61
+ if (!ast) {
62
+ return;
63
+ }
64
+ walkTemplate(ast, (node) => {
65
+ if (node.type !== NODE_ELEMENT || node.tag !== "component") {
66
+ return;
67
+ }
68
+ if (hasIs(node)) {
69
+ return;
70
+ }
71
+ reportAtFileOffset(
72
+ context,
73
+ entry,
74
+ node.loc.start.offset,
75
+ node.loc.end.offset,
76
+ "`<component>` without `is` renders nothing, silently. Add `:is` naming the component to render.",
77
+ );
78
+ });
79
+ },
80
+ };
81
+ },
82
+ };
@@ -0,0 +1,365 @@
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/require-explicit-emits` (no native oxlint equivalent — oxc#15761).
12
+ *
13
+ * An event emitted but not declared in `defineEmits` still fires, so nothing breaks loudly — but
14
+ * the component's contract is now a lie. Consumers reading `defineEmits` do not know the event
15
+ * exists, TypeScript cannot check the payload, and Vue falls back to treating the listener as a
16
+ * native attribute, which can double-fire it on the root element.
17
+ *
18
+ * A CORRELATION rule: declared events come from the script AST (`Program(node)`), emit calls from
19
+ * both the script and the template.
20
+ *
21
+ * Declaration forms handled:
22
+ * - Type, property form: `defineEmits<{ save: []; close: [] }>()`.
23
+ * - Type, CALL-SIGNATURE form: `defineEmits<{ (e: "save"): void }>()`. This is the dominant style
24
+ * in this repo (122+ SFCs) and omitting it made the declared set EMPTY, which turned every emit
25
+ * in those files into a false positive — 219 of them. Handled by reading the string-literal type
26
+ * of a call signature's FIRST parameter.
27
+ * - Runtime array: `defineEmits(["save", "close"])`.
28
+ * - Runtime object: `defineEmits({ save: null })`.
29
+ * - Options API `emits: [...]` / `emits: {...}`.
30
+ * - A NAMED type reference — `defineEmits<CopyInputEmits>()` — resolved against `interface` /
31
+ * `type` declarations in the SAME script. All 15 such references in this repo are local, so no
32
+ * cross-file resolution is needed; an unresolvable reference makes the declared set unknown and
33
+ * the rule stays silent rather than reporting every emit.
34
+ *
35
+ * Emit sites handled: `$emit("x")` and `<emitVar>("x")` in template expressions, and
36
+ * `<emitVar>("x")` in the script.
37
+ *
38
+ * Deliberately conservative — it only reports when BOTH halves are statically known:
39
+ * - If no declaration is found at all, the rule stays silent. A component with no `defineEmits`
40
+ * is a different finding, and guessing would report every emit in the file.
41
+ * - Only literal event names are checked. `emit(dynamicName)` is skipped.
42
+ * The template side is a lexical scan for `$emit(` / `emit(` followed by a quoted literal rather
43
+ * than a parse of each expression; a name built at runtime is therefore invisible, which is a
44
+ * false negative and never a false positive.
45
+ */
46
+
47
+ /** Event names declared by a `defineEmits` call or an `emits:` option. */
48
+ function declaredFrom(node) {
49
+ const names = new Set();
50
+ const fromTypeLiteral = (typeNode) => {
51
+ const walk = (current) => {
52
+ if (!current || typeof current !== "object") {
53
+ return;
54
+ }
55
+ if (Array.isArray(current)) {
56
+ for (const item of current) {
57
+ walk(item);
58
+ }
59
+ return;
60
+ }
61
+ if (
62
+ (current.type === "TSPropertySignature" ||
63
+ current.type === "TSMethodSignature") &&
64
+ current.key
65
+ ) {
66
+ const name = current.key.name ?? current.key.value;
67
+ if (typeof name === "string") {
68
+ names.add(name);
69
+ }
70
+ }
71
+ // `(event: "edit", payload: string): void` — the event name is the FIRST parameter's
72
+ // string-literal type, not a property key.
73
+ if (
74
+ current.type === "TSCallSignatureDeclaration" ||
75
+ current.type === "TSFunctionType"
76
+ ) {
77
+ const first = current.params?.[0] ?? current.parameters?.[0];
78
+ const annotation = first?.typeAnnotation?.typeAnnotation ?? first?.typeAnnotation;
79
+ if (annotation?.type === "TSLiteralType") {
80
+ const value = annotation.literal?.value;
81
+ if (typeof value === "string") {
82
+ names.add(value);
83
+ }
84
+ }
85
+ }
86
+ for (const key of Object.keys(current)) {
87
+ if (key === "parent" || key === "loc" || key === "range") {
88
+ continue;
89
+ }
90
+ walk(current[key]);
91
+ }
92
+ };
93
+ walk(typeNode);
94
+ };
95
+
96
+ const fromValue = (value) => {
97
+ if (value?.type === "ArrayExpression") {
98
+ for (const element of value.elements ?? []) {
99
+ if (typeof element?.value === "string") {
100
+ names.add(element.value);
101
+ }
102
+ }
103
+ } else if (value?.type === "ObjectExpression") {
104
+ for (const property of value.properties ?? []) {
105
+ const name = property.key?.name ?? property.key?.value;
106
+ if (typeof name === "string") {
107
+ names.add(name);
108
+ }
109
+ }
110
+ }
111
+ };
112
+
113
+ if (node.type === "CallExpression") {
114
+ fromValue(node.arguments?.[0]);
115
+ // Type argument key differs across parser versions; scan the node for TS signatures.
116
+ for (const key of Object.keys(node)) {
117
+ if (key.startsWith("type")) {
118
+ fromTypeLiteral(node[key]);
119
+ }
120
+ }
121
+ } else if (node.type?.startsWith("TS")) {
122
+ // A resolved type body: `TSInterfaceBody`, `TSTypeLiteral`, … — scan it for signatures
123
+ // rather than treating it as a runtime value.
124
+ fromTypeLiteral(node);
125
+ } else {
126
+ fromValue(node);
127
+ }
128
+ return names;
129
+ }
130
+
131
+ /** The name of a single `TSTypeReference` type argument on a call, or null. */
132
+ function referencedTypeName(call) {
133
+ for (const key of Object.keys(call)) {
134
+ if (!key.startsWith("type")) {
135
+ continue;
136
+ }
137
+ const container = call[key];
138
+ const params = container?.params ?? container?.parameters;
139
+ const first = Array.isArray(params) ? params[0] : null;
140
+ if (first?.type === "TSTypeReference" && first.typeName?.type === "Identifier") {
141
+ return first.typeName.name;
142
+ }
143
+ }
144
+ return null;
145
+ }
146
+
147
+ /** Map of locally declared type/interface name -> its body node. */
148
+ function localTypeDeclarations(program) {
149
+ const map = new Map();
150
+ const walk = (node) => {
151
+ if (!node || typeof node !== "object") {
152
+ return;
153
+ }
154
+ if (Array.isArray(node)) {
155
+ for (const item of node) {
156
+ walk(item);
157
+ }
158
+ return;
159
+ }
160
+ if (typeof node.type !== "string") {
161
+ return;
162
+ }
163
+ if (node.type === "TSInterfaceDeclaration" && node.id?.name) {
164
+ map.set(node.id.name, node.body);
165
+ }
166
+ if (node.type === "TSTypeAliasDeclaration" && node.id?.name) {
167
+ map.set(node.id.name, node.typeAnnotation);
168
+ }
169
+ for (const key of Object.keys(node)) {
170
+ if (key === "parent" || key === "loc" || key === "range") {
171
+ continue;
172
+ }
173
+ walk(node[key]);
174
+ }
175
+ };
176
+ walk(program);
177
+ return map;
178
+ }
179
+
180
+ /** Walk the program collecting the declared event set and the emit variable name. */
181
+ function analyzeScript(program) {
182
+ const localTypes = localTypeDeclarations(program);
183
+ let declared = null;
184
+ let emitVar = null;
185
+ const walk = (node, parent) => {
186
+ if (!node || typeof node !== "object") {
187
+ return;
188
+ }
189
+ if (Array.isArray(node)) {
190
+ for (const item of node) {
191
+ walk(item, parent);
192
+ }
193
+ return;
194
+ }
195
+ if (typeof node.type !== "string") {
196
+ return;
197
+ }
198
+ if (
199
+ node.type === "CallExpression" &&
200
+ node.callee?.type === "Identifier" &&
201
+ node.callee.name === "defineEmits"
202
+ ) {
203
+ declared = declaredFrom(node);
204
+ // `defineEmits<NamedEmits>()`: resolve the reference against local declarations.
205
+ // If it cannot be resolved, leave `declared` null so the rule stays silent rather
206
+ // than treating an empty set as "nothing is declared" and flagging every emit.
207
+ if (declared.size === 0) {
208
+ const referenced = referencedTypeName(node);
209
+ const body = referenced ? localTypes.get(referenced) : null;
210
+ declared = body ? declaredFrom(body) : null;
211
+ if (declared && declared.size === 0) {
212
+ declared = null;
213
+ }
214
+ }
215
+ if (parent?.type === "VariableDeclarator" && parent.id?.type === "Identifier") {
216
+ emitVar = parent.id.name;
217
+ }
218
+ }
219
+ if (
220
+ node.type === "Property" &&
221
+ (node.key?.name ?? node.key?.value) === "emits" &&
222
+ node.value
223
+ ) {
224
+ declared = declaredFrom(node.value);
225
+ }
226
+ for (const key of Object.keys(node)) {
227
+ if (key === "parent" || key === "loc" || key === "range") {
228
+ continue;
229
+ }
230
+ walk(node[key], node);
231
+ }
232
+ };
233
+ walk(program, null);
234
+ return { declared, emitVar };
235
+ }
236
+
237
+ /** Literal event names emitted in `text`, with the offset of each name's opening quote. */
238
+ function emitSites(text, emitVar) {
239
+ // NOTE: no `\b` before `$emit`. `$` is not a word character, so a word boundary can never
240
+ // match at the start of an expression like `$emit('x')` — that mistake silently disabled the
241
+ // whole template side of this rule. `$emit` is distinctive enough not to need a guard (and
242
+ // `this.$emit(...)` should match too); the script variable does get a `\b`.
243
+ const escaped = emitVar ? emitVar.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") : null;
244
+ const callees = ["\\$emit", ...(escaped ? [`\\b${escaped}`] : [])];
245
+ const pattern = new RegExp(`(?:${callees.join("|")})\\s*\\(\\s*(['"])([^'"]*)\\1`, "g");
246
+ const sites = [];
247
+ let match = pattern.exec(text);
248
+ while (match !== null) {
249
+ // index of the opening quote inside the whole match
250
+ const quoteOffset = match[0].lastIndexOf(match[1], match[0].length - match[2].length - 1);
251
+ sites.push({ name: match[2], offset: match.index + quoteOffset });
252
+ match = pattern.exec(text);
253
+ }
254
+ return sites;
255
+ }
256
+
257
+ export default {
258
+ meta: {
259
+ type: "problem",
260
+ docs: {
261
+ description: "require `emits` option with name triggered by `$emit()`",
262
+ recommended: true,
263
+ },
264
+ schema: [],
265
+ messages: {
266
+ undeclared:
267
+ 'The "{{name}}" event is emitted but not declared in `defineEmits`, so the component\'s contract does not mention it and its payload is unchecked.',
268
+ },
269
+ },
270
+ create(context) {
271
+ if (!context.filename.endsWith(".vue")) {
272
+ return {};
273
+ }
274
+ return {
275
+ Program(program) {
276
+ const { declared, emitVar } = analyzeScript(program);
277
+ // No declaration at all: reporting every emit would be a different rule.
278
+ if (!declared) {
279
+ return;
280
+ }
281
+
282
+ // --- script side: emitVar("name") -------------------------------------------
283
+ if (emitVar) {
284
+ const walk = (node) => {
285
+ if (!node || typeof node !== "object") {
286
+ return;
287
+ }
288
+ if (Array.isArray(node)) {
289
+ for (const item of node) {
290
+ walk(item);
291
+ }
292
+ return;
293
+ }
294
+ if (typeof node.type !== "string") {
295
+ return;
296
+ }
297
+ if (
298
+ node.type === "CallExpression" &&
299
+ node.callee?.type === "Identifier" &&
300
+ node.callee.name === emitVar
301
+ ) {
302
+ const first = node.arguments?.[0];
303
+ if (
304
+ typeof first?.value === "string" &&
305
+ !declared.has(first.value)
306
+ ) {
307
+ context.report({
308
+ node: first,
309
+ messageId: "undeclared",
310
+ data: { name: first.value },
311
+ });
312
+ }
313
+ }
314
+ for (const key of Object.keys(node)) {
315
+ if (key === "parent" || key === "loc" || key === "range") {
316
+ continue;
317
+ }
318
+ walk(node[key]);
319
+ }
320
+ };
321
+ walk(program);
322
+ }
323
+
324
+ // --- template side: $emit("name") / emitVar("name") -------------------------
325
+ const entry = parseSfc(context.filename);
326
+ const ast = entry.descriptor.template?.ast;
327
+ if (!ast) {
328
+ return;
329
+ }
330
+ const check = (expression) => {
331
+ if (!expression?.content) {
332
+ return;
333
+ }
334
+ for (const site of emitSites(expression.content, emitVar)) {
335
+ if (declared.has(site.name)) {
336
+ continue;
337
+ }
338
+ const start = expression.loc.start.offset + site.offset;
339
+ reportAtFileOffset(
340
+ context,
341
+ entry,
342
+ start,
343
+ start + site.name.length + 2,
344
+ `The "${site.name}" event is emitted but not declared in \`defineEmits\`, so the component's contract does not mention it and its payload is unchecked.`,
345
+ );
346
+ }
347
+ };
348
+ walkTemplate(ast, (node) => {
349
+ if (node.type === NODE_INTERPOLATION) {
350
+ check(node.content);
351
+ return;
352
+ }
353
+ if (node.type !== NODE_ELEMENT) {
354
+ return;
355
+ }
356
+ for (const prop of node.props ?? []) {
357
+ if (prop.type === PROP_DIRECTIVE) {
358
+ check(prop.exp);
359
+ }
360
+ }
361
+ });
362
+ },
363
+ };
364
+ },
365
+ };
@@ -0,0 +1,133 @@
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/require-toggle-inside-transition` (no native oxlint equivalent — oxc#15761).
12
+ *
13
+ * `<Transition>` animates an element ENTERING or LEAVING. If its content is never toggled,
14
+ * nothing ever enters or leaves, so the transition is inert — the CSS is written, reviewed and
15
+ * shipped, and simply never runs. Usually the toggle was removed, or was put on the
16
+ * `<Transition>` itself instead of on the child.
17
+ *
18
+ * Scope was derived EMPIRICALLY from eslint-plugin-vue rather than from its docs, after a first
19
+ * implementation produced 10 corpus false positives. Probed behaviour, all confirmed against
20
+ * ESLint on the same fixtures:
21
+ * - Only a direct child that is a PLAIN HTML element can trigger the report. Components,
22
+ * `<slot>`, `<template>`, and text/interpolation children are all exempt — their rendered
23
+ * content is not statically knowable, so the rule cannot conclude nothing toggles.
24
+ * - Nesting is NOT descended: `<Transition><div><span v-if>` IS reported, because the direct
25
+ * child `<div>` is what would have to enter or leave.
26
+ * - A toggle on the `<Transition>` ITSELF does not satisfy it (confirmed: ESLint still reports).
27
+ * - `appear` exempts the whole Transition — it animates on initial render, so static content
28
+ * is legitimate. This alone accounted for 5 of the 10 false positives.
29
+ * - The report is anchored on the CHILD element, not on the `<Transition>` (column parity).
30
+ * - A toggle on any qualifying child satisfies it; not all children need one.
31
+ */
32
+
33
+ // `<TransitionGroup>` is deliberately OUT of scope. It animates list MEMBERSHIP changes, so
34
+ // its children need no toggle at all — confirmed against eslint-plugin-vue, which flags no
35
+ // TransitionGroup case, not even one with a fully static child. Including it produced a false
36
+ // positive on a real v-for list here.
37
+ const TRANSITION_TAGS = new Set(["transition", "Transition"]);
38
+ const TOGGLE_DIRECTIVES = new Set(["if", "else-if", "else", "show"]);
39
+ /** ElementTypes.ELEMENT — a plain HTML element, as opposed to COMPONENT / SLOT / TEMPLATE. */
40
+ const ELEMENT_PLAIN = 0;
41
+
42
+ /**
43
+ * `appear` makes the Transition animate on INITIAL render, so it has a reason to exist even
44
+ * with static content and no toggle. Verified against eslint-plugin-vue: it reports a plain
45
+ * `<Transition>` but not `<Transition appear>`. Without this exemption the rule produced 10
46
+ * false positives on this repo, every one of them a legitimate appear-animation.
47
+ */
48
+ function hasAppear(node) {
49
+ return (node.props ?? []).some((prop) => {
50
+ if (prop.type === PROP_ATTRIBUTE) {
51
+ return prop.name === "appear";
52
+ }
53
+ return (
54
+ prop.type === PROP_DIRECTIVE &&
55
+ prop.name === "bind" &&
56
+ prop.arg?.type === 4 /* SIMPLE_EXPRESSION */ &&
57
+ prop.arg.content === "appear"
58
+ );
59
+ });
60
+ }
61
+
62
+ function hasToggle(node) {
63
+ for (const prop of node.props ?? []) {
64
+ if (prop.type !== PROP_DIRECTIVE) {
65
+ continue;
66
+ }
67
+ if (TOGGLE_DIRECTIVES.has(prop.name)) {
68
+ return true;
69
+ }
70
+ // Dynamic `<component :is>`: swapping the resolved component is the enter/leave.
71
+ if (
72
+ prop.name === "bind" &&
73
+ prop.arg?.type === 4 /* SIMPLE_EXPRESSION */ &&
74
+ prop.arg.content === "is"
75
+ ) {
76
+ return true;
77
+ }
78
+ }
79
+ return false;
80
+ }
81
+
82
+ export default {
83
+ meta: {
84
+ type: "problem",
85
+ docs: {
86
+ description: "require control the display of the content inside `<transition>`",
87
+ recommended: true,
88
+ },
89
+ schema: [],
90
+ messages: { m: "" },
91
+ },
92
+ create(context) {
93
+ if (!context.filename.endsWith(".vue")) {
94
+ return {};
95
+ }
96
+ return {
97
+ Program() {
98
+ const entry = parseSfc(context.filename);
99
+ const ast = entry.descriptor.template?.ast;
100
+ if (!ast) {
101
+ return;
102
+ }
103
+ walkTemplate(ast, (node) => {
104
+ if (node.type !== NODE_ELEMENT || !TRANSITION_TAGS.has(node.tag)) {
105
+ return;
106
+ }
107
+ if (hasAppear(node)) {
108
+ return;
109
+ }
110
+ // Only plain HTML element children are judgeable; see the header note.
111
+ const elements = (node.children ?? []).filter(
112
+ (child) =>
113
+ child.type === NODE_ELEMENT && child.tagType === ELEMENT_PLAIN,
114
+ );
115
+ if (!elements.length) {
116
+ return;
117
+ }
118
+ if (elements.some(hasToggle)) {
119
+ return;
120
+ }
121
+ const child = elements[0];
122
+ reportAtFileOffset(
123
+ context,
124
+ entry,
125
+ child.loc.start.offset,
126
+ child.loc.end.offset,
127
+ `Nothing inside this <${node.tag}> is ever toggled, so the transition never runs. Add \`v-if\` / \`v-show\` to the child (not to the <${node.tag}> itself), or use a dynamic \`<component :is>\`.`,
128
+ );
129
+ });
130
+ },
131
+ };
132
+ },
133
+ };