Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add
vue/no-bare-strings-in-template
rule (#1185)
- Loading branch information
Showing
7 changed files
with
673 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
} | ||
}) | ||
} | ||
} | ||
} | ||
}) | ||
} | ||
} |
Oops, something went wrong.