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-bare-strings-in-template rule #1185

Merged
merged 1 commit into from Jun 5, 2020
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 @@ -278,6 +278,7 @@ For example:
| [vue/html-comment-content-spacing](./html-comment-content-spacing.md) | enforce unified spacing in HTML comments | :wrench: |
| [vue/html-comment-indent](./html-comment-indent.md) | enforce consistent indentation in HTML comments | :wrench: |
| [vue/match-component-file-name](./match-component-file-name.md) | require component name property to match its file name | |
| [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-duplicate-attr-inheritance](./no-duplicate-attr-inheritance.md) | enforce `inheritAttrs` to be set to `false` when using `v-bind="$attrs"` | |
| [vue/no-potential-component-option-typo](./no-potential-component-option-typo.md) | disallow a potential typo in your component property | |
Expand Down
88 changes: 88 additions & 0 deletions docs/rules/no-bare-strings-in-template.md
@@ -0,0 +1,88 @@
---
pageClass: rule-details
sidebarDepth: 0
title: vue/no-bare-strings-in-template
description: disallow the use of bare strings in `<template>`
---
# vue/no-bare-strings-in-template
> disallow the use of bare strings in `<template>`

## :book: Rule Details

This rule disallows the use of bare strings in `<template>`.
In order to be able to internationalize your application, you will need to avoid using plain strings in your templates. Instead, you would need to use a template helper specializing in translation.

This rule was inspired by [no-bare-strings rule in ember-template-lint](https://github.com/ember-template-lint/ember-template-lint/blob/master/docs/rule/no-bare-strings.md).


<eslint-code-block :rules="{'vue/no-bare-strings-in-template': ['error']}">

```vue
<template>
<!-- ✓ GOOD -->
<h1>{{ $t('foo.bar') }}</h1>
<h1>{{ foo }}</h1>
<h1 v-t="'foo.bar'"></h1>

<!-- ✗ BAD -->
<h1>Lorem ipsum</h1>
<div
title="Lorem ipsum"
aria-label="Lorem ipsum"
aria-placeholder="Lorem ipsum"
aria-roledescription="Lorem ipsum"
aria-valuetext="Lorem ipsum"
/>
<img alt="Lorem ipsum">
<input placeholder="Lorem ipsum">
<h1 v-text="'Lorem ipsum'" />

<!-- Does not check -->
<h1>{{ 'Lorem ipsum' }}</h1>
<div
v-bind:title="'Lorem ipsum'"
/>
</template>
```

</eslint-code-block>

:::tip
This rule does not check for string literals, in bindings and mustaches interpolation. This is because it looks like a conscious decision.
If you want to report these string literals, enable the [vue/no-useless-v-bind] and [vue/no-useless-mustaches] rules and fix the useless string literals.
:::

## :wrench: Options

```js
{
"vue/no-bare-strings-in-template": ["error", {
"whitelist": [
"(", ")", ",", ".", "&", "+", "-", "=", "*", "/", "#", "%", "!", "?", ":", "[", "]", "{", "}", "<", ">", "\u00b7", "\u2022", "\u2010", "\u2013", "\u2014", "\u2212", "|"
],
"attributes": {
"/.+/": ["title", "aria-label", "aria-placeholder", "aria-roledescription", "aria-valuetext"],
"input": ["placeholder"],
"img": ["alt"]
},
"directives": ["v-text"]
}]
}
```

- `whitelist` ... An array of whitelisted strings.
- `attributes` ... An object whose keys are tag name or patterns and value is an array of attributes to check for that tag name.
- `directives` ... An array of directive names to check literal value.

## :couple: Related rules

- [vue/no-useless-v-bind]
- [vue/no-useless-mustaches]

[vue/no-useless-v-bind]: ./no-useless-v-bind.md
[vue/no-useless-mustaches]: ./no-useless-mustaches.md

## :mag: Implementation

- [Rule source](https://github.com/vuejs/eslint-plugin-vue/blob/master/lib/rules/no-bare-strings-in-template.js)
- [Test source](https://github.com/vuejs/eslint-plugin-vue/blob/master/tests/lib/rules/no-bare-strings-in-template.js)
1 change: 1 addition & 0 deletions lib/index.js
Expand Up @@ -45,6 +45,7 @@ module.exports = {
'name-property-casing': require('./rules/name-property-casing'),
'no-arrow-functions-in-watch': require('./rules/no-arrow-functions-in-watch'),
'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-confusing-v-for-v-if': require('./rules/no-confusing-v-for-v-if'),
'no-custom-modifiers-on-v-model': require('./rules/no-custom-modifiers-on-v-model'),
Expand Down
279 changes: 279 additions & 0 deletions lib/rules/no-bare-strings-in-template.js
@@ -0,0 +1,279 @@
/**
* @author Yosuke Ota
* See LICENSE file in root directory for full license.
*/
'use strict'

// ------------------------------------------------------------------------------
// Requirements
// ------------------------------------------------------------------------------

const utils = require('../utils')
const regexp = require('../utils/regexp')
const casing = require('../utils/casing')

/**
* @typedef {import('vue-eslint-parser').AST.VAttribute} VAttribute
* @typedef {import('vue-eslint-parser').AST.VDirective} VDirective
* @typedef {import('vue-eslint-parser').AST.VElement} VElement
* @typedef {import('vue-eslint-parser').AST.VIdentifier} VIdentifier
* @typedef {import('vue-eslint-parser').AST.VExpressionContainer} VExpressionContainer
* @typedef {import('vue-eslint-parser').AST.VText} VText
*/

/**
* @typedef { { names: { [tagName in string]: Set<string> }, regexps: { name: RegExp, attrs: Set<string> }[], cache: { [tagName in string]: Set<string> } } } TargetAttrs
* @typedef { { upper: ElementStack, name: string, attrs: Set<string> } } ElementStack
*/

// ------------------------------------------------------------------------------
// Constants
// ------------------------------------------------------------------------------

// https://dev.w3.org/html5/html-author/charref
const DEFAULT_WHITELIST = [
'(',
')',
',',
'.',
'&',
'+',
'-',
'=',
'*',
'/',
'#',
'%',
'!',
'?',
':',
'[',
']',
'{',
'}',
'<',
'>',
'\u00b7', // "·"
'\u2022', // "•"
'\u2010', // "‐"
'\u2013', // "–"
'\u2014', // "—"
'\u2212', // "−"
'|'
]

const DEFAULT_ATTRIBUTES = {
'/.+/': [
'title',
'aria-label',
'aria-placeholder',
'aria-roledescription',
'aria-valuetext'
],
input: ['placeholder'],
img: ['alt']
}

const DEFAULT_DIRECTIVES = ['v-text']

// --------------------------------------------------------------------------
// Helpers
// --------------------------------------------------------------------------

/**
* Parse attributes option
* @returns {TargetAttrs}
*/
function parseTargetAttrs(options) {
/** @type {TargetAttrs} */
const result = { names: {}, regexps: [], cache: {} }
for (const tagName of Object.keys(options)) {
/** @type { Set<string> } */
const attrs = new Set(options[tagName])
if (regexp.isRegExp(tagName)) {
result.regexps.push({
name: regexp.toRegExp(tagName),
attrs
})
} else {
result.names[tagName] = attrs
}
}
return result
}

/**
* Get a string from given expression container node
* @param {VExpressionContainer} node
* @returns { string | null }
*/
function getStringValue(value) {
const expression = value.expression
if (!expression) {
return null
}
if (expression.type !== 'Literal') {
return null
}
if (typeof expression.value === 'string') {
return expression.value
}
return null
}

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

module.exports = {
meta: {
type: 'suggestion',
docs: {
description: 'disallow the use of bare strings in `<template>`',
categories: undefined,
url: 'https://eslint.vuejs.org/rules/no-bare-strings-in-template.html'
},
schema: [
{
type: 'object',
properties: {
whitelist: {
type: 'array',
items: { type: 'string' },
uniqueItems: true
},
attributes: {
type: 'object',
patternProperties: {
'^(?:\\S+|/.*/[a-z]*)$': {
type: 'array',
items: { type: 'string' },
uniqueItems: true
}
},
additionalProperties: false
},
directives: {
type: 'array',
items: { type: 'string', pattern: '^v-' },
uniqueItems: true
}
}
}
],
messages: {
unexpected: 'Unexpected non-translated string used.',
unexpectedInAttr: 'Unexpected non-translated string used in `{{attr}}`.'
}
},
create(context) {
const opts = context.options[0] || {}
const whitelist = opts.whitelist || DEFAULT_WHITELIST
const attributes = parseTargetAttrs(opts.attributes || DEFAULT_ATTRIBUTES)
const directives = opts.directives || DEFAULT_DIRECTIVES

const whitelistRe = new RegExp(
whitelist.map((w) => regexp.escape(w)).join('|'),
'gu'
)

/** @type {ElementStack | null} */
let elementStack = null
/**
* Gets the bare string from given string
* @param {string} str
*/
function getBareString(str) {
return str.trim().replace(whitelistRe, '').trim()
}

/**
* Get the attribute to be verified from the element name.
* @param {string} tagName
* @returns {Set<string>}
*/
function getTargetAttrs(tagName) {
if (attributes.cache[tagName]) {
return attributes.cache[tagName]
}
/** @type {string[]} */
const result = []
if (attributes.names[tagName]) {
result.push(...attributes.names[tagName])
}
for (const { name, attrs } of attributes.regexps) {
name.lastIndex = 0
if (name.test(tagName)) {
result.push(...attrs)
}
}
if (casing.isKebabCase(tagName)) {
result.push(...getTargetAttrs(casing.pascalCase(tagName)))
}

return (attributes.cache[tagName] = new Set(result))
}

return utils.defineTemplateBodyVisitor(context, {
/** @param {VText} node */
VText(node) {
if (getBareString(node.value)) {
context.report({
node,
messageId: 'unexpected'
})
}
},
/**
* @param {VElement} node
*/
VElement(node) {
elementStack = {
upper: elementStack,
name: node.rawName,
attrs: getTargetAttrs(node.rawName)
}
},
'VElement:exit'() {
elementStack = elementStack.upper
},
/** @param {VAttribute|VDirective} node */
VAttribute(node) {
if (!node.value) {
return
}
if (node.directive === false) {
const attrs = elementStack.attrs
if (!attrs.has(node.key.rawName)) {
return
}

if (getBareString(node.value.value)) {
context.report({
node: node.value,
messageId: 'unexpectedInAttr',
data: {
attr: node.key.rawName
}
})
}
} else {
const directive = `v-${node.key.name.name}`
if (!directives.includes(directive)) {
return
}
const str = getStringValue(node.value)
if (str && getBareString(str)) {
context.report({
node: node.value,
messageId: 'unexpectedInAttr',
data: {
attr: directive
}
})
}
}
}
})
}
}