Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add vue/no-child-content rule #1707

Merged
merged 7 commits into from Nov 17, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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]
FloEdelmann marked this conversation as resolved.
Show resolved Hide resolved

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