Skip to content

Commit f4c12cc

Browse files
authored
Add vue/no-child-content rule (#1707)
* Add `vue/no-child-content` rule * Fix rule category * Add hints about available suggestions * Don't report whitespace-only child content * Report comments in `vue/no-child-content` * Simplify comment parsing with `tokenStore.getTokensBetween`
1 parent 6c64a09 commit f4c12cc

File tree

6 files changed

+444
-1
lines changed

6 files changed

+444
-1
lines changed

docs/rules/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,7 @@ For example:
321321
| [vue/next-tick-style](./next-tick-style.md) | enforce Promise or callback style in `nextTick` | :wrench: |
322322
| [vue/no-bare-strings-in-template](./no-bare-strings-in-template.md) | disallow the use of bare strings in `<template>` | |
323323
| [vue/no-boolean-default](./no-boolean-default.md) | disallow boolean defaults | :wrench: |
324+
| [vue/no-child-content](./no-child-content.md) | disallow element's child contents which would be overwritten by a directive like `v-html` or `v-text` | :bulb: |
324325
| [vue/no-duplicate-attr-inheritance](./no-duplicate-attr-inheritance.md) | enforce `inheritAttrs` to be set to `false` when using `v-bind="$attrs"` | |
325326
| [vue/no-empty-component-block](./no-empty-component-block.md) | disallow the `<template>` `<script>` `<style>` block to be empty | |
326327
| [vue/no-invalid-model-keys](./no-invalid-model-keys.md) | require valid keys in model option | |

docs/rules/no-child-content.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
---
2+
pageClass: rule-details
3+
sidebarDepth: 0
4+
title: vue/no-child-content
5+
description: disallow element's child contents which would be overwritten by a directive like `v-html` or `v-text`
6+
---
7+
# vue/no-child-content
8+
9+
> disallow element's child contents which would be overwritten by a directive like `v-html` or `v-text`
10+
11+
- :exclamation: <badge text="This rule has not been released yet." vertical="middle" type="error"> ***This rule has not been released yet.*** </badge>
12+
- :bulb: Some problems reported by this rule are manually fixable by editor [suggestions](https://eslint.org/docs/developer-guide/working-with-rules#providing-suggestions).
13+
14+
## :book: Rule Details
15+
16+
This rule reports child content of elements that have a directive which overwrites that child content. By default, those are `v-html` and `v-text`, additional ones (e.g. [Vue I18n's `v-t` directive](https://vue-i18n.intlify.dev/api/directive.html)) can be configured manually.
17+
18+
<eslint-code-block :rules="{'vue/no-child-content': ['error']}">
19+
20+
```vue
21+
<template>
22+
<!-- ✓ GOOD -->
23+
<div>child content</div>
24+
<div v-html="replacesChildContent"></div>
25+
26+
<!-- ✗ BAD -->
27+
<div v-html="replacesChildContent">child content</div>
28+
</template>
29+
```
30+
31+
</eslint-code-block>
32+
33+
## :wrench: Options
34+
35+
```json
36+
{
37+
"vue/no-child-content": ["error", {
38+
"additionalDirectives": ["foo"] // checks v-foo directive
39+
}]
40+
}
41+
```
42+
43+
- `additionalDirectives` ... An array of additional directives to check, without the `v-` prefix. Empty by default; `v-html` and `v-text` are always checked.
44+
45+
## :books: Further Reading
46+
47+
- [`v-html` directive](https://v3.vuejs.org/api/directives.html#v-html)
48+
- [`v-text` directive](https://v3.vuejs.org/api/directives.html#v-text)
49+
50+
## :mag: Implementation
51+
52+
- [Rule source](https://github.com/vuejs/eslint-plugin-vue/blob/master/lib/rules/no-child-content.js)
53+
- [Test source](https://github.com/vuejs/eslint-plugin-vue/blob/master/tests/lib/rules/no-child-content.js)

lib/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ module.exports = {
5858
'no-async-in-computed-properties': require('./rules/no-async-in-computed-properties'),
5959
'no-bare-strings-in-template': require('./rules/no-bare-strings-in-template'),
6060
'no-boolean-default': require('./rules/no-boolean-default'),
61+
'no-child-content': require('./rules/no-child-content'),
6162
'no-computed-properties-in-data': require('./rules/no-computed-properties-in-data'),
6263
'no-confusing-v-for-v-if': require('./rules/no-confusing-v-for-v-if'),
6364
'no-constant-condition': require('./rules/no-constant-condition'),

lib/rules/no-child-content.js

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
/**
2+
* @author Flo Edelmann
3+
* See LICENSE file in root directory for full license.
4+
*/
5+
'use strict'
6+
const { defineTemplateBodyVisitor } = require('../utils')
7+
8+
/**
9+
* @typedef {object} RuleOption
10+
* @property {string[]} additionalDirectives
11+
*/
12+
13+
/**
14+
* @param {VNode | Token} node
15+
* @returns {boolean}
16+
*/
17+
function isWhiteSpaceTextNode(node) {
18+
return node.type === 'VText' && node.value.trim() === ''
19+
}
20+
21+
/**
22+
* @param {Position} pos1
23+
* @param {Position} pos2
24+
* @returns {'less' | 'equal' | 'greater'}
25+
*/
26+
function comparePositions(pos1, pos2) {
27+
if (
28+
pos1.line < pos2.line ||
29+
(pos1.line === pos2.line && pos1.column < pos2.column)
30+
) {
31+
return 'less'
32+
}
33+
34+
if (
35+
pos1.line > pos2.line ||
36+
(pos1.line === pos2.line && pos1.column > pos2.column)
37+
) {
38+
return 'greater'
39+
}
40+
41+
return 'equal'
42+
}
43+
44+
/**
45+
* @param {(VNode | Token)[]} nodes
46+
* @returns {SourceLocation | undefined}
47+
*/
48+
function getLocationRange(nodes) {
49+
/** @type {Position | undefined} */
50+
let start
51+
/** @type {Position | undefined} */
52+
let end
53+
54+
for (const node of nodes) {
55+
if (!start || comparePositions(node.loc.start, start) === 'less') {
56+
start = node.loc.start
57+
}
58+
59+
if (!end || comparePositions(node.loc.end, end) === 'greater') {
60+
end = node.loc.end
61+
}
62+
}
63+
64+
if (start === undefined || end === undefined) {
65+
return undefined
66+
}
67+
68+
return { start, end }
69+
}
70+
71+
// ------------------------------------------------------------------------------
72+
// Rule Definition
73+
// ------------------------------------------------------------------------------
74+
75+
module.exports = {
76+
meta: {
77+
hasSuggestions: true,
78+
type: 'problem',
79+
docs: {
80+
description:
81+
"disallow element's child contents which would be overwritten by a directive like `v-html` or `v-text`",
82+
categories: undefined,
83+
url: 'https://eslint.vuejs.org/rules/no-child-content.html'
84+
},
85+
fixable: null,
86+
schema: [
87+
{
88+
type: 'object',
89+
additionalProperties: false,
90+
properties: {
91+
additionalDirectives: {
92+
type: 'array',
93+
uniqueItems: true,
94+
minItems: 1,
95+
items: {
96+
type: 'string'
97+
}
98+
}
99+
},
100+
required: ['additionalDirectives']
101+
}
102+
]
103+
},
104+
/** @param {RuleContext} context */
105+
create(context) {
106+
const directives = new Set(['html', 'text'])
107+
108+
/** @type {RuleOption | undefined} */
109+
const option = context.options[0]
110+
if (option !== undefined) {
111+
for (const directive of option.additionalDirectives) {
112+
directives.add(directive)
113+
}
114+
}
115+
116+
return defineTemplateBodyVisitor(context, {
117+
/** @param {VDirective} directiveNode */
118+
'VAttribute[directive=true]'(directiveNode) {
119+
const directiveName = directiveNode.key.name.name
120+
const elementNode = directiveNode.parent.parent
121+
122+
if (elementNode.endTag === null) {
123+
return
124+
}
125+
126+
const tokenStore = context.parserServices.getTemplateBodyTokenStore()
127+
const elementComments = tokenStore.getTokensBetween(
128+
elementNode.startTag,
129+
elementNode.endTag,
130+
{
131+
includeComments: true,
132+
filter: (token) => token.type === 'HTMLComment'
133+
}
134+
)
135+
136+
const childNodes = [...elementNode.children, ...elementComments]
137+
138+
if (
139+
directives.has(directiveName) &&
140+
childNodes.length > 0 &&
141+
childNodes.some((childNode) => !isWhiteSpaceTextNode(childNode))
142+
) {
143+
context.report({
144+
node: elementNode,
145+
loc: getLocationRange(childNodes),
146+
message:
147+
'Child content is disallowed because it will be overwritten by the v-{{ directiveName }} directive.',
148+
data: { directiveName },
149+
suggest: [
150+
{
151+
desc: 'Remove child content.',
152+
*fix(fixer) {
153+
for (const childNode of childNodes) {
154+
yield fixer.remove(childNode)
155+
}
156+
}
157+
}
158+
]
159+
})
160+
}
161+
}
162+
})
163+
}
164+
}

lib/utils/index.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1695,7 +1695,7 @@ module.exports = {
16951695
/**
16961696
* Checks whether the target node is within the given range.
16971697
* @param { [number, number] } range
1698-
* @param {ASTNode} target
1698+
* @param {ASTNode | Token} target
16991699
*/
17001700
inRange(range, target) {
17011701
return range[0] <= target.range[0] && target.range[1] <= range[1]

0 commit comments

Comments
 (0)