You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
276 lines
6.8 KiB
276 lines
6.8 KiB
/** |
|
* @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 { { names: { [tagName in string]: Set<string> }, regexps: { name: RegExp, attrs: Set<string> }[], cache: { [tagName in string]: Set<string> } } } TargetAttrs |
|
*/ |
|
|
|
// ------------------------------------------------------------------------------ |
|
// Constants |
|
// ------------------------------------------------------------------------------ |
|
|
|
// https://dev.w3.org/html5/html-author/charref |
|
const DEFAULT_ALLOWLIST = [ |
|
'(', |
|
')', |
|
',', |
|
'.', |
|
'&', |
|
'+', |
|
'-', |
|
'=', |
|
'*', |
|
'/', |
|
'#', |
|
'%', |
|
'!', |
|
'?', |
|
':', |
|
'[', |
|
']', |
|
'{', |
|
'}', |
|
'<', |
|
'>', |
|
'\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 |
|
* @param {any} options |
|
* @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} value |
|
* @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: { |
|
allowlist: { |
|
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 |
|
} |
|
}, |
|
additionalProperties: false |
|
} |
|
], |
|
messages: { |
|
unexpected: 'Unexpected non-translated string used.', |
|
unexpectedInAttr: 'Unexpected non-translated string used in `{{attr}}`.' |
|
} |
|
}, |
|
/** @param {RuleContext} context */ |
|
create(context) { |
|
/** |
|
* @typedef { { upper: ElementStack | null, name: string, attrs: Set<string> } } ElementStack |
|
*/ |
|
const opts = context.options[0] || {} |
|
/** @type {string[]} */ |
|
const allowlist = opts.allowlist || DEFAULT_ALLOWLIST |
|
const attributes = parseTargetAttrs(opts.attributes || DEFAULT_ATTRIBUTES) |
|
const directives = opts.directives || DEFAULT_DIRECTIVES |
|
|
|
const allowlistRe = new RegExp( |
|
allowlist.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(allowlistRe, '').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 && elementStack.upper |
|
}, |
|
/** @param {VAttribute|VDirective} node */ |
|
VAttribute(node) { |
|
if (!node.value || !elementStack) { |
|
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 |
|
} |
|
}) |
|
} |
|
} |
|
} |
|
}) |
|
} |
|
}
|
|
|