no-bare-strings-in-template.js 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263
  1. /**
  2. * @author Yosuke Ota
  3. * See LICENSE file in root directory for full license.
  4. */
  5. 'use strict'
  6. const utils = require('../utils')
  7. const regexp = require('../utils/regexp')
  8. const casing = require('../utils/casing')
  9. /**
  10. * @typedef { { names: { [tagName in string]: Set<string> }, regexps: { name: RegExp, attrs: Set<string> }[], cache: { [tagName in string]: Set<string> } } } TargetAttrs
  11. */
  12. // https://dev.w3.org/html5/html-author/charref
  13. const DEFAULT_ALLOWLIST = [
  14. '(',
  15. ')',
  16. ',',
  17. '.',
  18. '&',
  19. '+',
  20. '-',
  21. '=',
  22. '*',
  23. '/',
  24. '#',
  25. '%',
  26. '!',
  27. '?',
  28. ':',
  29. '[',
  30. ']',
  31. '{',
  32. '}',
  33. '<',
  34. '>',
  35. '\u00B7', // "·"
  36. '\u2022', // "•"
  37. '\u2010', // "‐"
  38. '\u2013', // "–"
  39. '\u2014', // "—"
  40. '\u2212', // "−"
  41. '|'
  42. ]
  43. const DEFAULT_ATTRIBUTES = {
  44. '/.+/': [
  45. 'title',
  46. 'aria-label',
  47. 'aria-placeholder',
  48. 'aria-roledescription',
  49. 'aria-valuetext'
  50. ],
  51. input: ['placeholder'],
  52. img: ['alt']
  53. }
  54. const DEFAULT_DIRECTIVES = ['v-text']
  55. /**
  56. * Parse attributes option
  57. * @param {any} options
  58. * @returns {TargetAttrs}
  59. */
  60. function parseTargetAttrs(options) {
  61. /** @type {TargetAttrs} */
  62. const result = { names: {}, regexps: [], cache: {} }
  63. for (const tagName of Object.keys(options)) {
  64. /** @type { Set<string> } */
  65. const attrs = new Set(options[tagName])
  66. if (regexp.isRegExp(tagName)) {
  67. result.regexps.push({
  68. name: regexp.toRegExp(tagName),
  69. attrs
  70. })
  71. } else {
  72. result.names[tagName] = attrs
  73. }
  74. }
  75. return result
  76. }
  77. /**
  78. * Get a string from given expression container node
  79. * @param {VExpressionContainer} value
  80. * @returns { string | null }
  81. */
  82. function getStringValue(value) {
  83. const expression = value.expression
  84. if (!expression) {
  85. return null
  86. }
  87. if (expression.type !== 'Literal') {
  88. return null
  89. }
  90. if (typeof expression.value === 'string') {
  91. return expression.value
  92. }
  93. return null
  94. }
  95. module.exports = {
  96. meta: {
  97. type: 'suggestion',
  98. docs: {
  99. description: 'disallow the use of bare strings in `<template>`',
  100. categories: undefined,
  101. url: 'https://eslint.vuejs.org/rules/no-bare-strings-in-template.html'
  102. },
  103. schema: [
  104. {
  105. type: 'object',
  106. properties: {
  107. allowlist: {
  108. type: 'array',
  109. items: { type: 'string' },
  110. uniqueItems: true
  111. },
  112. attributes: {
  113. type: 'object',
  114. patternProperties: {
  115. '^(?:\\S+|/.*/[a-z]*)$': {
  116. type: 'array',
  117. items: { type: 'string' },
  118. uniqueItems: true
  119. }
  120. },
  121. additionalProperties: false
  122. },
  123. directives: {
  124. type: 'array',
  125. items: { type: 'string', pattern: '^v-' },
  126. uniqueItems: true
  127. }
  128. },
  129. additionalProperties: false
  130. }
  131. ],
  132. messages: {
  133. unexpected: 'Unexpected non-translated string used.',
  134. unexpectedInAttr: 'Unexpected non-translated string used in `{{attr}}`.'
  135. }
  136. },
  137. /** @param {RuleContext} context */
  138. create(context) {
  139. /**
  140. * @typedef { { upper: ElementStack | null, name: string, attrs: Set<string> } } ElementStack
  141. */
  142. const opts = context.options[0] || {}
  143. /** @type {string[]} */
  144. const allowlist = opts.allowlist || DEFAULT_ALLOWLIST
  145. const attributes = parseTargetAttrs(opts.attributes || DEFAULT_ATTRIBUTES)
  146. const directives = opts.directives || DEFAULT_DIRECTIVES
  147. const allowlistRe = new RegExp(
  148. allowlist
  149. .map((w) => regexp.escape(w))
  150. .sort((a, b) => b.length - a.length)
  151. .join('|'),
  152. 'gu'
  153. )
  154. /** @type {ElementStack | null} */
  155. let elementStack = null
  156. /**
  157. * Gets the bare string from given string
  158. * @param {string} str
  159. */
  160. function getBareString(str) {
  161. return str.trim().replace(allowlistRe, '').trim()
  162. }
  163. /**
  164. * Get the attribute to be verified from the element name.
  165. * @param {string} tagName
  166. * @returns {Set<string>}
  167. */
  168. function getTargetAttrs(tagName) {
  169. if (attributes.cache[tagName]) {
  170. return attributes.cache[tagName]
  171. }
  172. /** @type {string[]} */
  173. const result = []
  174. if (attributes.names[tagName]) {
  175. result.push(...attributes.names[tagName])
  176. }
  177. for (const { name, attrs } of attributes.regexps) {
  178. name.lastIndex = 0
  179. if (name.test(tagName)) {
  180. result.push(...attrs)
  181. }
  182. }
  183. if (casing.isKebabCase(tagName)) {
  184. result.push(...getTargetAttrs(casing.pascalCase(tagName)))
  185. }
  186. return (attributes.cache[tagName] = new Set(result))
  187. }
  188. return utils.defineTemplateBodyVisitor(context, {
  189. /** @param {VText} node */
  190. VText(node) {
  191. if (getBareString(node.value)) {
  192. context.report({
  193. node,
  194. messageId: 'unexpected'
  195. })
  196. }
  197. },
  198. /**
  199. * @param {VElement} node
  200. */
  201. VElement(node) {
  202. elementStack = {
  203. upper: elementStack,
  204. name: node.rawName,
  205. attrs: getTargetAttrs(node.rawName)
  206. }
  207. },
  208. 'VElement:exit'() {
  209. elementStack = elementStack && elementStack.upper
  210. },
  211. /** @param {VAttribute|VDirective} node */
  212. VAttribute(node) {
  213. if (!node.value || !elementStack) {
  214. return
  215. }
  216. if (node.directive === false) {
  217. const attrs = elementStack.attrs
  218. if (!attrs.has(node.key.rawName)) {
  219. return
  220. }
  221. if (getBareString(node.value.value)) {
  222. context.report({
  223. node: node.value,
  224. messageId: 'unexpectedInAttr',
  225. data: {
  226. attr: node.key.rawName
  227. }
  228. })
  229. }
  230. } else {
  231. const directive = `v-${node.key.name.name}`
  232. if (!directives.includes(directive)) {
  233. return
  234. }
  235. const str = getStringValue(node.value)
  236. if (str && getBareString(str)) {
  237. context.report({
  238. node: node.value,
  239. messageId: 'unexpectedInAttr',
  240. data: {
  241. attr: directive
  242. }
  243. })
  244. }
  245. }
  246. }
  247. })
  248. }
  249. }