diff --git a/docs/rules/no-raw-text.md b/docs/rules/no-raw-text.md
index 51883955..65f8dd68 100644
--- a/docs/rules/no-raw-text.md
+++ b/docs/rules/no-raw-text.md
@@ -29,11 +29,11 @@ This rule encourage i18n in about the application needs to be localized.
```js
/* eslint @intlify/vue-i18n/no-raw-text: 'error' */
-export default {
+export default Vue.extend({
// ✗ BAD
template: '
hello
'
// ...
-}
+})
```
@@ -83,11 +83,11 @@ export default {
```js
/* eslint @intlify/vue-i18n/no-raw-text: 'error' */
-export default {
+export default Vue.extend({
// ✓ GOOD
template: `{{ \$t('hello') }}
`
// ...
-}
+})
```
diff --git a/lib/rules/no-raw-text.ts b/lib/rules/no-raw-text.ts
index 4bdc4c47..b7624aba 100644
--- a/lib/rules/no-raw-text.ts
+++ b/lib/rules/no-raw-text.ts
@@ -2,68 +2,84 @@
* @author kazuya kawaguchi (a.k.a. kazupon)
*/
import { parse, AST as VAST } from 'vue-eslint-parser'
-import { defineTemplateBodyVisitor, getVueObjectType } from '../utils/index'
+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,
- SourceLocation
+ SuggestionReportDescriptor,
+ Fix,
+ I18nLocaleMessageDictionary
} from '../types'
-type AnyValue =
- | VAST.ESLintLiteral['value']
- | VAST.ESLintTemplateElement['value']
+type LiteralValue = VAST.ESLintLiteral['value']
+type StaticTemplateLiteral = VAST.ESLintTemplateLiteral & {
+ quasis: [VAST.ESLintTemplateElement]
+ expressions: [/* empty */]
+}
+type TemplateOptionValueNode = VAST.ESLintLiteral | StaticTemplateLiteral
+type NodeScope = 'template' | 'template-option' | 'jsx'
const config: {
ignorePattern: RegExp
ignoreNodes: string[]
ignoreText: string[]
} = { ignorePattern: /^[^\S\s]$/, ignoreNodes: [], ignoreText: [] }
const hasOnlyWhitespace = (value: string) => /^[\r\n\s\t\f\v]+$/.test(value)
-const hasTemplateElementValue = (
- value: AnyValue
-): value is { raw: string; cooked: string } =>
- value != null &&
- typeof value === 'object' &&
- 'raw' in value &&
- typeof value.raw === 'string' &&
- 'cooked' in value &&
- typeof value.cooked === 'string'
const INNER_START_OFFSET = ''.length
+function isStaticTemplateLiteral(
+ node:
+ | VAST.ESLintExpression
+ | VAST.VExpressionContainer['expression']
+ | VAST.ESLintPattern
+): node is StaticTemplateLiteral {
+ return Boolean(
+ node && node.type === 'TemplateLiteral' && node.expressions.length === 0
+ )
+}
+function calculateRange(
+ node: VAST.ESLintLiteral | StaticTemplateLiteral | VAST.VText | JSXText,
+ base: TemplateOptionValueNode | null
+): [number, number] {
+ if (!base) {
+ return node.range
+ }
+ const offset = base.range[0] + 1 /* quote */ - INNER_START_OFFSET
+ return [offset + node.range[0], offset + node.range[1]]
+}
function calculateLoc(
- node: VAST.ESLintLiteral | VAST.ESLintTemplateElement,
- base: VAST.ESLintLiteral | VAST.ESLintTemplateElement | null = null
+ node: VAST.ESLintLiteral | StaticTemplateLiteral | VAST.VText | JSXText,
+ base: TemplateOptionValueNode | null,
+ context: RuleContext
) {
- return !base
- ? node.loc
- : {
- start: {
- line: base.loc.start.line,
- column:
- base.loc.start.column + (node.loc.start.column - INNER_START_OFFSET)
- },
- end: {
- line: base.loc.end.line,
- column:
- base.loc.end.column + (node.loc.end.column - INNER_START_OFFSET)
- }
- }
-}
-
-function testTextable(value: string): boolean {
- return (
- hasOnlyWhitespace(value) ||
- config.ignorePattern.test(value.trim()) ||
- config.ignoreText.includes(value.trim())
- )
+ 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: AnyValue): boolean {
+function testValue(value: LiteralValue): boolean {
if (typeof value === 'string') {
- return testTextable(value)
- } else if (hasTemplateElementValue(value)) {
- return testTextable(value.raw)
+ return (
+ hasOnlyWhitespace(value) ||
+ config.ignorePattern.test(value.trim()) ||
+ config.ignoreText.includes(value.trim())
+ )
} else {
return false
}
@@ -75,7 +91,8 @@ function checkVAttributeDirective(
node: VAST.VExpressionContainer & {
parent: VAST.VDirective
},
- baseNode = null
+ baseNode: TemplateOptionValueNode | null,
+ scope: NodeScope
) {
const attrNode = node.parent
if (attrNode.key && attrNode.key.type === 'VDirectiveKey') {
@@ -87,7 +104,7 @@ function checkVAttributeDirective(
attrNode.key.name.name === 'text') &&
node.expression
) {
- checkExpressionContainerText(context, node.expression, baseNode)
+ checkExpressionContainerText(context, node.expression, baseNode, scope)
}
}
}
@@ -95,7 +112,8 @@ function checkVAttributeDirective(
function checkVExpressionContainer(
context: RuleContext,
node: VAST.VExpressionContainer,
- baseNode: VAST.ESLintLiteral | VAST.ESLintTemplateElement | null = null
+ baseNode: TemplateOptionValueNode | null,
+ scope: NodeScope
) {
if (!node.expression) {
return
@@ -103,7 +121,7 @@ function checkVExpressionContainer(
if (node.parent && node.parent.type === 'VElement') {
// parent is element (e.g. {{ ... }}
)
- checkExpressionContainerText(context, node.expression, baseNode)
+ checkExpressionContainerText(context, node.expression, baseNode, scope)
} else if (
node.parent &&
node.parent.type === 'VAttribute' &&
@@ -113,85 +131,174 @@ function checkVExpressionContainer(
context,
node as VAST.VExpressionContainer & {
parent: VAST.VDirective
- }
+ },
+ baseNode,
+ scope
)
}
}
function checkExpressionContainerText(
context: RuleContext,
expression: Exclude,
- baseNode: VAST.ESLintLiteral | VAST.ESLintTemplateElement | null = null
+ baseNode: TemplateOptionValueNode | null,
+ scope: NodeScope
) {
if (expression.type === 'Literal') {
- const literalNode = expression
- if (testValue(literalNode.value)) {
- return
- }
-
- const loc = calculateLoc(literalNode, baseNode)
- context.report({
- loc,
- message: `raw text '${literalNode.value}' is used`
- })
- } else if (
- expression.type === 'TemplateLiteral' &&
- expression.expressions.length === 0
- ) {
- const templateNode = expression.quasis[0]
- if (testValue(templateNode.value)) {
- return
- }
-
- const loc = calculateLoc(templateNode, baseNode)
- context.report({
- loc,
- message: `raw text '${templateNode.value.raw}' is used`
- })
+ checkLiteral(context, expression, baseNode, scope)
+ } else if (isStaticTemplateLiteral(expression)) {
+ checkLiteral(context, expression, baseNode, scope)
} else if (expression.type === 'ConditionalExpression') {
const targets = [expression.consequent, expression.alternate]
targets.forEach(target => {
if (target.type === 'Literal') {
- if (testValue(target.value)) {
- return
- }
+ checkLiteral(context, target, baseNode, scope)
+ } else if (isStaticTemplateLiteral(target)) {
+ checkLiteral(context, target, baseNode, scope)
+ }
+ })
+ }
+}
- const loc = calculateLoc(target, baseNode)
- context.report({
- loc,
- message: `raw text '${target.value}' is used`
- })
- } else if (
- target.type === 'TemplateLiteral' &&
- target.expressions.length === 0
- ) {
- const node = target.quasis[0]
- if (testValue(node.value)) {
- return
- }
+function checkLiteral(
+ context: RuleContext,
+ literal: VAST.ESLintLiteral | StaticTemplateLiteral,
+ baseNode: TemplateOptionValueNode | null,
+ scope: NodeScope
+) {
+ const value =
+ literal.type === 'Literal' ? literal.value : literal.quasis[0].value.cooked
- const loc = calculateLoc(node, baseNode)
- context.report({
- loc,
- message: `raw text '${node.value.raw}' is used`
- })
+ if (testValue(value)) {
+ return
+ }
+
+ const loc = calculateLoc(literal, baseNode, context)
+ context.report({
+ loc,
+ message: `raw text '${value}' is used`,
+ suggest: buildSuggest()
+ })
+
+ function buildSuggest(): SuggestionReportDescriptor[] | null {
+ if (scope === 'template-option') {
+ if (!withoutEscape(context, baseNode)) {
+ return null
}
- })
+ } else if (scope !== 'template') {
+ return null
+ }
+ const replaceRange = calculateRange(literal, baseNode)
+
+ const suggest: SuggestionReportDescriptor[] = []
+
+ 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 '' block.",
+ fix(fixer) {
+ return generateFixAddI18nBlock(
+ context,
+ fixer,
+ i18nBlocks,
+ `${value}`,
+ [
+ fixer.insertTextBeforeRange(replaceRange, '$t('),
+ fixer.insertTextAfterRange(replaceRange, ')')
+ ]
+ )
+ }
+ })
+ }
+
+ return suggest
}
}
-function checkRawText(
+function checkText(
context: RuleContext,
- value: string,
- loc: SourceLocation
+ textNode: VAST.VText | JSXText,
+ baseNode: TemplateOptionValueNode | null,
+ scope: NodeScope
) {
+ const value = textNode.value
if (testValue(value)) {
return
}
+ const loc = calculateLoc(textNode, baseNode, context)
context.report({
loc,
- message: `raw text '${value}' is used`
+ message: `raw text '${value}' is used`,
+ suggest: buildSuggest()
})
+
+ function buildSuggest(): SuggestionReportDescriptor[] | null {
+ if (scope === 'template-option') {
+ if (!withoutEscape(context, baseNode)) {
+ return null
+ }
+ }
+ const replaceRange = calculateRange(textNode, baseNode)
+ const codeText = context.getSourceCode().text.slice(...replaceRange)
+ const baseQuote = baseNode
+ ? context.getSourceCode().getText(baseNode)[0]
+ : ''
+ const quote =
+ !codeText.includes("'") && !codeText.includes('\n') && baseQuote !== "'"
+ ? "'"
+ : !codeText.includes('"') &&
+ !codeText.includes('\n') &&
+ baseQuote !== '"'
+ ? '"'
+ : !codeText.includes('`') && baseQuote !== '`'
+ ? '`'
+ : null
+ if (quote == null) {
+ return null
+ }
+
+ const before = `${scope === 'jsx' ? '{' : '{{'}$t(${quote}`
+ const after = `${quote})${scope === 'jsx' ? '}' : '}}'}`
+
+ const suggest: SuggestionReportDescriptor[] = []
+
+ for (const key of extractMessageKeys(context, value)) {
+ suggest.push({
+ desc: `Replace to "${before}${key}${after}".`,
+ fix(fixer) {
+ return fixer.replaceTextRange(replaceRange, before + key + after)
+ }
+ })
+ }
+ const i18nBlocks = getFixableI18nBlocks(context, `${value}`)
+ if (i18nBlocks) {
+ suggest.push({
+ desc: "Add the resource to the '' block.",
+ fix(fixer) {
+ return generateFixAddI18nBlock(
+ context,
+ fixer,
+ i18nBlocks,
+ `${value}`,
+ [
+ fixer.insertTextBeforeRange(replaceRange, before),
+ fixer.insertTextAfterRange(replaceRange, after)
+ ]
+ )
+ }
+ })
+ }
+
+ return suggest
+ }
}
function findVariable(variables: Variable[], name: string) {
@@ -201,7 +308,7 @@ function findVariable(variables: Variable[], name: string) {
function getComponentTemplateValueNode(
context: RuleContext,
node: VAST.ESLintObjectExpression
-): VAST.ESLintLiteral | VAST.ESLintTemplateElement | null {
+): TemplateOptionValueNode | null {
const templateNode = node.properties.find(
(p): p is VAST.ESLintProperty =>
p.type === 'Property' &&
@@ -212,11 +319,8 @@ function getComponentTemplateValueNode(
if (templateNode) {
if (templateNode.value.type === 'Literal') {
return templateNode.value
- } else if (
- templateNode.value.type === 'TemplateLiteral' &&
- templateNode.value.expressions.length === 0
- ) {
- return templateNode.value.quasis[0]
+ } else if (isStaticTemplateLiteral(templateNode.value)) {
+ return templateNode.value
} else if (templateNode.value.type === 'Identifier') {
const templateVariable = findVariable(
context.getScope().variables,
@@ -228,11 +332,8 @@ function getComponentTemplateValueNode(
if (varDeclNode.init) {
if (varDeclNode.init.type === 'Literal') {
return varDeclNode.init
- } else if (
- varDeclNode.init.type === 'TemplateLiteral' &&
- varDeclNode.init.expressions.length === 0
- ) {
- return varDeclNode.init.quasis[0]
+ } else if (isStaticTemplateLiteral(varDeclNode.init)) {
+ return varDeclNode.init
}
}
}
@@ -242,20 +343,228 @@ function getComponentTemplateValueNode(
return null
}
-function getComponentTemplateNode(value: AnyValue) {
+function getComponentTemplateNode(node: TemplateOptionValueNode) {
return parse(
`${
- // prettier-ignore
- typeof value === 'string'
- ? value
- : hasTemplateElementValue(value)
- ? value.raw
- : value
+ node.type === 'TemplateLiteral' ? node.quasis[0].value.cooked : node.value
}`,
{}
).templateBody!
}
+function withoutEscape(
+ context: RuleContext,
+ baseNode: TemplateOptionValueNode | null
+) {
+ 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]: string | undefined }
+ i18n: VAST.VElement
+ offsets: {
+ getLoc: (index: number) => { line: number; column: number }
+ getIndex: (index: number) => number
+ }
+ objects: JSONAST.JSONObjectExpression[]
+}
+
+function getFixableI18nBlocks(
+ context: RuleContext,
+ newKey: string
+): I18nBlockInfo[] | null {
+ const df = context.parserServices.getDocumentFragment?.()
+ if (!df) {
+ return null
+ }
+ const i18nBlocks: I18nBlockInfo[] = []
+ 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') // Do not support yaml
+ ) {
+ 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') {
+ // Maybe invalid messages
+ return null
+ }
+ const objects: JSONAST.JSONObjectExpression[] = []
+ if (attrs.locale) {
+ objects.push(root)
+ } else {
+ for (const prop of root.properties) {
+ if (prop.value.type !== 'JSONObjectExpression') {
+ // Maybe invalid messages
+ return null
+ }
+ objects.push(prop.value)
+ }
+ }
+
+ // check for new key
+ // If there are duplicate keys, the addition will be stopped.
+ 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): number => offset + index
+ i18nBlocks.push({
+ attrs,
+ i18n,
+ objects,
+ offsets: {
+ getLoc: (index: number) => {
+ return context.getSourceCode().getLocFromIndex(getIndex(index))
+ },
+ getIndex
+ }
+ })
+ } catch {
+ return null
+ }
+ }
+
+ return i18nBlocks
+}
+
+function* generateFixAddI18nBlock(
+ context: RuleContext,
+ fixer: RuleFixer,
+ i18nBlocks: I18nBlockInfo[],
+ resource: string,
+ replaceFixes: Fix[]
+): IterableIterator {
+ const text = JSON.stringify(resource)
+ const df = context.parserServices.getDocumentFragment!()!
+ const tokenStore = context.parserServices.getTemplateBodyTokenStore()
+
+ if (!i18nBlocks.length) {
+ let baseToken: VAST.VElement | VAST.Token = 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,
+ `\n{\n "en": {\n ${text}: ${text}\n }\n}\n\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: RuleContext,
+ targetValue: string
+): string[] {
+ const keys = new Set()
+ 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: I18nLocaleMessageDictionary,
+ paths: string[]
+ ): Iterable {
+ 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])
+ }
+ }
+ }
+}
+
function create(context: RuleContext): RuleListener {
config.ignorePattern = /^$/
config.ignoreNodes = []
@@ -278,7 +587,7 @@ function create(context: RuleContext): RuleListener {
{
// template block
VExpressionContainer(node: VAST.VExpressionContainer) {
- checkVExpressionContainer(context, node)
+ checkVExpressionContainer(context, node, null, 'template')
},
VText(node: VAST.VText) {
@@ -286,7 +595,7 @@ function create(context: RuleContext): RuleListener {
return
}
- checkRawText(context, node.value, node.loc)
+ checkText(context, node, null, 'template')
}
},
{
@@ -298,18 +607,23 @@ function create(context: RuleContext): RuleListener {
}
if (
getVueObjectType(context, node) == null ||
- valueNode.value == null
+ (valueNode.type === 'Literal' && valueNode.value == null)
) {
return
}
- const templateNode = getComponentTemplateNode(valueNode.value)
+ const templateNode = getComponentTemplateNode(valueNode)
VAST.traverseNodes(templateNode, {
enterNode(node) {
if (node.type === 'VText') {
- checkRawText(context, node.value, valueNode.loc)
+ checkText(context, node, valueNode, 'template-option')
} else if (node.type === 'VExpressionContainer') {
- checkVExpressionContainer(context, node, valueNode)
+ checkVExpressionContainer(
+ context,
+ node,
+ valueNode,
+ 'template-option'
+ )
}
},
leaveNode() {
@@ -319,7 +633,7 @@ function create(context: RuleContext): RuleListener {
},
JSXText(node: JSXText) {
- checkRawText(context, node.value, node.loc)
+ checkText(context, node, null, 'jsx')
}
}
)
@@ -334,6 +648,7 @@ export = {
recommended: true
},
fixable: null,
+ hasSuggestions: true,
schema: [
{
type: 'object',
diff --git a/lib/types/eslint.ts b/lib/types/eslint.ts
index 1942e37a..74c4baf0 100644
--- a/lib/types/eslint.ts
+++ b/lib/types/eslint.ts
@@ -57,7 +57,7 @@ interface ReportDescriptorOptionsBase {
}
type SuggestionDescriptorMessage = { desc: string } | { messageId: string }
-type SuggestionReportDescriptor = SuggestionDescriptorMessage &
+export type SuggestionReportDescriptor = SuggestionDescriptorMessage &
ReportDescriptorOptionsBase
interface ReportDescriptorOptions extends ReportDescriptorOptionsBase {
diff --git a/lib/utils/index.ts b/lib/utils/index.ts
index eb53c979..4f2f51c6 100644
--- a/lib/utils/index.ts
+++ b/lib/utils/index.ts
@@ -133,7 +133,10 @@ const puttedSettingsError = new WeakSet()
* @param {RuleContext} context
* @returns {LocaleMessages}
*/
-export function getLocaleMessages(context: RuleContext): LocaleMessages {
+export function getLocaleMessages(
+ context: RuleContext,
+ options?: { ignoreMissingSettingsError?: boolean }
+): LocaleMessages {
const { settings } = context
/** @type {SettingsVueI18nLocaleDir | null} */
const localeDir =
@@ -150,7 +153,10 @@ export function getLocaleMessages(context: RuleContext): LocaleMessages {
)) ||
[]
if (!localeDir && !i18nBlocks.length) {
- if (!puttedSettingsError.has(context)) {
+ if (
+ !puttedSettingsError.has(context) &&
+ !options?.ignoreMissingSettingsError
+ ) {
context.report({
loc: UNEXPECTED_ERROR_LOCATION,
message: `You need to set 'localeDir' at 'settings', or '' blocks. See the 'eslint-plugin-vue-i18n' documentation`
@@ -247,12 +253,7 @@ function getLocaleMessagesFromI18nBlocks(
const filename = context.getFilename()
localeMessages = i18nBlocks
.map(block => {
- const attrs: { [name: string]: string | undefined } = {}
- for (const attr of block.startTag.attributes) {
- if (!attr.directive && attr.value) {
- attrs[attr.key.name] = attr.value.value
- }
- }
+ const attrs = getStaticAttributes(block)
let localeMessage = null
if (attrs.src) {
const fullpath = resolve(dirname(filename), attrs.src)
@@ -470,6 +471,31 @@ export function isVElement(
): node is VAST.VElement {
return node.type === 'VElement'
}
+/**
+ * Checks whether the given node is ``.
+ * @param node
+ */
+export function isI18nBlock(
+ node: VAST.VElement | VAST.VExpressionContainer | VAST.VText
+): node is VAST.VElement & { name: 'i18n' } {
+ return isVElement(node) && node.name === 'i18n'
+}
+
+/**
+ * Get the static attribute values from a given element.
+ * @param element The element to get.
+ */
+export function getStaticAttributes(
+ element: VAST.VElement
+): { [name: string]: string | undefined } {
+ const attrs: { [name: string]: string | undefined } = {}
+ for (const attr of element.startTag.attributes) {
+ if (!attr.directive && attr.value) {
+ attrs[attr.key.name] = attr.value.value
+ }
+ }
+ return attrs
+}
/**
* Retrieve `TSAsExpression#expression` value if the given node a `TSAsExpression` node. Otherwise, pass through it.
diff --git a/tests/lib/rules/no-raw-text.ts b/tests/lib/rules/no-raw-text.ts
index d88f3a59..1d5f71c6 100644
--- a/tests/lib/rules/no-raw-text.ts
+++ b/tests/lib/rules/no-raw-text.ts
@@ -154,7 +154,21 @@ tester.run('no-raw-text', rule as never, {
errors: [
{
message: `raw text 'hello' is used`,
- line: 1
+ line: 1,
+ suggestions: [
+ {
+ desc: "Add the resource to the '' block.",
+ output: `
+{
+ "en": {
+ "hello": "hello"
+ }
+}
+
+
+{{$t('hello')}}
`
+ }
+ ]
}
]
},
@@ -168,7 +182,25 @@ tester.run('no-raw-text', rule as never, {
errors: [
{
message: `raw text 'hello' is used`,
- line: 3
+ line: 3,
+ suggestions: [
+ {
+ desc: "Add the resource to the '' block.",
+ output: `
+
+{
+ "en": {
+ "hello": "hello"
+ }
+}
+
+
+
+ {{$t('hello')}}
+
+ `
+ }
+ ]
}
]
},
@@ -185,19 +217,103 @@ tester.run('no-raw-text', rule as never, {
errors: [
{
message: `raw text 'hello' is used`,
- line: 4
+ line: 4,
+ suggestions: [
+ {
+ desc: "Add the resource to the '' block.",
+ output: `
+
+{
+ "en": {
+ "hello": "hello"
+ }
+}
+
+
+
+
+
{{$t('hello')}}
+
clickhere!
+
+
+ `
+ }
+ ]
},
{
message: `raw text 'click' is used`,
- line: 5
+ line: 5,
+ suggestions: [
+ {
+ desc: "Add the resource to the '' block.",
+ output: `
+
+{
+ "en": {
+ "click": "click"
+ }
+}
+
+
+
+
+
hello
+
{{$t('click')}}here!
+
+
+ `
+ }
+ ]
},
{
message: `raw text 'here' is used`,
- line: 5
+ line: 5,
+ suggestions: [
+ {
+ desc: "Add the resource to the '' block.",
+ output: `
+
+{
+ "en": {
+ "here": "here"
+ }
+}
+
+
+
+
+
+ `
+ }
+ ]
},
{
message: `raw text '!' is used`,
- line: 5
+ line: 5,
+ suggestions: [
+ {
+ desc: "Add the resource to the '' block.",
+ output: `
+
+{
+ "en": {
+ "!": "!"
+ }
+}
+
+
+
+
+
hello
+
clickhere{{$t('!')}}
+
+
+ `
+ }
+ ]
}
]
},
@@ -211,7 +327,25 @@ tester.run('no-raw-text', rule as never, {
errors: [
{
message: `raw text 'hello' is used`,
- line: 3
+ line: 3,
+ suggestions: [
+ {
+ desc: "Add the resource to the '' block.",
+ output: `
+
+{
+ "en": {
+ "hello": "hello"
+ }
+}
+
+
+
+ {{ $t('hello') }}
+
+ `
+ }
+ ]
}
]
},
@@ -225,7 +359,25 @@ tester.run('no-raw-text', rule as never, {
errors: [
{
message: `raw text 'hello' is used`,
- line: 3
+ line: 3,
+ suggestions: [
+ {
+ desc: "Add the resource to the '' block.",
+ output: `
+
+{
+ "en": {
+ "hello": "hello"
+ }
+}
+
+
+
+ {{ $t(\`hello\`) }}
+
+ `
+ }
+ ]
}
]
},
@@ -239,11 +391,47 @@ tester.run('no-raw-text', rule as never, {
errors: [
{
message: `raw text 'hello' is used`,
- line: 3
+ line: 3,
+ suggestions: [
+ {
+ desc: "Add the resource to the '' block.",
+ output: `
+
+{
+ "en": {
+ "hello": "hello"
+ }
+}
+
+
+
+ {{ ok ? $t('hello') : 'world' }}
+
+ `
+ }
+ ]
},
{
message: `raw text 'world' is used`,
- line: 3
+ line: 3,
+ suggestions: [
+ {
+ desc: "Add the resource to the '' block.",
+ output: `
+
+{
+ "en": {
+ "world": "world"
+ }
+}
+
+
+
+ {{ ok ? 'hello' : $t('world') }}
+
+ `
+ }
+ ]
}
]
},
@@ -257,11 +445,47 @@ tester.run('no-raw-text', rule as never, {
errors: [
{
message: `raw text 'hello' is used`,
- line: 3
+ line: 3,
+ suggestions: [
+ {
+ desc: "Add the resource to the '' block.",
+ output: `
+
+{
+ "en": {
+ "hello": "hello"
+ }
+}
+
+
+
+ {{ ok ? $t(\`hello\`) : \`world\` }}
+
+ `
+ }
+ ]
},
{
message: `raw text 'world' is used`,
- line: 3
+ line: 3,
+ suggestions: [
+ {
+ desc: "Add the resource to the '' block.",
+ output: `
+
+{
+ "en": {
+ "world": "world"
+ }
+}
+
+
+
+ {{ ok ? \`hello\` : $t(\`world\`) }}
+
+ `
+ }
+ ]
}
]
},
@@ -275,7 +499,25 @@ tester.run('no-raw-text', rule as never, {
errors: [
{
message: `raw text 'hello' is used`,
- line: 3
+ line: 3,
+ suggestions: [
+ {
+ desc: "Add the resource to the '' block.",
+ output: `
+
+{
+ "en": {
+ "hello": "hello"
+ }
+}
+
+
+
+
+
+ `
+ }
+ ]
}
]
},
@@ -289,7 +531,25 @@ tester.run('no-raw-text', rule as never, {
errors: [
{
message: `raw text 'hello' is used`,
- line: 3
+ line: 3,
+ suggestions: [
+ {
+ desc: "Add the resource to the '' block.",
+ output: `
+
+{
+ "en": {
+ "hello": "hello"
+ }
+}
+
+
+
+
+
+ `
+ }
+ ]
}
]
},
@@ -303,7 +563,8 @@ tester.run('no-raw-text', rule as never, {
errors: [
{
message: `raw text 'hello' is used`,
- line: 3
+ line: 3,
+ suggestions: []
}
]
},
@@ -317,7 +578,8 @@ tester.run('no-raw-text', rule as never, {
errors: [
{
message: `raw text 'hello' is used`,
- line: 3
+ line: 3,
+ suggestions: []
}
]
},
@@ -331,7 +593,8 @@ tester.run('no-raw-text', rule as never, {
errors: [
{
message: `raw text 'hello' is used`,
- line: 3
+ line: 3,
+ suggestions: []
}
]
},
@@ -345,7 +608,8 @@ tester.run('no-raw-text', rule as never, {
errors: [
{
message: `raw text 'hello' is used`,
- line: 3
+ line: 3,
+ suggestions: []
}
]
},
@@ -360,7 +624,8 @@ tester.run('no-raw-text', rule as never, {
errors: [
{
message: `raw text 'hello' is used`,
- line: 2
+ line: 2,
+ suggestions: []
}
]
},
@@ -375,7 +640,8 @@ tester.run('no-raw-text', rule as never, {
errors: [
{
message: `raw text 'hello' is used`,
- line: 2
+ line: 2,
+ suggestions: []
}
]
},
@@ -391,7 +657,8 @@ tester.run('no-raw-text', rule as never, {
{
message: `raw text 'hello' is used`,
line: 2,
- column: 30
+ column: 31,
+ suggestions: []
}
]
},
@@ -407,7 +674,8 @@ tester.run('no-raw-text', rule as never, {
{
message: `raw text 'hello' is used`,
line: 2,
- column: 30
+ column: 31,
+ suggestions: []
}
]
},
@@ -423,12 +691,14 @@ tester.run('no-raw-text', rule as never, {
{
message: `raw text 'hello' is used`,
line: 2,
- column: 35
+ column: 36,
+ suggestions: []
},
{
message: `raw text 'world' is used`,
line: 2,
- column: 45
+ column: 46,
+ suggestions: []
}
]
},
@@ -444,12 +714,14 @@ tester.run('no-raw-text', rule as never, {
{
message: `raw text 'hello' is used`,
line: 2,
- column: 35
+ column: 36,
+ suggestions: []
},
{
message: `raw text 'world' is used`,
line: 2,
- column: 45
+ column: 46,
+ suggestions: []
}
]
},
@@ -465,7 +737,8 @@ tester.run('no-raw-text', rule as never, {
errors: [
{
message: `raw text 'hello' is used`,
- line: 4
+ line: 4,
+ suggestions: []
}
]
},
@@ -481,7 +754,27 @@ tester.run('no-raw-text', rule as never, {
errors: [
{
message: `raw text 'hello' is used`,
- line: 5
+ line: 5,
+ suggestions: [
+ {
+ desc: "Add the resource to the '' block.",
+ output: `
+
+{
+ "en": {
+ "hello": "hello"
+ }
+}
+
+
+
+ person
+ menu
+ {{$t('hello')}}
+
+ `
+ }
+ ]
}
]
},
@@ -500,17 +793,86 @@ tester.run('no-raw-text', rule as never, {
errors: [
{
message: `raw text 'hello' is used`,
- line: 4
+ line: 4,
+ suggestions: [
+ {
+ desc: "Add the resource to the '' block.",
+ output: `
+
+{
+ "en": {
+ "hello": "hello"
+ }
+}
+
+
+
+ {{ $t(\`foo\`) }}: {{ $t('bar') }}
+ {{$t('hello')}}
+ -
+ @
+ {{ true ? $t(\`ok\`) : ' - ' }}
+ {{ true ? $t('ok') : '@' }}
+
+ `
+ }
+ ]
},
{
message: `raw text '@' is used`,
line: 6,
- column: 12
+ column: 12,
+ suggestions: [
+ {
+ desc: "Add the resource to the '' block.",
+ output: `
+
+{
+ "en": {
+ "@": "@"
+ }
+}
+
+
+
+ {{ $t(\`foo\`) }}: {{ $t('bar') }}
+ hello
+ -
+ {{$t('@')}}
+ {{ true ? $t(\`ok\`) : ' - ' }}
+ {{ true ? $t('ok') : '@' }}
+
+ `
+ }
+ ]
},
{
message: `raw text '@' is used`,
line: 8,
- column: 33
+ column: 33,
+ suggestions: [
+ {
+ desc: "Add the resource to the '' block.",
+ output: `
+
+{
+ "en": {
+ "@": "@"
+ }
+}
+
+
+
+ {{ $t(\`foo\`) }}: {{ $t('bar') }}
+ hello
+ -
+ @
+ {{ true ? $t(\`ok\`) : ' - ' }}
+ {{ true ? $t('ok') : $t('@') }}
+
+ `
+ }
+ ]
}
]
},
@@ -534,7 +896,29 @@ tester.run('no-raw-text', rule as never, {
{
message: `raw text 'not' is used`,
line: 4,
- column: 25
+ column: 25,
+ suggestions: [
+ {
+ desc: "Add the resource to the '' block.",
+ output: `
+
+{
+ "en": {
+ "not": "not"
+ }
+}
+
+
+
+
+
+
+
+
+
+ `
+ }
+ ]
}
]
},
@@ -549,7 +933,26 @@ tester.run('no-raw-text', rule as never, {
errors: [
{
message: `raw text 'world' is used`,
- line: 4
+ line: 4,
+ suggestions: [
+ {
+ desc: "Add the resource to the '' block.",
+ output: `
+
+{
+ "en": {
+ "world": "world"
+ }
+}
+
+
+
+ hello
+ {{$t('world')}}
+
+ `
+ }
+ ]
}
]
},
@@ -603,6 +1006,324 @@ tester.run('no-raw-text', rule as never, {
"raw text 'components option' is used",
"raw text 'mark' is used"
]
+ },
+ {
+ code: `
+ `,
+ errors: [
+ {
+ message: `raw text 'foo' is used`,
+ suggestions: [
+ {
+ desc: "Add the resource to the '' block.",
+ output: `
+
+{
+ "en": {
+ "foo": "foo"
+ }
+}
+
+
+`
+ }
+ ]
+ }
+ ]
+ },
+ {
+ code: `
+ `,
+ errors: [
+ {
+ message: `raw text 'foo' is used`,
+ suggestions: [
+ {
+ desc: "Add the resource to the '' block.",
+ output: `
+
+{
+ "en": {
+ "foo": "foo"
+ }
+}
+
+
+`
+ }
+ ]
+ }
+ ]
+ },
+ {
+ code: `
+ `,
+ errors: [
+ {
+ message: `raw text '\\foo' is used`,
+ suggestions: []
+ }
+ ]
+ },
+ {
+ code: `
+ `,
+ errors: [
+ {
+ message: `raw text 'foo' is used`,
+ suggestions: [
+ {
+ desc: "Add the resource to the '' block.",
+ output: `
+
+{
+ "en": {
+ "foo": "foo"
+ }
+}
+
+
+`
+ }
+ ]
+ }
+ ]
+ },
+ {
+ code: `
+ `,
+ errors: [
+ {
+ message: `raw text '"foo' is used`,
+ suggestions: []
+ }
+ ]
+ },
+ {
+ code: `
+ `,
+ errors: [
+ {
+ message: `raw text 'hello' is used`,
+ suggestions: [
+ {
+ desc: "Add the resource to the '' block.",
+ output: `
+
+{
+ "en": {
+ "hello": "hello"
+ }
+}
+
+
+`
+ }
+ ]
+ }
+ ]
+ },
+ {
+ code: `
+
+ {
+ "ja": {
+ "hello": "こんにちは"
+ },
+ "en": {"hello": "hello"},
+ "zh": {}
+ }
+
+
+ foo
+ `,
+ errors: [
+ {
+ message: `raw text 'foo' is used`,
+ suggestions: [
+ {
+ desc: "Add the resource to the '' block.",
+ output: `
+
+ {
+ "ja": {
+ "foo": "foo",
+ "hello": "こんにちは"
+ },
+ "en": {
+ "foo": "foo",
+ "hello": "hello"},
+ "zh": {
+ "foo": "foo"}
+ }
+
+
+ {{$t('foo')}}
+ `
+ }
+ ]
+ }
+ ]
+ },
+ {
+ code: `
+
+ {
+ "ja": {
+ "hello": "こんにちは"
+ },
+ "en": {"hello": "hello"},
+ "zh": {}
+ }
+
+
+ こんにちは
+ `,
+ errors: [
+ {
+ message: `raw text 'こんにちは' is used`,
+ suggestions: [
+ {
+ desc: `Replace to "{{$t('hello')}}".`,
+ output: `
+
+ {
+ "ja": {
+ "hello": "こんにちは"
+ },
+ "en": {"hello": "hello"},
+ "zh": {}
+ }
+
+
+ {{$t('hello')}}
+ `
+ },
+ {
+ desc: "Add the resource to the '' block.",
+ output: `
+
+ {
+ "ja": {
+ "こんにちは": "こんにちは",
+ "hello": "こんにちは"
+ },
+ "en": {
+ "こんにちは": "こんにちは",
+ "hello": "hello"},
+ "zh": {
+ "こんにちは": "こんにちは"}
+ }
+
+
+ {{$t('こんにちは')}}
+ `
+ }
+ ]
+ }
+ ]
+ },
+ {
+ code: `
+
+ {
+ "ja": {
+ "hello": "こんにちは"
+ },
+ "en": {"hello": "hello"},
+ "zh": {}
+ }
+
+
+ {{'こんにちは'}}
+ `,
+ errors: [
+ {
+ message: `raw text 'こんにちは' is used`,
+ suggestions: [
+ {
+ desc: `Replace to "$t('hello')".`,
+ output: `
+
+ {
+ "ja": {
+ "hello": "こんにちは"
+ },
+ "en": {"hello": "hello"},
+ "zh": {}
+ }
+
+
+ {{$t('hello')}}
+ `
+ },
+ {
+ desc: "Add the resource to the '' block.",
+ output: `
+
+ {
+ "ja": {
+ "こんにちは": "こんにちは",
+ "hello": "こんにちは"
+ },
+ "en": {
+ "こんにちは": "こんにちは",
+ "hello": "hello"},
+ "zh": {
+ "こんにちは": "こんにちは"}
+ }
+
+
+ {{$t('こんにちは')}}
+ `
+ }
+ ]
+ }
+ ]
}
]
})