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
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
|
+
};
|