diff --git a/docs/rules/no-raw-text.md b/docs/rules/no-raw-text.md index 34772e6..41106e3 100644 --- a/docs/rules/no-raw-text.md +++ b/docs/rules/no-raw-text.md @@ -55,6 +55,17 @@ This rule encourage i18n in about the application needs to be localized. "@intlify/svelte/no-raw-text": [ "error", { + "attributes": { + "/.+/": [ + "title", + "aria-label", + "aria-placeholder", + "aria-roledescription", + "aria-valuetext" + ], + "input": ["placeholder"], + "img": ["alt"] + }, "ignoreNodes": ["md-icon", "v-icon"], "ignorePattern": "^[-#:()&]+$", "ignoreText": ["EUR", "HKD", "USD"] @@ -63,10 +74,44 @@ This rule encourage i18n in about the application needs to be localized. } ``` +- `attributes`: An object whose keys are tag name or patterns and value is an array of attributes to check for that tag name. Default empty. - `ignoreNodes`: specify nodes to ignore such as icon components - `ignorePattern`: specify a regexp pattern that matches strings to ignore - `ignoreText`: specify an array of strings to ignore +### `attributes` + + + + + +```svelte + + + + +``` + + + + + + + +```svelte + + + + + +``` + + + ## :rocket: Version This rule was introduced in `@intlify/eslint-plugin-svelte` v0.0.1 diff --git a/lib/rules/no-raw-text.ts b/lib/rules/no-raw-text.ts index 06f78ce..f2b6720 100644 --- a/lib/rules/no-raw-text.ts +++ b/lib/rules/no-raw-text.ts @@ -6,23 +6,63 @@ import type ESTree from 'estree' import type { RuleContext, RuleListener } from '../types' import { defineRule } from '../utils' -type AnyValue = ESTree.Literal['value'] +type LiteralValue = ESTree.Literal['value'] +type StaticTemplateLiteral = ESTree.TemplateLiteral & { + quasis: [ESTree.TemplateElement] + expressions: [/* empty */] +} +type TargetAttrs = { name: RegExp; attrs: Set } type Config = { + attributes: TargetAttrs[] ignorePattern: RegExp ignoreNodes: string[] ignoreText: string[] } +const RE_REGEXP_STR = /^\/(.+)\/(.*)$/u +function toRegExp(str: string): RegExp { + const parts = RE_REGEXP_STR.exec(str) + if (parts) { + return new RegExp(parts[1], parts[2]) + } + return new RegExp(`^${escape(str)}$`) +} const hasOnlyWhitespace = (value: string) => /^[\r\n\s\t\f\v]+$/.test(value) -function isValidValue(value: AnyValue, config: Config) { - return ( - typeof value !== 'string' || - hasOnlyWhitespace(value) || - config.ignorePattern.test(value.trim()) || - config.ignoreText.includes(value.trim()) +/** + * Get the attribute to be verified from the element name. + */ +function getTargetAttrs(tagName: string, config: Config): Set { + const result = [] + for (const { name, attrs } of config.attributes) { + name.lastIndex = 0 + if (name.test(tagName)) { + result.push(...attrs) + } + } + + return new Set(result) +} + +function isStaticTemplateLiteral( + node: ESTree.Expression | ESTree.Pattern +): node is StaticTemplateLiteral { + return Boolean( + node && node.type === 'TemplateLiteral' && node.expressions.length === 0 ) } +function testValue(value: LiteralValue, config: Config): boolean { + if (typeof value === 'string') { + return ( + hasOnlyWhitespace(value) || + config.ignorePattern.test(value.trim()) || + config.ignoreText.includes(value.trim()) + ) + } else { + return false + } +} + function checkSvelteMustacheTagText( context: RuleContext, node: SvAST.SvelteMustacheTag, @@ -34,41 +74,90 @@ function checkSvelteMustacheTagText( if (node.parent.type === 'SvelteElement') { // parent is element (e.g.

{ ... }

) - if (node.expression.type === 'Literal') { - const literalNode = node.expression - if (isValidValue(literalNode.value, config)) { - return + checkExpressionText(context, node.expression, config) + } +} + +function checkExpressionText( + context: RuleContext, + expression: ESTree.Expression, + config: Config +) { + if (expression.type === 'Literal') { + checkLiteral(context, expression, config) + } else if (isStaticTemplateLiteral(expression)) { + checkLiteral(context, expression, config) + } else if (expression.type === 'ConditionalExpression') { + const targets = [expression.consequent, expression.alternate] + targets.forEach(target => { + if (target.type === 'Literal') { + checkLiteral(context, target, config) + } else if (isStaticTemplateLiteral(target)) { + checkLiteral(context, target, config) } + }) + } +} - context.report({ - node: literalNode, - message: `raw text '${literalNode.value}' is used` - }) - } else if (node.expression.type === 'ConditionalExpression') { - for (const target of [ - node.expression.consequent, - node.expression.alternate - ]) { - if (target.type !== 'Literal') { - continue - } - if (isValidValue(target.value, config)) { - continue - } +function checkSvelteLiteralOrText( + context: RuleContext, + literal: SvAST.SvelteLiteral | SvAST.SvelteText, + config: Config +) { + if (testValue(literal.value, config)) { + return + } - context.report({ - node: target, - message: `raw text '${target.value}' is used` - }) - } - } + const loc = literal.loc! + context.report({ + loc, + message: `raw text '${literal.value}' is used` + }) +} + +function checkLiteral( + context: RuleContext, + literal: ESTree.Literal | StaticTemplateLiteral, + config: Config +) { + const value = + literal.type !== 'TemplateLiteral' + ? literal.value + : literal.quasis[0].value.cooked + + if (testValue(value, config)) { + return + } + + const loc = literal.loc! + context.report({ + loc, + message: `raw text '${value}' is used` + }) +} +/** + * Parse attributes option + */ +function parseTargetAttrs( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + options: any +) { + const regexps: TargetAttrs[] = [] + for (const tagName of Object.keys(options)) { + const attrs: Set = new Set(options[tagName]) + regexps.push({ + name: toRegExp(tagName), + attrs + }) } + return regexps } function create(context: RuleContext): RuleListener { const sourceCode = context.getSourceCode() const config: Config = { - ignorePattern: /^[^\S\s]$/, + attributes: [], + ignorePattern: /^$/, ignoreNodes: [], ignoreText: [] } @@ -76,14 +165,15 @@ function create(context: RuleContext): RuleListener { if (context.options[0]?.ignorePattern) { config.ignorePattern = new RegExp(context.options[0].ignorePattern, 'u') } - if (context.options[0]?.ignoreNodes) { config.ignoreNodes = context.options[0].ignoreNodes } - if (context.options[0]?.ignoreText) { config.ignoreText = context.options[0].ignoreText } + if (context.options[0]?.attributes) { + config.attributes = parseTargetAttrs(context.options[0].attributes) + } function isIgnore(node: SvAST.SvelteMustacheTag | SvAST.SvelteText) { const element = getElement(node) @@ -98,7 +188,8 @@ function create(context: RuleContext): RuleListener { | SvAST.SvelteText['parent'] | SvAST.SvelteMustacheTag['parent'] | SvAST.SvelteElement - | SvAST.SvelteAwaitBlock = node.parent + | SvAST.SvelteAwaitBlock + | SvAST.SvelteElseBlockElseIf = node.parent while ( target.type === 'SvelteIfBlock' || target.type === 'SvelteElseBlock' || @@ -118,6 +209,19 @@ function create(context: RuleContext): RuleListener { } return { + SvelteAttribute(node: SvAST.SvelteAttribute) { + if (node.value.length !== 1 || node.value[0].type !== 'SvelteLiteral') { + return + } + const nameNode = node.parent.parent.name + const tagName = sourceCode.text.slice(...nameNode.range!) + const attrName = node.key.name + if (!getTargetAttrs(tagName, config).has(attrName)) { + return + } + + checkSvelteLiteralOrText(context, node.value[0], config) + }, SvelteMustacheTag(node: SvAST.SvelteMustacheTag) { if (isIgnore(node)) { return @@ -129,15 +233,7 @@ function create(context: RuleContext): RuleListener { if (isIgnore(node)) { return } - - if (isValidValue(node.value, config)) { - return - } - - context.report({ - node, - message: `raw text '${node.value}' is used` - }) + checkSvelteLiteralOrText(context, node, config) } } } @@ -154,6 +250,17 @@ export = defineRule('no-raw-text', { { type: 'object', properties: { + attributes: { + type: 'object', + patternProperties: { + '^(?:\\S+|/.*/[a-z]*)$': { + type: 'array', + items: { type: 'string' }, + uniqueItems: true + } + }, + additionalProperties: false + }, ignoreNodes: { type: 'array' }, diff --git a/package.json b/package.json index 6d72cbf..3a226fc 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ }, "dependencies": { "debug": "^4.3.1", - "svelte-eslint-parser": "^0.4.1" + "svelte-eslint-parser": "^0.8.0" }, "peerDependencies": { "eslint": "^7.0.0 || ^8.0.0-0", @@ -94,6 +94,7 @@ "generate": "ts-node --transpile-only scripts/update.ts && prettier . --write", "lint": "eslint . --ext js,ts,vue,md --ignore-pattern \"/tests/fixtures\"", "lint:docs": "prettier docs --check", + "format:docs": "prettier docs --write", "release:prepare": "shipjs prepare", "release:trigger": "shipjs trigger", "test": "mocha --require ts-node/register \"./tests/**/*.ts\"", diff --git a/tests/lib/rules/no-raw-text.ts b/tests/lib/rules/no-raw-text.ts index 53cff28..980275f 100644 --- a/tests/lib/rules/no-raw-text.ts +++ b/tests/lib/rules/no-raw-text.ts @@ -181,6 +181,49 @@ tester.run('no-raw-text', rule as never, { line: 3 } ] + }, + { + code: ` + + `, + options: [ + { + attributes: { + '/.*/': ['label'] + } + } + ], + errors: [ + { + message: "raw text 'hello' is used", + line: 2, + column: 23 + }, + { + message: "raw text 'hello' is used", + line: 3, + column: 28 + } + ] + }, + { + code: ` + + `, + options: [ + { + attributes: { + MyInput: ['label'] + } + } + ], + errors: [ + { + message: "raw text 'hello' is used", + line: 2, + column: 23 + } + ] } ] }) diff --git a/yarn.lock b/yarn.lock index babd24b..6cca1d0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1762,6 +1762,11 @@ acorn@^7.1.1, acorn@^7.4.0: resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa" integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A== +acorn@^8.6.0: + version "8.6.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.6.0.tgz#e3692ba0eb1a0c83eaa4f37f5fa7368dd7142895" + integrity sha512-U1riIR+lBSNi3IbxtaHOIKdH8sLFv3NYfNv8sg7ZsNhcfl4HF2++BfqqrNAxoCLQW1iiylOj76ecnaUxz+z9yw== + add-stream@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/add-stream/-/add-stream-1.0.0.tgz#6a7990437ca736d5e1288db92bd3266d5f5cb2aa" @@ -4266,6 +4271,14 @@ eslint-scope@^5.1.1: esrecurse "^4.3.0" estraverse "^4.1.1" +eslint-scope@^7.0.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-7.1.0.tgz#c1f6ea30ac583031f203d65c73e723b01298f153" + integrity sha512-aWwkhnS0qAXqNOgKOK0dJ2nvzEbhEvpy8OlJ9kZ0FeZnA6zpjv1/Vei+puGFFX7zkPCkHHXb7IDX3A+7yPrRWg== + dependencies: + esrecurse "^4.3.0" + estraverse "^5.2.0" + eslint-utils@^2.0.0, eslint-utils@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/eslint-utils/-/eslint-utils-2.1.0.tgz#d2de5e03424e707dc10c74068ddedae708741b27" @@ -4288,6 +4301,11 @@ eslint-visitor-keys@^3.0.0: resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.0.0.tgz#e32e99c6cdc2eb063f204eda5db67bfe58bb4186" integrity sha512-mJOZa35trBTb3IyRmo8xmKBZlxf+N7OnUl4+ZhJHs/r+0770Wh/LEACE2pqMGMe27G/4y8P2bYGk4J70IC5k1Q== +eslint-visitor-keys@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.1.0.tgz#eee4acea891814cda67a7d8812d9647dd0179af2" + integrity sha512-yWJFpu4DtjsWKkt5GeNBBuZMlNcYVs6vRCLoCVEJrTjaSB6LC98gFipNK/erM2Heg/E8mIK+hXG/pJMLK+eRZA== + eslint4b@^7.16.0: version "7.16.0" resolved "https://registry.yarnpkg.com/eslint4b/-/eslint4b-7.16.0.tgz#6bea3e3440814828deef6e5e9e42448603edf3b2" @@ -4375,6 +4393,15 @@ espree@^7.3.0, espree@^7.3.1: acorn-jsx "^5.3.1" eslint-visitor-keys "^1.3.0" +espree@^9.0.0: + version "9.1.0" + resolved "https://registry.yarnpkg.com/espree/-/espree-9.1.0.tgz#ba9d3c9b34eeae205724124e31de4543d59fbf74" + integrity sha512-ZgYLvCS1wxOczBYGcQT9DDWgicXwJ4dbocr9uYN+/eresBAUuBu+O4WzB21ufQ/JqQT8gyp7hJ3z8SHii32mTQ== + dependencies: + acorn "^8.6.0" + acorn-jsx "^5.3.1" + eslint-visitor-keys "^3.1.0" + esprima@^4.0.0: version "4.0.1" resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" @@ -10016,14 +10043,14 @@ supports-color@^7.1.0: dependencies: has-flag "^4.0.0" -svelte-eslint-parser@^0.4.1: - version "0.4.1" - resolved "https://registry.yarnpkg.com/svelte-eslint-parser/-/svelte-eslint-parser-0.4.1.tgz#ac645aea60a3dfa977b2693aaa654210f193ae9a" - integrity sha512-OoCyDI+0O7BjOCHxLiQMsS6e02T/ahh5u7o+CiQBM4RoChadYn/AfgR9SaIMHL2P+aVToxPnX94sC5e482w/Lg== +svelte-eslint-parser@^0.8.0: + version "0.8.0" + resolved "https://registry.yarnpkg.com/svelte-eslint-parser/-/svelte-eslint-parser-0.8.0.tgz#33946ad6ff01a0932640095d17fd318c8fbb7dda" + integrity sha512-ZWmwCoqpVZiQ3RG6Id1BSA1ZCpFNdEn1T4owXCNJUd9+oXSh00QUxIKBA4AvkTbqrOi6sIoOphD50ugpPpt6tw== dependencies: - eslint-scope "^5.1.1" + eslint-scope "^7.0.0" eslint-visitor-keys "^3.0.0" - espree "^7.3.1" + espree "^9.0.0" svelte@^3.37.0: version "3.37.0"