Repository: vuejs/eslint-plugin-vue (js)

Issue:
Rule proposal: vue/v-text-no-child-content
**Please describe what the rule should do:**

When using the `v-text` directive, the element's inner content gets overwritten by the result of the `v-text` expression.

**What category should the rule belong to?**

[ ] Enforces code style (layout)
[x] Warns about a potential error (problem)
[ ] Suggests an alternate way of doing something (suggestion)
[ ] Other (please specify:)

This might just belong to the layout category, not sure.

**Provide 2-3 code examples that this rule should warn about:**

```vue
<template>
  <!-- ✓ GOOD -->
  <div v-text="foo"></div>

  <!-- ✗ BAD -->
  <div v-text="foo">{{ bar }}</div>
</template>
```

**Additional context**

I'm in the process of localizing an app, and stumbled upon a few elements which already used `v-text`, but *also* had child content:

```vue
<template>
  <label class="control-label col-sm-3" v-text="fields.name.label">
    Name
  </label>
</template>
```

This isn't really a problem, just unnecessary and confusing.

Pull request description:
Add `vue/no-child-content` rule
Fixes #1701.

Patch:
diff --git a/docs/rules/README.md b/docs/rules/README.md
index 03ce9d58..684731a9 100644
--- a/docs/rules/README.md
+++ b/docs/rules/README.md
@@ -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 |  |
diff --git a/docs/rules/no-child-content.md b/docs/rules/no-child-content.md
new file mode 100644
index 00000000..f3bf3177
--- /dev/null
+++ b/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)
diff --git a/lib/index.js b/lib/index.js
index 60d7aac0..437ed3e4 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -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'),
diff --git a/lib/rules/no-child-content.js b/lib/rules/no-child-content.js
new file mode 100644
index 00000000..85636d1d
--- /dev/null
+++ b/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)
+                  }
+                }
+              }
+            ]
+          })
+        }
+      }
+    })
+  }
+}
diff --git a/lib/utils/index.js b/lib/utils/index.js
index ef82c7b9..d6c3a6f5 100644
--- a/lib/utils/index.js
+++ b/lib/utils/index.js
@@ -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]


Test patch:
diff --git a/tests/lib/rules/no-child-content.js b/tests/lib/rules/no-child-content.js
new file mode 100644
index 00000000..320b7ade
--- /dev/null
+++ b/tests/lib/rules/no-child-content.js
@@ -0,0 +1,224 @@
+/**
+ * @author Flo Edelmann
+ * See LICENSE file in root directory for full license.
+ */
+'use strict'
+
+// ------------------------------------------------------------------------------
+// Requirements
+// ------------------------------------------------------------------------------
+
+const RuleTester = require('eslint').RuleTester
+const rule = require('../../../lib/rules/no-child-content')
+
+// ------------------------------------------------------------------------------
+// Tests
+// ------------------------------------------------------------------------------
+const ruleTester = new RuleTester({
+  parser: require.resolve('vue-eslint-parser'),
+  parserOptions: { ecmaVersion: 2015 }
+})
+
+ruleTester.run('no-child-content', rule, {
+  valid: [
+    {
+      // element without directive
+      filename: 'test.vue',
+      code: '<template><div>foo</div></template>'
+    },
+    {
+      // element with unknown directive
+      filename: 'test.vue',
+      code: '<template><div v-foo="bar" /></template>'
+    },
+    {
+      // self-closing element with v-html directive
+      filename: 'test.vue',
+      code: '<template><div v-html="foo" /></template>'
+    },
+    {
+      // self-closing element with v-text directive
+      filename: 'test.vue',
+      code: '<template><div v-text="foo" /></template>'
+    },
+    {
+      // empty element with v-html directive
+      filename: 'test.vue',
+      code: '<template><div v-html="foo"></div></template>'
+    },
+    {
+      // v-html directive and whitespace-only text content
+      filename: 'test.vue',
+      code: '<template><div v-html="foo"> </div></template>'
+    },
+    {
+      // v-html directive and whitespace-only text content with newline
+      filename: 'test.vue',
+      code: `
+        <template>
+          <div v-html="foo">
+          </div>
+        </template>
+      `
+    },
+    {
+      // self-closing element with v-t directive
+      filename: 'test.vue',
+      options: [{ additionalDirectives: ['t'] }],
+      code: '<template><div v-t="foo" /></template>'
+    },
+    {
+      // self-closing element with v-html directive and sibling comment element
+      filename: 'test.vue',
+      code: '<template><div v-html="foo" /><!-- bar --></template>'
+    }
+  ],
+  invalid: [
+    {
+      // v-html directive and text content
+      filename: 'test.vue',
+      code: '<template><div v-html="foo">bar</div></template>',
+      errors: [
+        {
+          message:
+            'Child content is disallowed because it will be overwritten by the v-html directive.',
+          column: 29,
+          endColumn: 32,
+          suggestions: [
+            { output: '<template><div v-html="foo"></div></template>' }
+          ]
+        }
+      ]
+    },
+    {
+      // v-html directive and text expression content
+      filename: 'test.vue',
+      code: '<template><div v-html="foo">{{ bar }}</div></template>',
+      errors: [
+        {
+          message:
+            'Child content is disallowed because it will be overwritten by the v-html directive.',
+          column: 29,
+          endColumn: 38,
+          suggestions: [
+            { output: '<template><div v-html="foo"></div></template>' }
+          ]
+        }
+      ]
+    },
+    {
+      // v-html directive and child element
+      filename: 'test.vue',
+      code: '<template><div v-html="foo"><span /></div></template>',
+      errors: [
+        {
+          message:
+            'Child content is disallowed because it will be overwritten by the v-html directive.',
+          column: 29,
+          endColumn: 37,
+          suggestions: [
+            { output: '<template><div v-html="foo"></div></template>' }
+          ]
+        }
+      ]
+    },
+    {
+      // v-html directive and child comment element
+      filename: 'test.vue',
+      code: '<template><div v-html="foo"><!-- bar --></div></template>',
+      errors: [
+        {
+          message:
+            'Child content is disallowed because it will be overwritten by the v-html directive.',
+          column: 29,
+          endColumn: 41,
+          suggestions: [
+            { output: '<template><div v-html="foo"></div></template>' }
+          ]
+        }
+      ]
+    },
+    {
+      // v-html directive and multiple child elements
+      filename: 'test.vue',
+      code: `
+        <template>
+          <div v-html="foo">
+            <!-- this is a test -->
+            <span>foo</span>
+            bar
+          </div>
+        </template>
+      `,
+      errors: [
+        {
+          message:
+            'Child content is disallowed because it will be overwritten by the v-html directive.',
+          line: 3,
+          column: 29,
+          endLine: 7,
+          endColumn: 11,
+          suggestions: [
+            {
+              output: `
+        <template>
+          <div v-html="foo"></div>
+        </template>
+      `
+            }
+          ]
+        }
+      ]
+    },
+    {
+      // v-text directive and text content
+      filename: 'test.vue',
+      code: '<template><div v-text="foo">bar</div></template>',
+      errors: [
+        {
+          message:
+            'Child content is disallowed because it will be overwritten by the v-text directive.',
+          column: 29,
+          endColumn: 32,
+          suggestions: [
+            { output: '<template><div v-text="foo"></div></template>' }
+          ]
+        }
+      ]
+    },
+    {
+      // v-t directive and text content
+      filename: 'test.vue',
+      options: [{ additionalDirectives: ['t'] }],
+      code: '<template><div v-t="foo">bar</div></template>',
+      errors: [
+        {
+          message:
+            'Child content is disallowed because it will be overwritten by the v-t directive.',
+          column: 26,
+          endColumn: 29,
+          suggestions: [
+            { output: '<template><div v-t="foo"></div></template>' }
+          ]
+        }
+      ]
+    },
+    {
+      // v-html directive and text content while v-t directive is configured
+      filename: 'test.vue',
+      options: [{ additionalDirectives: ['t'] }],
+      code: '<template><div v-html="foo">baz</div></template>',
+      errors: [
+        {
+          message:
+            'Child content is disallowed because it will be overwritten by the v-html directive.',
+          column: 29,
+          endColumn: 32,
+          suggestions: [
+            { output: '<template><div v-html="foo"></div></template>' }
+          ]
+        }
+      ]
+    }
+  ]
+})