Skip to content

Commit

Permalink
Add vue/no-child-content rule (#1707)
Browse files Browse the repository at this point in the history
* 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`
  • Loading branch information
FloEdelmann committed Nov 17, 2021
1 parent 6c64a09 commit f4c12cc
Show file tree
Hide file tree
Showing 6 changed files with 444 additions and 1 deletion.
1 change: 1 addition & 0 deletions docs/rules/README.md
Expand Up @@ -321,6 +321,7 @@ For example:
| [vue/next-tick-style](./next-tick-style.md) | enforce Promise or callback style in `nextTick` | :wrench: |
| [vue/no-bare-strings-in-template](./no-bare-strings-in-template.md) | disallow the use of bare strings in `<template>` | |
| [vue/no-boolean-default](./no-boolean-default.md) | disallow boolean defaults | :wrench: |
| [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: |
| [vue/no-duplicate-attr-inheritance](./no-duplicate-attr-inheritance.md) | enforce `inheritAttrs` to be set to `false` when using `v-bind="$attrs"` | |
| [vue/no-empty-component-block](./no-empty-component-block.md) | disallow the `<template>` `<script>` `<style>` block to be empty | |
| [vue/no-invalid-model-keys](./no-invalid-model-keys.md) | require valid keys in model option | |
Expand Down
53 changes: 53 additions & 0 deletions docs/rules/no-child-content.md
@@ -0,0 +1,53 @@
---
pageClass: rule-details
sidebarDepth: 0
title: vue/no-child-content
description: disallow element's child contents which would be overwritten by a directive like `v-html` or `v-text`
---
# vue/no-child-content

> disallow element's child contents which would be overwritten by a directive like `v-html` or `v-text`
- :exclamation: <badge text="This rule has not been released yet." vertical="middle" type="error"> ***This rule has not been released yet.*** </badge>
- :bulb: Some problems reported by this rule are manually fixable by editor [suggestions](https://eslint.org/docs/developer-guide/working-with-rules#providing-suggestions).

## :book: Rule Details

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.

<eslint-code-block :rules="{'vue/no-child-content': ['error']}">

```vue
<template>
<!-- ✓ GOOD -->
<div>child content</div>
<div v-html="replacesChildContent"></div>
<!-- ✗ BAD -->
<div v-html="replacesChildContent">child content</div>
</template>
```

</eslint-code-block>

## :wrench: Options

```json
{
"vue/no-child-content": ["error", {
"additionalDirectives": ["foo"] // checks v-foo directive
}]
}
```

- `additionalDirectives` ... An array of additional directives to check, without the `v-` prefix. Empty by default; `v-html` and `v-text` are always checked.

## :books: Further Reading

- [`v-html` directive](https://v3.vuejs.org/api/directives.html#v-html)
- [`v-text` directive](https://v3.vuejs.org/api/directives.html#v-text)

## :mag: Implementation

- [Rule source](https://github.com/vuejs/eslint-plugin-vue/blob/master/lib/rules/no-child-content.js)
- [Test source](https://github.com/vuejs/eslint-plugin-vue/blob/master/tests/lib/rules/no-child-content.js)
1 change: 1 addition & 0 deletions lib/index.js
Expand Up @@ -58,6 +58,7 @@ module.exports = {
'no-async-in-computed-properties': require('./rules/no-async-in-computed-properties'),
'no-bare-strings-in-template': require('./rules/no-bare-strings-in-template'),
'no-boolean-default': require('./rules/no-boolean-default'),
'no-child-content': require('./rules/no-child-content'),
'no-computed-properties-in-data': require('./rules/no-computed-properties-in-data'),
'no-confusing-v-for-v-if': require('./rules/no-confusing-v-for-v-if'),
'no-constant-condition': require('./rules/no-constant-condition'),
Expand Down
164 changes: 164 additions & 0 deletions lib/rules/no-child-content.js
@@ -0,0 +1,164 @@
/**
* @author Flo Edelmann
* See LICENSE file in root directory for full license.
*/
'use strict'
const { defineTemplateBodyVisitor } = require('../utils')

/**
* @typedef {object} RuleOption
* @property {string[]} additionalDirectives
*/

/**
* @param {VNode | Token} node
* @returns {boolean}
*/
function isWhiteSpaceTextNode(node) {
return node.type === 'VText' && node.value.trim() === ''
}

/**
* @param {Position} pos1
* @param {Position} pos2
* @returns {'less' | 'equal' | 'greater'}
*/
function comparePositions(pos1, pos2) {
if (
pos1.line < pos2.line ||
(pos1.line === pos2.line && pos1.column < pos2.column)
) {
return 'less'
}

if (
pos1.line > pos2.line ||
(pos1.line === pos2.line && pos1.column > pos2.column)
) {
return 'greater'
}

return 'equal'
}

/**
* @param {(VNode | Token)[]} nodes
* @returns {SourceLocation | undefined}
*/
function getLocationRange(nodes) {
/** @type {Position | undefined} */
let start
/** @type {Position | undefined} */
let end

for (const node of nodes) {
if (!start || comparePositions(node.loc.start, start) === 'less') {
start = node.loc.start
}

if (!end || comparePositions(node.loc.end, end) === 'greater') {
end = node.loc.end
}
}

if (start === undefined || end === undefined) {
return undefined
}

return { start, end }
}

// ------------------------------------------------------------------------------
// Rule Definition
// ------------------------------------------------------------------------------

module.exports = {
meta: {
hasSuggestions: true,
type: 'problem',
docs: {
description:
"disallow element's child contents which would be overwritten by a directive like `v-html` or `v-text`",
categories: undefined,
url: 'https://eslint.vuejs.org/rules/no-child-content.html'
},
fixable: null,
schema: [
{
type: 'object',
additionalProperties: false,
properties: {
additionalDirectives: {
type: 'array',
uniqueItems: true,
minItems: 1,
items: {
type: 'string'
}
}
},
required: ['additionalDirectives']
}
]
},
/** @param {RuleContext} context */
create(context) {
const directives = new Set(['html', 'text'])

/** @type {RuleOption | undefined} */
const option = context.options[0]
if (option !== undefined) {
for (const directive of option.additionalDirectives) {
directives.add(directive)
}
}

return defineTemplateBodyVisitor(context, {
/** @param {VDirective} directiveNode */
'VAttribute[directive=true]'(directiveNode) {
const directiveName = directiveNode.key.name.name
const elementNode = directiveNode.parent.parent

if (elementNode.endTag === null) {
return
}

const tokenStore = context.parserServices.getTemplateBodyTokenStore()
const elementComments = tokenStore.getTokensBetween(
elementNode.startTag,
elementNode.endTag,
{
includeComments: true,
filter: (token) => token.type === 'HTMLComment'
}
)

const childNodes = [...elementNode.children, ...elementComments]

if (
directives.has(directiveName) &&
childNodes.length > 0 &&
childNodes.some((childNode) => !isWhiteSpaceTextNode(childNode))
) {
context.report({
node: elementNode,
loc: getLocationRange(childNodes),
message:
'Child content is disallowed because it will be overwritten by the v-{{ directiveName }} directive.',
data: { directiveName },
suggest: [
{
desc: 'Remove child content.',
*fix(fixer) {
for (const childNode of childNodes) {
yield fixer.remove(childNode)
}
}
}
]
})
}
}
})
}
}
2 changes: 1 addition & 1 deletion lib/utils/index.js
Expand Up @@ -1695,7 +1695,7 @@ module.exports = {
/**
* Checks whether the target node is within the given range.
* @param { [number, number] } range
* @param {ASTNode} target
* @param {ASTNode | Token} target
*/
inRange(range, target) {
return range[0] <= target.range[0] && target.range[1] <= range[1]
Expand Down

0 comments on commit f4c12cc

Please sign in to comment.