From d284e26a21558608e1b831ebf4944b6c37076bb6 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Tue, 10 Mar 2026 18:25:34 -0400 Subject: [PATCH 1/2] Extract rule: template-no-bare-strings --- README.md | 1 + docs/rules/template-no-bare-strings.md | 143 ++++++ lib/rules/template-no-bare-strings.js | 370 ++++++++++++++++ tests/lib/rules/template-no-bare-strings.js | 467 ++++++++++++++++++++ 4 files changed, 981 insertions(+) create mode 100644 docs/rules/template-no-bare-strings.md create mode 100644 lib/rules/template-no-bare-strings.js create mode 100644 tests/lib/rules/template-no-bare-strings.js diff --git a/README.md b/README.md index 60d69ca2bc..000617bace 100644 --- a/README.md +++ b/README.md @@ -206,6 +206,7 @@ rules in templates can be disabled with eslint directives with mustache or html | [template-no-arguments-for-html-elements](docs/rules/template-no-arguments-for-html-elements.md) | disallow @arguments on HTML elements | | | | | [template-no-array-prototype-extensions](docs/rules/template-no-array-prototype-extensions.md) | disallow usage of Ember Array prototype extensions | | | | | [template-no-at-ember-render-modifiers](docs/rules/template-no-at-ember-render-modifiers.md) | disallow usage of @ember/render-modifiers | | | | +| [template-no-bare-strings](docs/rules/template-no-bare-strings.md) | disallow bare strings in templates (require translation/localization) | | | | | [template-no-bare-yield](docs/rules/template-no-bare-yield.md) | disallow templates whose only meaningful content is a bare {{yield}} | | | | | [template-no-block-params-for-html-elements](docs/rules/template-no-block-params-for-html-elements.md) | disallow block params on HTML elements | | | | | [template-no-capital-arguments](docs/rules/template-no-capital-arguments.md) | disallow capital arguments (use lowercase @arg instead of @Arg) | | | | diff --git a/docs/rules/template-no-bare-strings.md b/docs/rules/template-no-bare-strings.md new file mode 100644 index 0000000000..471e62dc95 --- /dev/null +++ b/docs/rules/template-no-bare-strings.md @@ -0,0 +1,143 @@ +# ember/template-no-bare-strings + + + +Disallows bare strings in templates to encourage internationalization. + +Bare strings in templates make internationalization (i18n) difficult. This rule encourages using translation helpers or properties to enable easy localization of your application. + +## Rule Details + +This rule disallows text content in templates that isn't wrapped in a translation helper or passed as a property. + +The following are allowed: + +- Whitespace-only strings +- Strings in the default allowlist (punctuation characters like `(`, `)`, `.`, `&`, etc.) +- Strings in a custom allowlist (configurable) + +## Examples + +Examples of **incorrect** code for this rule: + +```gjs + +``` + +```gjs + +``` + +```gjs + +``` + +Examples of **correct** code for this rule: + +```gjs + +``` + +```gjs + +``` + +```gjs + +``` + +## Configuration + +### `allowlist` + +An array of strings that are allowed to appear as bare strings: + +```js +module.exports = { + rules: { + 'ember/template-no-bare-strings': [ + 'error', + { + allowlist: ['Welcome', 'Home', 'About'], + }, + ], + }, +}; +``` + +### `globalAttributes` + +An array of attribute names where bare strings will be checked globally on all elements (defaults to `["title", "aria-label", "aria-placeholder", "aria-roledescription", "aria-valuetext"]`): + +```js +module.exports = { + rules: { + 'ember/template-no-bare-strings': [ + 'error', + { + globalAttributes: [ + 'title', + 'aria-label', + 'aria-placeholder', + 'aria-roledescription', + 'aria-valuetext', + ], + }, + ], + }, +}; +``` + +### `elementAttributes` + +An object mapping element names to arrays of attribute names to check for bare strings (defaults to `{ input: ["placeholder"], img: ["alt"] }`). The built-in Ember components `Input` and `Textarea` also check `placeholder` and `@placeholder`: + +```js +module.exports = { + rules: { + 'ember/template-no-bare-strings': [ + 'error', + { + elementAttributes: { + input: ['placeholder'], + img: ['alt'], + }, + }, + ], + }, +}; +``` + +### `ignoredElements` + +An array of element names whose text content is ignored (defaults to `["pre", "script", "style", "textarea"]`): + +```js +module.exports = { + rules: { + 'ember/template-no-bare-strings': [ + 'error', + { + ignoredElements: ['pre', 'script', 'style', 'textarea'], + }, + ], + }, +}; +``` + +## References + +- [eslint-plugin-ember template-no-bare-strings](https://github.com/ember-cli/eslint-plugin-ember/blob/master/docs/rules/template-no-bare-strings.md) +- [Ember Intl](https://github.com/ember-intl/ember-intl) diff --git a/lib/rules/template-no-bare-strings.js b/lib/rules/template-no-bare-strings.js new file mode 100644 index 0000000000..7ec2714c8b --- /dev/null +++ b/lib/rules/template-no-bare-strings.js @@ -0,0 +1,370 @@ +const DEFAULT_GLOBAL_ATTRIBUTES = [ + 'title', + 'aria-label', + 'aria-placeholder', + 'aria-roledescription', + 'aria-valuetext', +]; + +const DEFAULT_ELEMENT_ATTRIBUTES = { + input: ['placeholder'], + img: ['alt'], +}; + +const BUILTIN_COMPONENT_ATTRIBUTES = { + Input: ['placeholder', '@placeholder'], + Textarea: ['placeholder', '@placeholder'], +}; + +const DEFAULT_ALLOWLIST = [ + '(', + ')', + ',', + '.', + '&', + '&', + '+', + '−', + '=', + '×', + '*', + '*', + '/', + '#', + '%', + '!', + '?', + ':', + '[', + '[', + ']', + ']', + '{', + '{', + '}', + '}', + '<', + '<', + '>', + '>', + '•', + '•', + '—', + '–', + ' ', + ' ', + ' ', + '|', + '|', + '|', + '(', + ')', + ',', + '.', + '&', + '+', + '-', + '=', + '*', + '/', + '#', + '%', + '!', + '?', + ':', + '[', + ']', + '{', + '}', + '<', + '>', + '•', + '—', + ' ', + '|', +]; + +const IGNORED_ELEMENTS = ['pre', 'script', 'style', 'template', 'textarea']; + +function sanitizeConfigArray(arr = []) { + return arr.filter((o) => o !== '').sort((a, b) => b.length - a.length); +} + +function mergeObjects(obj1 = {}, obj2 = {}) { + const result = {}; + for (const [key, value] of Object.entries(obj1)) { + result[key] = [...(result[key] || []), ...value]; + } + for (const [key, value] of Object.entries(obj2)) { + result[key] = [...(result[key] || []), ...value]; + } + return result; +} + +function isPageTitleHelper(node) { + return node.path?.type === 'GlimmerPathExpression' && node.path.original === 'page-title'; +} + +function isIfHelper(node) { + return node.path?.type === 'GlimmerPathExpression' && node.path.original === 'if'; +} + +function isUnlessHelper(node) { + return node.path?.type === 'GlimmerPathExpression' && node.path.original === 'unless'; +} + +function isStringOnlyConcatHelper(node) { + return ( + node.path?.type === 'GlimmerPathExpression' && + node.path.original === 'concat' && + (node.params || []).every((p) => p.type === 'GlimmerStringLiteral') + ); +} + +function isInAttrNode(node) { + let p = node.parent; + while (p) { + if (p.type === 'GlimmerAttrNode') { + return p; + } + p = p.parent; + } + return null; +} + +/** @type {import('eslint').Rule.RuleModule} */ +module.exports = { + meta: { + type: 'suggestion', + docs: { + description: 'disallow bare strings in templates (require translation/localization)', + category: 'Best Practices', + url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-bare-strings.md', + templateMode: 'both', + }, + fixable: null, + schema: [ + { + oneOf: [ + { + type: 'object', + properties: { + allowlist: { type: 'array', items: { type: 'string' } }, + globalAttributes: { type: 'array', items: { type: 'string' } }, + elementAttributes: { type: 'object' }, + ignoredElements: { type: 'array', items: { type: 'string' } }, + }, + additionalProperties: false, + }, + { + type: 'array', + items: { type: 'string' }, + }, + ], + }, + ], + messages: { + bareString: 'Non-translated string used{{additionalDescription}}', + }, + originallyFrom: { + name: 'ember-template-lint', + rule: 'lib/rules/no-bare-strings.js', + docs: 'docs/rule/no-bare-strings.md', + tests: 'test/unit/rules/no-bare-strings-test.js', + }, + }, + + create(context) { + const rawConfig = context.options[0]; + let config; + + if (Array.isArray(rawConfig)) { + config = { + allowlist: sanitizeConfigArray([...rawConfig, ...DEFAULT_ALLOWLIST]), + globalAttributes: [...DEFAULT_GLOBAL_ATTRIBUTES], + elementAttributes: mergeObjects(DEFAULT_ELEMENT_ATTRIBUTES, BUILTIN_COMPONENT_ATTRIBUTES), + ignoredElements: [...IGNORED_ELEMENTS], + }; + } else if (rawConfig && typeof rawConfig === 'object') { + config = { + allowlist: sanitizeConfigArray([...(rawConfig.allowlist || []), ...DEFAULT_ALLOWLIST]), + globalAttributes: [...(rawConfig.globalAttributes || []), ...DEFAULT_GLOBAL_ATTRIBUTES], + elementAttributes: mergeObjects( + rawConfig.elementAttributes, + mergeObjects(DEFAULT_ELEMENT_ATTRIBUTES, BUILTIN_COMPONENT_ATTRIBUTES) + ), + ignoredElements: [ + ...sanitizeConfigArray(rawConfig.ignoredElements || []), + ...IGNORED_ELEMENTS, + ], + }; + } else { + config = { + allowlist: [...DEFAULT_ALLOWLIST], + globalAttributes: [...DEFAULT_GLOBAL_ATTRIBUTES], + elementAttributes: mergeObjects(DEFAULT_ELEMENT_ATTRIBUTES, BUILTIN_COMPONENT_ATTRIBUTES), + ignoredElements: [...IGNORED_ELEMENTS], + }; + } + + const elementStack = []; + + function isWithinIgnoredElement() { + return elementStack.some((tag) => config.ignoredElements.includes(tag)); + } + + function getBareString(str) { + let s = str; + for (const entry of config.allowlist) { + while (s.includes(entry)) { + s = s.replace(entry, ''); + } + } + return s.trim() === '' ? null : str; + } + + function checkAndLog(node, additionalDescription) { + if (isWithinIgnoredElement()) { + return; + } + + switch (node.type) { + case 'GlimmerTextNode': { + const bareString = getBareString(node.chars); + if (bareString) { + context.report({ + node, + messageId: 'bareString', + data: { additionalDescription }, + }); + } + break; + } + case 'GlimmerConcatStatement': { + for (const part of node.parts || []) { + checkAndLog(part, additionalDescription); + } + break; + } + case 'GlimmerStringLiteral': { + const bareString = getBareString(node.value || ''); + if (bareString) { + context.report({ + node, + messageId: 'bareString', + data: { additionalDescription }, + }); + } + break; + } + default: { + break; + } + } + } + + let currentElementNode = null; + let templateRange = null; + + return { + GlimmerTemplate(node) { + // Only track the template range in GJS/GTS mode (not HBS mode). + // In GJS/GTS, the outermost