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 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,124 @@
1
+ # oxlint-plugin-vue-sfc
2
+
3
+ Vue SFC template 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-vue`. These 24 rules self-parse the SFC with
7
+ `@vue/compiler-sfc` and walk the real template AST, so the checks run under oxlint instead.
8
+
9
+ Ports of `eslint-plugin-vue` rules that oxlint has no native equivalent for. Validated against a
10
+ production Nuxt monorepo — 1581 SFCs, all template-first — where they replaced the corresponding
11
+ `vue/*` ESLint rules outright.
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ npm i -D oxlint-plugin-vue-sfc
17
+ ```
18
+
19
+ ## Usage
20
+
21
+ Extend a preset from your `.oxlintrc.json`:
22
+
23
+ ```jsonc
24
+ {
25
+ "extends": ["./node_modules/oxlint-plugin-vue-sfc/configs/recommended.json"]
26
+ }
27
+ ```
28
+
29
+ Two presets:
30
+
31
+ | Preset | Rules | For |
32
+ | --- | --- | --- |
33
+ | `configs/recommended.json` | all 24 | plain oxlint — you want the full set |
34
+ | `configs/complement.json` | 13 | you also run [`oxlint-vue`](https://github.com/vad1ym/oxlint-vue), which covers the other 11 |
35
+
36
+ Or wire it up by hand:
37
+
38
+ ```jsonc
39
+ {
40
+ "jsPlugins": ["./node_modules/oxlint-plugin-vue-sfc/index.mjs"],
41
+ "rules": {
42
+ "vue-sfc/require-v-for-key": "error",
43
+ "vue-sfc/no-raw-text": "off"
44
+ }
45
+ }
46
+ ```
47
+
48
+ The plugin namespace is `vue-sfc`, not `vue` — oxlint reserves `vue` for its native plugin and
49
+ rejects a JS plugin that claims it.
50
+
51
+ ## Rules
52
+
53
+ | Rule | Default | In `complement` | Description |
54
+ | --- | --- | --- | --- |
55
+ | `vue-sfc/no-child-content` | error | | disallow element's child contents which would be overwritten by a directive like `v-html` or `v-text` |
56
+ | `vue-sfc/no-dupe-v-else-if` | error | | disallow duplicate conditions in `v-if` / `v-else-if` chains |
57
+ | `vue-sfc/no-duplicate-attributes` | error | | disallow duplication of attributes on the same element |
58
+ | `vue-sfc/no-lone-template` | warn | yes | disallow unnecessary `<template>` |
59
+ | `vue-sfc/no-mutating-props` | error | | disallow mutation of component props |
60
+ | `vue-sfc/no-parsing-error` | error | yes | disallow parsing errors in `<template>` |
61
+ | `vue-sfc/no-raw-text` | off | yes | disallow untranslated raw text in Vue templates (use i18n) |
62
+ | `vue-sfc/no-ref-as-operand` | error | yes | require `.value` when using a `ref` as an operand |
63
+ | `vue-sfc/no-template-key` | error | | disallow `key` attribute on `<template>` without `v-for` |
64
+ | `vue-sfc/no-template-shadow` | warn | yes | disallow variable declarations from shadowing variables declared in the outer scope |
65
+ | `vue-sfc/no-textarea-mustache` | error | | disallow mustaches in `<textarea>` |
66
+ | `vue-sfc/no-unused-components` | error | yes | disallow registering components that are not used inside templates |
67
+ | `vue-sfc/no-unused-vars` | error | yes | disallow unused variable definitions of v-for directives or scope attributes |
68
+ | `vue-sfc/no-use-v-if-with-v-for` | error | | disallow use of `v-if` on the same element as `v-for` |
69
+ | `vue-sfc/no-useless-template-attributes` | error | yes | disallow useless attribute on `<template>` |
70
+ | `vue-sfc/no-v-text-v-html-on-component` | warn | | disallow `v-text` / `v-html` on component |
71
+ | `vue-sfc/require-component-is` | error | | require `v-bind:is` of `<component>` elements |
72
+ | `vue-sfc/require-explicit-emits` | warn | yes | require `emits` option with name triggered by `$emit()` |
73
+ | `vue-sfc/require-toggle-inside-transition` | error | yes | require control the display of the content inside `<transition>` |
74
+ | `vue-sfc/require-v-for-key` | error | | require `v-bind:key` with `v-for` directives |
75
+ | `vue-sfc/require-valid-default-prop` | error | yes | enforce props default values to be valid |
76
+ | `vue-sfc/this-in-template` | warn | | disallow usage of `this` in template |
77
+ | `vue-sfc/use-v-on-exact` | error | yes | enforce usage of `exact` modifier on `v-on` |
78
+ | `vue-sfc/v-on-event-hyphenation` | warn | yes | enforce `v-on` event naming style on custom components in template |
79
+
80
+ `no-raw-text` defaults to `off`: it is an i18n rule (a replacement for
81
+ `@intlify/vue-i18n/no-raw-text`) and only makes sense in a translated app. It takes
82
+ `{ ignoreTags, ignorePattern }`.
83
+
84
+ ## Known limitation: diagnostic positions
85
+
86
+ Template diagnostics currently render on the `<script>` block, with the true template position
87
+ prepended to the message:
88
+
89
+ ```
90
+ Comp.vue:13:1 error vue-sfc(require-v-for-key): [template 3:9] <li> uses v-for without a :key...
91
+ ```
92
+
93
+ oxlint gives JS-plugin rules a script-relative view of an SFC, so a template position — which in a
94
+ template-first file is a negative offset — cannot be expressed. The workaround clamps the range and
95
+ puts the real location in the message text.
96
+
97
+ [oxc#26001](https://github.com/oxc-project/oxc/pull/26001) fixes this upstream with an `actualRange`
98
+ on the diagnostic, and would give every rule here correct positions **with no rule changes**. Until
99
+ it lands, editor jump-to-error goes to the script block.
100
+
101
+ ## Reduced-scope ports
102
+
103
+ Six rules deliberately implement less than their upstream counterpart. Each says so in its own file
104
+ header:
105
+
106
+ - `no-dupe-v-else-if` — exact duplicate conditions only, not subsumption
107
+ - `this-in-template`, `no-mutating-props` — lexical template scan
108
+ - `require-explicit-emits` — silent on an unresolvable named type reference
109
+ - `no-unused-vars`, `no-template-shadow` — positioned scan, not a full destructuring-pattern parse
110
+ - `no-unused-components` — upstream only inspects Options API `components: {}`, so it is inert in a
111
+ `<script setup>` codebase; ported so the check does not vanish if that changes
112
+
113
+ They gate new violations rather than opening a migration: all 24 report zero diagnostics across the
114
+ 1581-SFC corpus they were built against.
115
+
116
+ ## Tests
117
+
118
+ ```bash
119
+ npm test # node:test, 320 specs across 24 rules
120
+ ```
121
+
122
+ ## License
123
+
124
+ MIT
@@ -0,0 +1,20 @@
1
+ {
2
+ "jsPlugins": [
3
+ "../index.mjs"
4
+ ],
5
+ "rules": {
6
+ "vue-sfc/no-lone-template": "warn",
7
+ "vue-sfc/no-parsing-error": "error",
8
+ "vue-sfc/no-raw-text": "off",
9
+ "vue-sfc/no-ref-as-operand": "error",
10
+ "vue-sfc/no-template-shadow": "warn",
11
+ "vue-sfc/no-unused-components": "error",
12
+ "vue-sfc/no-unused-vars": "error",
13
+ "vue-sfc/no-useless-template-attributes": "error",
14
+ "vue-sfc/require-explicit-emits": "warn",
15
+ "vue-sfc/require-toggle-inside-transition": "error",
16
+ "vue-sfc/require-valid-default-prop": "error",
17
+ "vue-sfc/use-v-on-exact": "error",
18
+ "vue-sfc/v-on-event-hyphenation": "warn"
19
+ }
20
+ }
@@ -0,0 +1,31 @@
1
+ {
2
+ "jsPlugins": [
3
+ "../index.mjs"
4
+ ],
5
+ "rules": {
6
+ "vue-sfc/no-child-content": "error",
7
+ "vue-sfc/no-dupe-v-else-if": "error",
8
+ "vue-sfc/no-duplicate-attributes": "error",
9
+ "vue-sfc/no-lone-template": "warn",
10
+ "vue-sfc/no-mutating-props": "error",
11
+ "vue-sfc/no-parsing-error": "error",
12
+ "vue-sfc/no-raw-text": "off",
13
+ "vue-sfc/no-ref-as-operand": "error",
14
+ "vue-sfc/no-template-key": "error",
15
+ "vue-sfc/no-template-shadow": "warn",
16
+ "vue-sfc/no-textarea-mustache": "error",
17
+ "vue-sfc/no-unused-components": "error",
18
+ "vue-sfc/no-unused-vars": "error",
19
+ "vue-sfc/no-use-v-if-with-v-for": "error",
20
+ "vue-sfc/no-useless-template-attributes": "error",
21
+ "vue-sfc/no-v-text-v-html-on-component": "warn",
22
+ "vue-sfc/require-component-is": "error",
23
+ "vue-sfc/require-explicit-emits": "warn",
24
+ "vue-sfc/require-toggle-inside-transition": "error",
25
+ "vue-sfc/require-v-for-key": "error",
26
+ "vue-sfc/require-valid-default-prop": "error",
27
+ "vue-sfc/this-in-template": "warn",
28
+ "vue-sfc/use-v-on-exact": "error",
29
+ "vue-sfc/v-on-event-hyphenation": "warn"
30
+ }
31
+ }
package/index.mjs ADDED
@@ -0,0 +1,56 @@
1
+ import noChildContentRule from "./rules/no-child-content.mjs";
2
+ import noDupeVElseIfRule from "./rules/no-dupe-v-else-if.mjs";
3
+ import noDuplicateAttributesRule from "./rules/no-duplicate-attributes.mjs";
4
+ import noLoneTemplateRule from "./rules/no-lone-template.mjs";
5
+ import noMutatingPropsRule from "./rules/no-mutating-props.mjs";
6
+ import noParsingErrorRule from "./rules/no-parsing-error.mjs";
7
+ import noRawTextRule from "./rules/no-raw-text.mjs";
8
+ import noRefAsOperandRule from "./rules/no-ref-as-operand.mjs";
9
+ import noTemplateKeyRule from "./rules/no-template-key.mjs";
10
+ import noTemplateShadowRule from "./rules/no-template-shadow.mjs";
11
+ import noTextareaMustacheRule from "./rules/no-textarea-mustache.mjs";
12
+ import noUnusedComponentsRule from "./rules/no-unused-components.mjs";
13
+ import noUnusedVarsRule from "./rules/no-unused-vars.mjs";
14
+ import noUseVIfWithVForRule from "./rules/no-use-v-if-with-v-for.mjs";
15
+ import noUselessTemplateAttributesRule from "./rules/no-useless-template-attributes.mjs";
16
+ import noVTextVHtmlOnComponentRule from "./rules/no-v-text-v-html-on-component.mjs";
17
+ import requireComponentIsRule from "./rules/require-component-is.mjs";
18
+ import requireExplicitEmitsRule from "./rules/require-explicit-emits.mjs";
19
+ import requireToggleInsideTransitionRule from "./rules/require-toggle-inside-transition.mjs";
20
+ import requireVForKeyRule from "./rules/require-v-for-key.mjs";
21
+ import requireValidDefaultPropRule from "./rules/require-valid-default-prop.mjs";
22
+ import thisInTemplateRule from "./rules/this-in-template.mjs";
23
+ import useVOnExactRule from "./rules/use-v-on-exact.mjs";
24
+ import vOnEventHyphenationRule from "./rules/v-on-event-hyphenation.mjs";
25
+
26
+ const plugin = {
27
+ meta: { name: "vue-sfc" },
28
+ rules: {
29
+ "no-child-content": noChildContentRule,
30
+ "no-dupe-v-else-if": noDupeVElseIfRule,
31
+ "no-duplicate-attributes": noDuplicateAttributesRule,
32
+ "no-lone-template": noLoneTemplateRule,
33
+ "no-mutating-props": noMutatingPropsRule,
34
+ "no-parsing-error": noParsingErrorRule,
35
+ "no-raw-text": noRawTextRule,
36
+ "no-ref-as-operand": noRefAsOperandRule,
37
+ "no-template-key": noTemplateKeyRule,
38
+ "no-template-shadow": noTemplateShadowRule,
39
+ "no-textarea-mustache": noTextareaMustacheRule,
40
+ "no-unused-components": noUnusedComponentsRule,
41
+ "no-unused-vars": noUnusedVarsRule,
42
+ "no-use-v-if-with-v-for": noUseVIfWithVForRule,
43
+ "no-useless-template-attributes": noUselessTemplateAttributesRule,
44
+ "no-v-text-v-html-on-component": noVTextVHtmlOnComponentRule,
45
+ "require-component-is": requireComponentIsRule,
46
+ "require-explicit-emits": requireExplicitEmitsRule,
47
+ "require-toggle-inside-transition": requireToggleInsideTransitionRule,
48
+ "require-v-for-key": requireVForKeyRule,
49
+ "require-valid-default-prop": requireValidDefaultPropRule,
50
+ "this-in-template": thisInTemplateRule,
51
+ "use-v-on-exact": useVOnExactRule,
52
+ "v-on-event-hyphenation": vOnEventHyphenationRule,
53
+ },
54
+ };
55
+
56
+ export default plugin;
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "oxlint-plugin-vue-sfc",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Vue SFC template rules for oxlint, as a JS plugin. Ports of eslint-plugin-vue rules oxlint has no native equivalent for.",
6
+ "license": "MIT",
7
+ "keywords": [
8
+ "oxlint",
9
+ "oxc",
10
+ "vue",
11
+ "sfc",
12
+ "linter",
13
+ "eslint-plugin-vue",
14
+ "vue3",
15
+ "nuxt"
16
+ ],
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/Togetic/oxlint-plugin-vue.git"
20
+ },
21
+ "bugs": {
22
+ "url": "https://github.com/Togetic/oxlint-plugin-vue/issues"
23
+ },
24
+ "homepage": "https://github.com/Togetic/oxlint-plugin-vue#readme",
25
+ "author": "Togetic",
26
+ "exports": {
27
+ ".": "./index.mjs",
28
+ "./configs/recommended": "./configs/recommended.json",
29
+ "./configs/complement": "./configs/complement.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
+ },
47
+ "devDependencies": {
48
+ "oxc-parser": "^0.90.0",
49
+ "oxlint": "^1.78.0"
50
+ },
51
+ "peerDependencies": {
52
+ "oxlint": ">=1.0.0"
53
+ },
54
+ "engines": {
55
+ "node": ">=20.19.0"
56
+ }
57
+ }
@@ -0,0 +1,80 @@
1
+ import {
2
+ NODE_ELEMENT,
3
+ NODE_TEXT,
4
+ findDirective,
5
+ parseSfc,
6
+ reportAtFileOffset,
7
+ walkTemplate,
8
+ } from "../utils/vue-sfc.mjs";
9
+
10
+ /**
11
+ * Replacement for `vue/no-child-content` (no native oxlint equivalent — oxc#15761).
12
+ *
13
+ * `v-text` and `v-html` REPLACE an element's children. So markup written inside such an
14
+ * element never renders — it reads as live content but is dead. Typically a leftover from
15
+ * converting static markup to a binding, or a merge that kept both.
16
+ *
17
+ * Judging "has children" carefully, because the naive check reports on formatting:
18
+ * - Whitespace-only text children are ignored; `<p v-text="x">\n</p>` is just indentation.
19
+ * - A comment child is ignored: it renders nothing, so `v-text` overwrites nothing.
20
+ * - Anything else — real text, an element, an interpolation — is content that will be
21
+ * silently discarded, and is reported.
22
+ */
23
+
24
+ function meaningfulChildren(node) {
25
+ return (node.children ?? []).filter((child) => {
26
+ if (child.type === NODE_TEXT) {
27
+ return (child.content ?? "").trim() !== "";
28
+ }
29
+ // NodeTypes.COMMENT === 3; renders nothing, so it is not lost content.
30
+ return child.type !== 3;
31
+ });
32
+ }
33
+
34
+ export default {
35
+ meta: {
36
+ type: "problem",
37
+ docs: {
38
+ description: "disallow element's child contents which would be overwritten by a directive like `v-html` or `v-text`",
39
+ recommended: true,
40
+ },
41
+ schema: [],
42
+ messages: { m: "" },
43
+ },
44
+ create(context) {
45
+ if (!context.filename.endsWith(".vue")) {
46
+ return {};
47
+ }
48
+ return {
49
+ Program() {
50
+ const entry = parseSfc(context.filename);
51
+ const ast = entry.descriptor.template?.ast;
52
+ if (!ast) {
53
+ return;
54
+ }
55
+ walkTemplate(ast, (node) => {
56
+ if (node.type !== NODE_ELEMENT) {
57
+ return;
58
+ }
59
+ const directive = findDirective(node, "text") ?? findDirective(node, "html");
60
+ if (!directive) {
61
+ return;
62
+ }
63
+ const children = meaningfulChildren(node);
64
+ if (!children.length) {
65
+ return;
66
+ }
67
+ const first = children[0];
68
+ const last = children[children.length - 1];
69
+ reportAtFileOffset(
70
+ context,
71
+ entry,
72
+ first.loc.start.offset,
73
+ last.loc.end.offset,
74
+ `This content is never rendered: \`v-${directive.name}\` on <${node.tag}> replaces the element's children. Remove the content, or remove the directive.`,
75
+ );
76
+ });
77
+ },
78
+ };
79
+ },
80
+ };
@@ -0,0 +1,114 @@
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-dupe-v-else-if` (no native oxlint equivalent — oxc#15761).
11
+ *
12
+ * A condition repeated later in a `v-if` / `v-else-if` chain can never be reached: the
13
+ * earlier branch always wins. So one of the two branches is dead code, and the author
14
+ * almost certainly meant a different condition in the second one.
15
+ *
16
+ * Chain reconstruction: `@vue/compiler-sfc`'s `parse()` returns the UNTRANSFORMED template,
17
+ * where `v-if` / `v-else-if` / `v-else` are plain directives on sibling elements rather than
18
+ * a branches structure. A chain is therefore a run of consecutive element siblings starting
19
+ * at `v-if`, continuing while each next sibling has `v-else-if` (or `v-else`, which ends it).
20
+ * Non-element siblings (whitespace text, comments) are skipped rather than breaking the run,
21
+ * because they do not break the chain for Vue either.
22
+ *
23
+ * DELIBERATELY NARROWER than eslint-plugin-vue: this compares normalized condition SOURCE
24
+ * TEXT for exact equality only. eslint-plugin-vue additionally detects logical subsets
25
+ * (`a` after `a && b`, and `a || b` overlaps). Doing that safely needs real expression
26
+ * analysis; getting it wrong reports working code. False negatives are acceptable here,
27
+ * false positives are not — so subset detection stays with ESLint until it is worth building
28
+ * properly. Any rule ported at reduced scope must say so; see OXLINT_MIGRATION.md.
29
+ */
30
+
31
+ const NODE_COMMENT = 3;
32
+ const NODE_TEXT = 2;
33
+
34
+ /** Condition source, whitespace-normalized so formatting differences don't hide a duplicate. */
35
+ function conditionText(directive) {
36
+ return (directive.exp?.content ?? "").replace(/\s+/g, " ").trim();
37
+ }
38
+
39
+ function isSkippable(node) {
40
+ if (node.type === NODE_COMMENT) {
41
+ return true;
42
+ }
43
+ return node.type === NODE_TEXT && (node.content ?? "").trim() === "";
44
+ }
45
+
46
+ export default {
47
+ meta: {
48
+ type: "problem",
49
+ docs: {
50
+ description: "disallow duplicate conditions in `v-if` / `v-else-if` chains",
51
+ recommended: true,
52
+ },
53
+ schema: [],
54
+ messages: { m: "" },
55
+ },
56
+ create(context) {
57
+ if (!context.filename.endsWith(".vue")) {
58
+ return {};
59
+ }
60
+ return {
61
+ Program() {
62
+ const entry = parseSfc(context.filename);
63
+ const ast = entry.descriptor.template?.ast;
64
+ if (!ast) {
65
+ return;
66
+ }
67
+ // Every node with children can host a chain among those children.
68
+ walkTemplate(ast, (parent) => {
69
+ const children = (parent.children ?? []).filter((c) => !isSkippable(c));
70
+ let seen = null;
71
+ for (const child of children) {
72
+ if (child.type !== NODE_ELEMENT) {
73
+ seen = null;
74
+ continue;
75
+ }
76
+ const vIf = findDirective(child, "if");
77
+ const vElseIf = findDirective(child, "else-if");
78
+ if (vIf) {
79
+ seen = new Set([conditionText(vIf)]);
80
+ continue;
81
+ }
82
+ if (vElseIf) {
83
+ if (!seen) {
84
+ // `v-else-if` with no preceding `v-if` is a different error
85
+ // (vue/valid-v-else-if); not this rule's business.
86
+ continue;
87
+ }
88
+ const text = conditionText(vElseIf);
89
+ if (text && seen.has(text)) {
90
+ // Anchor on the CONDITION, not the whole directive: that is
91
+ // where eslint-plugin-vue points (verified at exact column
92
+ // parity), and it is the part the author must change.
93
+ const at = vElseIf.exp?.loc ?? vElseIf.loc;
94
+ reportAtFileOffset(
95
+ context,
96
+ entry,
97
+ at.start.offset,
98
+ at.end.offset,
99
+ `This condition is a duplicate of an earlier branch in the same v-if chain, so this branch is unreachable: \`${text}\`.`,
100
+ );
101
+ } else if (text) {
102
+ seen.add(text);
103
+ }
104
+ continue;
105
+ }
106
+ // `v-else` terminates the chain, and an element with no chain directive
107
+ // breaks it. Either way the run ends here.
108
+ seen = null;
109
+ }
110
+ });
111
+ },
112
+ };
113
+ },
114
+ };
@@ -0,0 +1,106 @@
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-duplicate-attributes` (no native oxlint equivalent — oxc#15761).
12
+ *
13
+ * Two attributes with the same name on one element: the browser/compiler keeps one and
14
+ * drops the other silently, so the dropped one is dead code that reads as live. Usually a
15
+ * bad merge or a copy-paste.
16
+ *
17
+ * Name resolution, and why it is not just `prop.name`:
18
+ * - A static attribute contributes its own name (`class`, `id`).
19
+ * - A `v-bind` directive contributes its ARGUMENT (`:class` → `class`), not "bind".
20
+ * - `v-bind="obj"` (no argument) is a spread; it has no single name and is skipped.
21
+ * - Non-bind directives (`v-if`, `v-on`, `v-model`) are out of scope: duplicates there
22
+ * mean different things (two `@click` handlers both fire) and belong to other rules.
23
+ *
24
+ * `class` and `style` are deliberately exempt from the static+bound pair, matching
25
+ * eslint-plugin-vue's `allowCoexistClass`/`allowCoexistStyle` defaults: Vue MERGES
26
+ * `class="a" :class="b"` rather than dropping one, so it is idiomatic, not a bug. Two
27
+ * static `class` attributes, or two bound `:class`, are still flagged — merging only
28
+ * applies across the static/bound boundary.
29
+ */
30
+
31
+ const MERGEABLE = new Set(["class", "style"]);
32
+
33
+ /** Resolved attribute name for duplicate purposes, or null when it has none. */
34
+ function attributeName(prop) {
35
+ if (prop.type === PROP_ATTRIBUTE) {
36
+ return prop.name;
37
+ }
38
+ if (
39
+ prop.type === PROP_DIRECTIVE &&
40
+ prop.name === "bind" &&
41
+ prop.arg?.type === 4 /* SIMPLE_EXPRESSION */ &&
42
+ prop.arg.isStatic !== false
43
+ ) {
44
+ return prop.arg.content;
45
+ }
46
+ return null;
47
+ }
48
+
49
+ export default {
50
+ meta: {
51
+ type: "problem",
52
+ docs: {
53
+ description: "disallow duplication of attributes on the same element",
54
+ recommended: true,
55
+ },
56
+ schema: [],
57
+ messages: { m: "" },
58
+ },
59
+ create(context) {
60
+ if (!context.filename.endsWith(".vue")) {
61
+ return {};
62
+ }
63
+ return {
64
+ Program() {
65
+ const entry = parseSfc(context.filename);
66
+ const ast = entry.descriptor.template?.ast;
67
+ if (!ast) {
68
+ return;
69
+ }
70
+ walkTemplate(ast, (node) => {
71
+ if (node.type !== NODE_ELEMENT) {
72
+ return;
73
+ }
74
+ // Track static and bound occurrences separately so the class/style
75
+ // merge exemption can apply across the boundary but not within it.
76
+ const seen = new Map();
77
+ for (const prop of node.props ?? []) {
78
+ const name = attributeName(prop);
79
+ if (name === null) {
80
+ continue;
81
+ }
82
+ const isStatic = prop.type === PROP_ATTRIBUTE;
83
+ const bucket = seen.get(name);
84
+ if (!bucket) {
85
+ seen.set(name, { static: isStatic, bound: !isStatic });
86
+ continue;
87
+ }
88
+ const sameKind = isStatic ? bucket.static : bucket.bound;
89
+ const exempt = MERGEABLE.has(name) && !sameKind;
90
+ if (!exempt) {
91
+ reportAtFileOffset(
92
+ context,
93
+ entry,
94
+ prop.loc.start.offset,
95
+ prop.loc.end.offset,
96
+ `Duplicate attribute \`${name}\` on <${node.tag}>. Only one survives compilation, so the other is silently ignored.`,
97
+ );
98
+ }
99
+ bucket.static ||= isStatic;
100
+ bucket.bound ||= !isStatic;
101
+ }
102
+ });
103
+ },
104
+ };
105
+ },
106
+ };