Eslint custom rules

This commit is contained in:
midzelis 2025-10-09 14:19:26 +00:00
parent 53680d9643
commit 8a7e679b74
3 changed files with 334 additions and 2 deletions

View file

@ -10,6 +10,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import parser from 'svelte-eslint-parser';
import typescriptEslint from 'typescript-eslint';
import immichEslintRules from './immich.eslint.rules.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@ -79,6 +80,11 @@ export default typescriptEslint.config(
{
plugins: {
svelte: eslintPluginSvelte,
immich: {
rules: {
'no-unlocalized-strings': immichEslintRules,
},
},
},
languageOptions: {
@ -99,7 +105,7 @@ export default typescriptEslint.config(
},
},
ignores: ['**/service-worker/**'],
ignores: ['**/service-worker/**', '**/*.svelte.ts'],
rules: {
'@typescript-eslint/no-unused-vars': [
@ -129,6 +135,7 @@ export default typescriptEslint.config(
'@typescript-eslint/require-await': 'error',
'object-shorthand': ['error', 'always'],
'svelte/no-navigation-without-resolve': 'off',
'immich/no-unlocalized-strings': 'error',
},
},
{

325
web/immich.eslint.rules.js Normal file
View file

@ -0,0 +1,325 @@
/**
* ESLint rule: no-unlocalized-strings
*
* Ensures all string literals containing alphabetic characters are wrapped in t() function calls.
* String literals without alpha characters (like "123", "---", etc.) are ignored.
*/
export default {
meta: {
type: 'problem',
docs: {
description: 'Require string literals with alphabetic characters to be wrapped in t() function',
category: 'Internationalization',
recommended: true,
},
messages: {
unlocalizedString: "String literal '{{text}}' should be wrapped in t() function for internationalization",
},
schema: [], // no options
},
create(context) {
/**
* Check if current file should be ignored
*/
function shouldIgnoreFile() {
const filename = context.getFilename();
// Ignore test/spec files
if (/\.(spec|test)\.(ts|tsx|js|jsx)$/.test(filename)) {
return true;
}
// Ignore files in __tests__ directories
if (filename.includes('__tests__')) {
return true;
}
// Ignore files in test-data directories
if (filename.includes('test-data')) {
return true;
}
// Ignore config files
if (/\.config\.(ts|js)$/.test(filename)) {
return true;
}
return false;
}
// Skip entire file if it should be ignored
if (shouldIgnoreFile()) {
return {};
}
/**
* Check if a node has an ignore comment
* Supports: i18n-ignore, or standard eslint-disable
*/
function hasIgnoreComment(node) {
const sourceCode = context.getSourceCode();
const comments = sourceCode.getCommentsBefore(node);
// Check for i18n-ignore in comments on the same line or line before
for (const comment of comments) {
if (comment.value.trim().includes('i18n-ignore')) {
return true;
}
}
// Also check for inline comments on the same line
const lineComments = sourceCode.getCommentsAfter(node);
for (const comment of lineComments) {
if (comment.loc.start.line === node.loc.start.line && comment.value.trim().includes('i18n-ignore')) {
return true;
}
}
return false;
}
/**
* Check if a string contains any alphabetic characters
*/
function containsAlphabeticChars(str) {
return /[a-zA-Z]/.test(str);
}
/**
* Check if a node is a direct argument to t() function call
*/
function isInTranslationFunction(node) {
const parent = node.parent;
// Check if parent is a CallExpression with callee named 't' or '$t'
if (parent && parent.type === 'CallExpression') {
const callee = parent.callee;
// Direct function call: t('string') or $t('string')
if (callee.type === 'Identifier' && (callee.name === 't' || callee.name === '$t')) {
return true;
}
// Member expression: this.t('string') or obj.t('string')
if (
callee.type === 'MemberExpression' &&
callee.property.type === 'Identifier' &&
(callee.property.name === 't' || callee.property.name === '$t')
) {
return true;
}
}
return false;
}
/**
* Check if node is in a context where translation is not needed
*/
function isInIgnoredContext(node) {
let current = node.parent;
while (current) {
// Ignore object keys: { key: 'value' } - the 'key' part
if (current.type === 'Property' && current.key === node) {
return true;
}
// Ignore object property values for certain keys
if (current.type === 'Property' && current.value === node) {
const keyName = current.key?.name || current.key?.value;
// List of object keys whose values don't need translation
const nonTranslatableKeys = [
'key',
'keys',
'shortcut',
'hotkey',
'keyCode',
'code',
'id',
'type',
'name',
'ref',
'testId',
'dataTestId',
'className',
'class',
'style',
'href',
'src',
'alt',
'role',
'method',
'action',
'target',
'rel',
'as',
];
if (nonTranslatableKeys.includes(keyName)) {
return true;
}
// Ignore data-* properties
if (typeof keyName === 'string' && keyName.startsWith('data-')) {
return true;
}
}
// Ignore import/export statements
if (
current.type === 'ImportDeclaration' ||
current.type === 'ExportNamedDeclaration' ||
current.type === 'ExportAllDeclaration'
) {
return true;
}
// Ignore dynamic imports: import('module-name')
if (current.type === 'ImportExpression') {
return true;
}
// Ignore type annotations (TypeScript)
if (current.type === 'TSLiteralType' || current.type === 'TSTypeReference') {
return true;
}
// Ignore JSX/Svelte attribute values that are plain strings (not expressions)
// For example: <div class="container"> or <Button variant="primary">
if (current.type === 'JSXAttribute') {
const attrName = current.name?.name;
// List of attributes that typically don't need translation
const nonTranslatableAttrs = [
'class',
'className',
'id',
'style',
'type',
'name',
'value',
'href',
'src',
'alt',
'role',
'data-testid',
'key',
'ref',
'variant',
'color',
'size',
'shape',
'icon',
'position',
'align',
'justify',
'direction',
'wrap',
'gap',
'spacing',
];
// Also ignore any data-* or aria-* attributes except aria-label, aria-description
const isDataAttr = attrName?.startsWith('data-');
const isNonTextAriaAttr =
attrName?.startsWith('aria-') &&
attrName !== 'aria-label' &&
attrName !== 'aria-description' &&
attrName !== 'aria-placeholder';
if (nonTranslatableAttrs.includes(attrName) || isDataAttr || isNonTextAriaAttr) {
return true;
}
}
current = current.parent;
}
return false;
}
return {
Literal(node) {
// Only check string literals
if (typeof node.value !== 'string') {
return;
}
const stringValue = node.value;
// Skip strings without alphabetic characters
if (!containsAlphabeticChars(stringValue)) {
return;
}
// Skip if has ignore comment
if (hasIgnoreComment(node)) {
return;
}
// Skip if already wrapped in t() function
if (isInTranslationFunction(node)) {
return;
}
// Skip if in ignored context (object keys, imports, etc.)
if (isInIgnoredContext(node)) {
return;
}
// Report the violation
context.report({
node,
messageId: 'unlocalizedString',
data: {
text: stringValue.length > 30 ? stringValue.substring(0, 30) + '...' : stringValue,
},
});
},
// Also handle template literals
TemplateLiteral(node) {
// Skip if it has expressions (e.g., `Hello ${name}`)
if (node.expressions.length > 0) {
return;
}
// Get the string value from quasis
const stringValue = node.quasis[0]?.value.cooked || '';
// Skip strings without alphabetic characters
if (!containsAlphabeticChars(stringValue)) {
return;
}
// Skip if has ignore comment
if (hasIgnoreComment(node)) {
return;
}
// Skip if already wrapped in t() function
if (isInTranslationFunction(node)) {
return;
}
// Skip if in ignored context
if (isInIgnoredContext(node)) {
return;
}
// Report the violation
context.report({
node,
messageId: 'unlocalizedString',
data: {
text: stringValue.length > 30 ? stringValue.substring(0, 30) + '...' : stringValue,
},
});
},
};
},
};

View file

@ -28,7 +28,7 @@
};
</script>
<svelte:document use:shortcut={{ shortcut: { key: 't' }, onShortcut: handleTagAssets }} />
<svelte:document use:shortcut={{ shortcut: { key: 't' /* i18n-ignore */ }, onShortcut: handleTagAssets }} />
{#if menuItem}
<MenuOption {text} {icon} onClick={handleTagAssets} />