author kazuya kawaguchi (a.k.a. kazupon)
  import { parse , AST as VAST } from 'vue-eslint-parser' ; import type { AST as JSONAST } from 'jsonc-eslint-parser' ; import { parseJSON , getStaticJSONValue } from 'jsonc-eslint-parser' ; import { defineTemplateBodyVisitor , getLocaleMessages , getStaticAttributes , getVueObjectType , isI18nBlock , isVElement } from '../utils/index' ; import type { JSXText , RuleContext , RuleFixer , Variable , RuleListener , SuggestionReportDescriptor , Fix , I18nLocaleMessageDictionary , Range } from '../types' ; import { isKebabCase , pascalCase } from '../utils/casing' ; type LiteralValue = VAST . ESLintLiteral [ 'value' ] ; type StaticTemplateLiteral = VAST . ESLintTemplateLiteral & { quasis : [ VAST . ESLintTemplateElement ] ; expressions : [ ] ; } ; type TemplateOptionValueNode = VAST . ESLintLiteral | StaticTemplateLiteral ; type NodeScope = 'template' | 'template-option' | 'jsx' ; type TargetAttrs = { name : RegExp ; attrs : Set < string > ; } ; type Config = { attributes : TargetAttrs [ ] ; ignorePattern : RegExp ; ignoreNodes : string [ ] ; ignoreText : string [ ] ; } ; type Quotes = Set < '"' | "'" | '`' > ; function getFixQuote ( quotes , code ) { if ( ! code . includes ( '\n' ) ) { for ( const q of [ "'" , '"' ] as const ) { if ( ! quotes . has ( q ) && ! code . includes ( q ) ) { return q ; } } } if ( ! quotes . has ( '`' ) && ! code . includes ( '`' ) ) { return '`' ; } return null ; } const RE_REGEXP_CHAR = /[\\^$.*+?()[\]{}|]/gu ; const RE_HAS_REGEXP_CHAR = new RegExp ( RE_REGEXP_CHAR . source ) ; const RE_REGEXP_STR = /^\/(.+)\/(.*)$/u ; function toRegExp ( str ) { const parts = RE_REGEXP_STR . exec ( str ) ; if ( parts ) { return new RegExp ( parts [ 1 ] , parts [ 2 ] ) ; } return new RegExp ( `^${ escape ( str ) }$` ) ; } const hasOnlyWhitespace = ( value ) => /^[\r\n\s\t\f\v]+$/ . test ( value ) ; const INNER_START_OFFSET = '<template>' . length ; param { string } string returns { string } function escape ( str ) { return str && RE_HAS_REGEXP_CHAR . test ( str ) ? str . replace ( RE_REGEXP_CHAR , '\\$&' ) : str ; } /**
 * Get the attribute to be verified from the element name.
 */ function getTargetAttrs ( tagName , config ) { const result = [ ] ; for ( const { name , attrs } of config . attributes ) { name . lastIndex = 0 ; if ( name . test ( tagName ) ) { result . push ( ... attrs ) ; } } if ( isKebabCase ( tagName ) ) { result . push ( ... getTargetAttrs ( pascalCase ( tagName ) , config ) ) ; } return new Set ( result ) ; } function isStaticTemplateLiteral ( node ) { return Boolean ( node && node . type === 'TemplateLiteral' && node . expressions . length === 0 ) ; } function calculateRange ( node , base ) { const range = node . range ; if ( ! base ) { return range ; } const offset = base . range [ 0 ] + 1 - INNER_START_OFFSET ; return [ offset + range [ 0 ] , offset + range [ 1 ] ] ; } function calculateLoc ( node , base , context ) { if ( ! base ) { return node . loc ; } const range = calculateRange ( node , base ) ; return { start : context . getSourceCode ( ) . getLocFromIndex ( range [ 0 ] ) , end : context . getSourceCode ( ) . getLocFromIndex ( range [ 1 ] ) } ; } function testValue ( value , config ) { if ( typeof value === 'string' ) { return ( hasOnlyWhitespace ( value ) || config . ignorePattern . test ( value . trim ( ) ) || config . ignoreText . includes ( value . trim ( ) ) ) ; } else { return false ; } } function checkVAttributeDirective ( context , node , config , baseNode , scope ) { const attrNode = node . parent ; if ( attrNode . key && attrNode . key . type === 'VDirectiveKey' ) { if ( ( attrNode . key . name === 'text' || attrNode . key . name . name === 'text' ) && node . expression ) { checkExpressionContainerText ( context , node . expression , config , baseNode , scope ) ; } } } function checkVExpressionContainer ( context , node , config , baseNode , scope ) { if ( ! node . expression ) { return ; } if ( node . parent && node . parent . type === 'VElement' ) { checkExpressionContainerText ( context , node . expression , config , baseNode , scope ) ; } else if ( node . parent && node . parent . type === 'VAttribute' && node . parent . directive ) { checkVAttributeDirective ( context , node as VAST . VExpressionContainer & { parent : VAST . VDirective ; } , config , baseNode , scope ) ; } } function checkExpressionContainerText ( context , expression , config , baseNode , scope ) { if ( expression . type === 'Literal' ) { checkLiteral ( context , expression , config , baseNode , scope ) ; } else if ( isStaticTemplateLiteral ( expression ) ) { checkLiteral ( context , expression , config , baseNode , scope ) ; } else if ( expression . type === 'ConditionalExpression' ) { const targets = [ expression . consequent , expression . alternate ] ; targets . forEach ( target => { if ( target . type === 'Literal' ) { checkLiteral ( context , target , config , baseNode , scope ) ; } else if ( isStaticTemplateLiteral ( target ) ) { checkLiteral ( context , target , config , baseNode , scope ) ; } } ) ; } } function checkLiteral ( context , literal , config , baseNode , scope ) { const value = literal . type !== 'TemplateLiteral' ? literal . value : literal . quasis [ 0 ] . value . cooked ; if ( testValue ( value , config ) ) { return ; } const loc = calculateLoc ( literal , baseNode , context ) ; context . report ( { loc , message : `raw text '${ value }' is used` , suggest : buildSuggest ( ) } ) ; function buildSuggest ( ) { if ( scope === 'template-option' ) { if ( ! withoutEscape ( context , baseNode ) ) { return null ; } } else if ( scope !== 'template' ) { return null ; } const replaceRange = calculateRange ( literal , baseNode ) ; const suggest = [ ] ; for ( const key of extractMessageKeys ( context , `${ value }` ) ) { suggest . push ( { desc : `Replace to "$t('${ key }')".` , fix ( fixer ) { return fixer . replaceTextRange ( replaceRange , `$t('${ key }')` ) ; } } ) ; } const i18nBlocks = getFixableI18nBlocks ( context , `${ value }` ) ; if ( i18nBlocks ) { suggest . push ( { desc : "Add the resource to the '<i18n>' block." , fix ( fixer ) { return generateFixAddI18nBlock ( context , fixer , i18nBlocks , `${ value }` , [ fixer . insertTextBeforeRange ( replaceRange , '$t(' ) , fixer . insertTextAfterRange ( replaceRange , ')' ) ] ) ; } } ) ; } return suggest ; } } function checkVAttribute ( context , attribute , config , baseNode , scope ) { if ( ! attribute . value ) { return ; } const literal = attribute . value ; const value = literal . value ; if ( testValue ( value , config ) ) { return ; } const loc = calculateLoc ( literal , baseNode , context ) ; context . report ( { loc , message : `raw text '${ value }' is used` , suggest : buildSuggest ( ) } ) ; function buildSuggest ( ) { if ( scope === 'template-option' ) { if ( ! withoutEscape ( context , baseNode ) ) { return null ; } } else if ( scope !== 'template' ) { return null ; } const literalRange = calculateRange ( literal , baseNode ) ; const replaceRange = [ literalRange [ 0 ] + 1 , literalRange [ 1 ] - 1 ] as Range ; const keyRange = calculateRange ( attribute . key , baseNode ) ; const sourceCode = context . getSourceCode ( ) ; const attrQuote = sourceCode . text [ literalRange [ 0 ] ] ; const quotes = new Set ( attrQuote as never ) ; if ( baseNode ) { const baseQuote = sourceCode . text [ baseNode . range [ 0 ] ] ; quotes . add ( baseQuote as never ) ; } const suggest = [ ] ; for ( const key of extractMessageKeys ( context , `${ value }` ) ) { const quote = getFixQuote ( quotes , key ) ; if ( quote ) { suggest . push ( { desc : `Replace to "$t('${ key }')".` , fix ( fixer ) { return [ fixer . insertTextBeforeRange ( keyRange , ':' ) , fixer . replaceTextRange ( replaceRange , `$t(${ quote }${ key }${ quote })` ) ] ; } } ) ; } } const i18nBlocks = getFixableI18nBlocks ( context , `${ value }` ) ; const quote = getFixQuote ( quotes , sourceCode . text . slice ( ... replaceRange ) ) ; if ( i18nBlocks && quote ) { suggest . push ( { desc : "Add the resource to the '<i18n>' block." , fix ( fixer ) { return generateFixAddI18nBlock ( context , fixer , i18nBlocks , `${ value }` , [ fixer . insertTextBeforeRange ( keyRange , ':' ) , fixer . insertTextBeforeRange ( replaceRange , `$t(${ quote }` ) , fixer . insertTextAfterRange ( replaceRange , `${ quote })` ) ] ) ; } } ) ; } return suggest ; } } function checkText ( context , textNode , config , baseNode , scope ) { const value = textNode . value ; if ( testValue ( value , config ) ) { return ; } const loc = calculateLoc ( textNode , baseNode , context ) ; context . report ( { loc , message : `raw text '${ value }' is used` , suggest : buildSuggest ( ) } ) ; function buildSuggest ( ) { if ( scope === 'template-option' ) { if ( ! withoutEscape ( context , baseNode ) ) { return null ; } } const replaceRange = calculateRange ( textNode , baseNode ) ; const sourceCode = context . getSourceCode ( ) ; const quotes = new Set ( ) ; if ( baseNode ) { const baseQuote = sourceCode . text [ baseNode . range [ 0 ] ] ; quotes . add ( baseQuote as never ) ; } const suggest = [ ] ; for ( const key of extractMessageKeys ( context , value ) ) { const quote = getFixQuote ( quotes , key ) ; if ( quote ) { const before = `${ scope === 'jsx' ? '{' : '{{' }$t(${ quote }` ; const after = `${ quote })${ scope === 'jsx' ? '}' : '}}' }` ; suggest . push ( { desc : `Replace to "${ before }${ key }${ after }".` , fix ( fixer ) { return fixer . replaceTextRange ( replaceRange , before + key + after ) ; } } ) ; } } const i18nBlocks = getFixableI18nBlocks ( context , `${ value }` ) ; const quote = getFixQuote ( quotes , sourceCode . text . slice ( ... replaceRange ) ) ; if ( i18nBlocks && quote ) { const before = `${ scope === 'jsx' ? '{' : '{{' }$t(${ quote }` ; const after = `${ quote })${ scope === 'jsx' ? '}' : '}}' }` ; suggest . push ( { desc : "Add the resource to the '<i18n>' block." , fix ( fixer ) { return generateFixAddI18nBlock ( context , fixer , i18nBlocks , `${ value }` , [ fixer . insertTextBeforeRange ( replaceRange , before ) , fixer . insertTextAfterRange ( replaceRange , after ) ] ) ; } } ) ; } return suggest ; } } function findVariable ( variables , name ) { return variables . find ( variable => variable . name === name ) ; } function getComponentTemplateValueNode ( context , node ) { const templateNode = node . properties . find ( ( p ) : p is VAST . ESLintProperty => p . type === 'Property' && p . key . type === 'Identifier' && p . key . name === 'template' ) ; if ( templateNode ) { if ( templateNode . value . type === 'Literal' ) { return templateNode . value ; } else if ( isStaticTemplateLiteral ( templateNode . value ) ) { return templateNode . value ; } else if ( templateNode . value . type === 'Identifier' ) { const templateVariable = findVariable ( context . getScope ( ) . variables , templateNode . value . name ) ; if ( templateVariable ) { const varDeclNode = templateVariable . defs [ 0 ] . node as VAST . ESLintVariableDeclarator ; if ( varDeclNode . init ) { if ( varDeclNode . init . type === 'Literal' ) { return varDeclNode . init ; } else if ( isStaticTemplateLiteral ( varDeclNode . init ) ) { return varDeclNode . init ; } } } } } return null ; } function getComponentTemplateNode ( node ) { return parse ( `<template>${ node . type === 'TemplateLiteral' ? node . quasis [ 0 ] . value . cooked : node . value }</template>` , { } ) . templateBody ! ; } function withoutEscape ( context , baseNode ) { if ( ! baseNode ) { return false ; } const sourceText = context . getSourceCode ( ) . getText ( baseNode ) . slice ( 1 , - 1 ) ; const templateText = baseNode . type === 'TemplateLiteral' ? baseNode . quasis [ 0 ] . value . cooked : `${ baseNode . value }` ; return sourceText === templateText ; } type I18nBlockInfo = { attrs : { [ name ] : string | undefined ; } ; i18n : VAST . VElement ; offsets : { getLoc : ( index : number ) => { line : number ; column : number ; } ; getIndex : ( index : number ) => number ; } ; objects : JSONAST . JSONObjectExpression [ ] ; } ; function getFixableI18nBlocks ( context , newKey ) { const df = context . parserServices . getDocumentFragment ?. ( ) ; if ( ! df ) { return null ; } const i18nBlocks = [ ] ; for ( const i18n of df . children . filter ( isI18nBlock ) ) { const attrs = getStaticAttributes ( i18n ) ; if ( attrs . src != null || ( attrs . lang != null && attrs . lang !== 'json' && attrs . lang !== 'json5' ) ) { return null ; } const textNode = i18n . children [ 0 ] ; const sourceString = textNode != null && textNode . type === 'VText' && textNode . value ; if ( ! sourceString ) { return null ; } try { const ast = parseJSON ( sourceString ) ; const root = ast . body [ 0 ] . expression ; if ( root . type !== 'JSONObjectExpression' ) { return null ; } const objects = [ ] ; if ( attrs . locale ) { objects . push ( root ) ; } else { for ( const prop of root . properties ) { if ( prop . value . type !== 'JSONObjectExpression' ) { return null ; } objects . push ( prop . value ) ; } } for ( const objNode of objects ) { if ( objNode . properties . some ( prop => { const keyValue = `${ getStaticJSONValue ( prop . key ) }` ; return keyValue === newKey ; } ) ) { return null ; } } const offset = textNode . range [ 0 ] ; const getIndex = ( index ) : number => offset + index ; i18nBlocks . push ( { attrs , i18n , objects , offsets : { getLoc : ( index ) => { return context . getSourceCode ( ) . getLocFromIndex ( getIndex ( index ) ) ; } , getIndex } } ) ; } catch { return null ; } } return i18nBlocks ; } function * generateFixAddI18nBlock ( context , fixer , i18nBlocks , resource , replaceFixes ) { const text = JSON . stringify ( resource ) ; const df = context . parserServices . getDocumentFragment ! ( ) ! ; const tokenStore = context . parserServices . getTemplateBodyTokenStore ( ) ; if ( ! i18nBlocks . length ) { let baseToken = df . children . find ( isVElement ) ! ; let beforeToken = tokenStore . getTokenBefore ( baseToken , { includeComments : true } ) ; while ( beforeToken && beforeToken . type === 'HTMLComment' ) { baseToken = beforeToken ; beforeToken = tokenStore . getTokenBefore ( beforeToken , { includeComments : true } ) ; } yield fixer . insertTextBeforeRange ( baseToken . range , `<i18n>\n{\n  "en": {\n    ${ text }: ${ text }\n  }\n}\n</i18n>\n\n` ) ; yield * replaceFixes ; return ; } const replaceFix = replaceFixes [ 0 ] ; const after = i18nBlocks . find ( e => replaceFix . range [ 1 ] < e . i18n . range [ 0 ] ) ; for ( const { i18n , offsets , objects } of i18nBlocks ) { if ( after && after . i18n === i18n ) { yield * replaceFixes ; } for ( const objectNode of objects ) { const first = objectNode . properties [ 0 ] ; let indent = /^\s*/ . exec ( context . getSourceCode ( ) . lines [ offsets . getLoc ( objectNode . range [ 0 ] ) . line - 1 ] ) ! [ 0 ] + '  ' ; let next = '' ; if ( first ) { if ( objectNode . loc . start . line === first . loc . start . line ) { next = ',\n' + indent ; } else { indent = /^\s*/ . exec ( context . getSourceCode ( ) . lines [ offsets . getLoc ( first . range [ 0 ] ) . line - 1 ] ) ! [ 0 ] ; next = ',' ; } } yield fixer . insertTextAfterRange ( [ offsets . getIndex ( objectNode . range [ 0 ] ) , offsets . getIndex ( objectNode . range [ 0 ] + 1 ) ] , `\n${ indent }${ text }: ${ text }${ next }` ) ; } } if ( after == null ) { yield * replaceFixes ; } } function extractMessageKeys ( context , targetValue ) { const keys = new Set < string > ( ) ; const localeMessages = getLocaleMessages ( context , { ignoreMissingSettingsError : true } ) ; for ( const localeMessage of localeMessages . localeMessages ) { for ( const locale of localeMessage . locales ) { const messages = localeMessage . getMessagesFromLocale ( locale ) ; for ( const key of extractMessageKeysFromObject ( messages , [ ] ) ) { keys . add ( key ) ; } } } return [ ... keys ] . sort ( ) ; function * extractMessageKeysFromObject ( messages , paths ) { for ( const key of Object . keys ( messages ) ) { const value = messages [ key ] ; if ( typeof value === 'string' ) { if ( targetValue === value ) { yield [ ... paths , key ] . join ( '.' ) ; } } else { yield * extractMessageKeysFromObject ( value , [ ... paths , key ] ) ; } } } } /**
 * Parse attributes option
 */ function parseTargetAttrs ( options ) { const regexps = [ ] ; for ( const tagName of Object . keys ( options ) ) { const attrs = new Set ( options [ tagName ] ) ; regexps . push ( { name : toRegExp ( tagName ) , attrs } ) ; } return regexps ; } function create ( context ) { const options = context . options [ 0 ] || { } ; const config = { attributes : [ ] , ignorePattern : /^$/ , ignoreNodes : [ ] , ignoreText : [ ] } ; if ( options . ignorePattern ) { config . ignorePattern = new RegExp ( options . ignorePattern , 'u' ) ; } if ( options . ignoreNodes ) { config . ignoreNodes = options . ignoreNodes ; } if ( options . ignoreText ) { config . ignoreText = options . ignoreText ; } if ( options . attributes ) { config . attributes = parseTargetAttrs ( options . attributes ) ; } const templateVisitor = { VExpressionContainer ( node , baseNode = null , scope = 'template' ) { checkVExpressionContainer ( context , node , config , baseNode , scope ) ; } , VAttribute ( node , baseNode = null , scope = 'template' ) { if ( node . directive ) { return ; } const tagName = node . parent . parent . rawName ; const attrName = node . key . name ; if ( ! getTargetAttrs ( tagName , config ) . has ( attrName ) ) { return ; } checkVAttribute ( context , node , config , baseNode , scope ) ; } , VText ( node , baseNode = null , scope = 'template' ) { if ( config . ignoreNodes . includes ( ( node . parent as VAST . VElement ) . name ) ) { return ; } checkText ( context , node , config , baseNode , scope ) ; } } ; return defineTemplateBodyVisitor ( context , templateVisitor , { ObjectExpression ( node ) { const valueNode = getComponentTemplateValueNode ( context , node ) ; if ( ! valueNode ) { return ; } if ( getVueObjectType ( context , node ) == null || ( valueNode . type === 'Literal' && valueNode . value == null ) ) { return ; } const templateNode = getComponentTemplateNode ( valueNode ) ; VAST . traverseNodes ( templateNode , { enterNode ( node ) { const visitor = templateVisitor [ node . type as never ] as any ; if ( visitor ) { visitor ( node , valueNode , 'template-option' ) ; } } , leaveNode ( ) { } } ) ; } , JSXText ( node ) { checkText ( context , node , config , null , 'jsx' ) ; } } ) ; } export = { meta : { type : 'suggestion' , docs : { description : 'disallow to string literal in template or JSX' , category : 'Recommended' , recommended : true } , fixable : null , hasSuggestions : true , schema : [ { type : 'object' , properties : { attributes : { type : 'object' , patternProperties : { '^(?:\\S+|/.*/[a-z]*)$' : { type : 'array' , items : { type : 'string' } , uniqueItems : true } } , additionalProperties : false } , ignoreNodes : { type : 'array' } , ignorePattern : { type : 'string' } , ignoreText : { type : 'array' } } } ] } , create } ;