diff --git a/web/eslint.config.js b/web/eslint.config.js index 792ff90e0c..8608d09d67 100644 --- a/web/eslint.config.js +++ b/web/eslint.config.js @@ -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', }, }, { diff --git a/web/immich.eslint.rules.js b/web/immich.eslint.rules.js new file mode 100644 index 0000000000..7bd3c2d6fb --- /dev/null +++ b/web/immich.eslint.rules.js @@ -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: