eslint-plugin-ember
Version:
ESLint plugin for Ember.js apps
377 lines (343 loc) • 9.96 kB
JavaScript
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 filename = context.filename;
const isStrictMode = filename.endsWith('.gjs') || filename.endsWith('.gts');
const rawConfig = context.options[0];
let config;
// In strict mode (gjs/gts), Input/Textarea could be custom components —
// prefer false negatives over false positives, matching ember-template-lint.
const defaultElementAttributes = isStrictMode
? DEFAULT_ELEMENT_ATTRIBUTES
: mergeObjects(DEFAULT_ELEMENT_ATTRIBUTES, BUILTIN_COMPONENT_ATTRIBUTES);
if (Array.isArray(rawConfig)) {
config = {
allowlist: sanitizeConfigArray([...rawConfig, ...DEFAULT_ALLOWLIST]),
globalAttributes: [...DEFAULT_GLOBAL_ATTRIBUTES],
elementAttributes: defaultElementAttributes,
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, defaultElementAttributes),
ignoredElements: [
...sanitizeConfigArray(rawConfig.ignoredElements || []),
...IGNORED_ELEMENTS,
],
};
} else {
config = {
allowlist: [...DEFAULT_ALLOWLIST],
globalAttributes: [...DEFAULT_GLOBAL_ATTRIBUTES],
elementAttributes: defaultElementAttributes,
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 <template> is a structural wrapper that should
// NOT be treated as an ignored HTML <template> element.
// In HBS mode, any <template> element is a real HTML element.
const isHbsParser = context.parserPath && context.parserPath.includes('hbs-parser');
if (!isHbsParser) {
templateRange = node.range;
}
},
GlimmerElementNode(node) {
currentElementNode = node;
// Skip the structural <template> wrapper in GJS/GTS mode.
// In GJS/GTS, the wrapper has tag='template' and same range as GlimmerTemplate.
// A real HTML <template> element will have a different range.
const isGjsWrapper =
templateRange &&
node.tag === 'template' &&
node.range[0] === templateRange[0] &&
node.range[1] === templateRange[1];
if (!isGjsWrapper) {
elementStack.push(node.tag);
}
},
'GlimmerElementNode:exit'(node) {
const isGjsWrapper =
templateRange &&
node.tag === 'template' &&
node.range[0] === templateRange[0] &&
node.range[1] === templateRange[1];
if (!isGjsWrapper) {
elementStack.pop();
}
},
GlimmerTextNode(node) {
if (!node.loc) {
return;
}
const attrParent = isInAttrNode(node);
if (attrParent) {
// Check if this attribute should be checked
const attrName = attrParent.name;
const tag = currentElementNode?.tag;
const isGlobal = config.globalAttributes.includes(attrName);
const isElement =
tag &&
config.elementAttributes[tag] &&
config.elementAttributes[tag].includes(attrName);
if (isGlobal || isElement) {
const desc = ` in \`${attrName}\` ${attrName.startsWith('@') ? 'argument' : 'attribute'}`;
checkAndLog(node, desc);
}
} else {
checkAndLog(node, '');
}
},
GlimmerMustacheStatement(node) {
const inAttr = isInAttrNode(node);
// Check the path itself (StringLiteral path)
if (!inAttr && node.path) {
checkAndLog(node.path, '');
}
if (isPageTitleHelper(node)) {
for (const param of node.params || []) {
checkAndLog(param, '');
}
} else if (isIfHelper(node) && !inAttr) {
const [, maybeTrue, maybeFalse] = node.params || [];
if (maybeTrue) {
checkAndLog(maybeTrue, '');
}
if (maybeFalse) {
checkAndLog(maybeFalse, '');
}
} else if (isUnlessHelper(node) && !inAttr) {
const [, maybeFalse, maybeTrue] = node.params || [];
if (maybeTrue) {
checkAndLog(maybeTrue, '');
}
if (maybeFalse) {
checkAndLog(maybeFalse, '');
}
} else if (isStringOnlyConcatHelper(node) && !inAttr) {
if (node.params?.[0]) {
checkAndLog(node.params[0], '');
}
}
},
};
},
};