mirror of
https://github.com/immich-app/immich
synced 2025-10-17 18:19:27 +00:00
Merge 8a7e679b74 into 4b7f851428
This commit is contained in:
commit
45a3d4a56e
3 changed files with 334 additions and 2 deletions
|
|
@ -10,6 +10,7 @@ import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import parser from 'svelte-eslint-parser';
|
import parser from 'svelte-eslint-parser';
|
||||||
import typescriptEslint from 'typescript-eslint';
|
import typescriptEslint from 'typescript-eslint';
|
||||||
|
import immichEslintRules from './immich.eslint.rules.js';
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
@ -79,6 +80,11 @@ export default typescriptEslint.config(
|
||||||
{
|
{
|
||||||
plugins: {
|
plugins: {
|
||||||
svelte: eslintPluginSvelte,
|
svelte: eslintPluginSvelte,
|
||||||
|
immich: {
|
||||||
|
rules: {
|
||||||
|
'no-unlocalized-strings': immichEslintRules,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
|
|
@ -99,7 +105,7 @@ export default typescriptEslint.config(
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
ignores: ['**/service-worker/**'],
|
ignores: ['**/service-worker/**', '**/*.svelte.ts'],
|
||||||
|
|
||||||
rules: {
|
rules: {
|
||||||
'@typescript-eslint/no-unused-vars': [
|
'@typescript-eslint/no-unused-vars': [
|
||||||
|
|
@ -129,6 +135,7 @@ export default typescriptEslint.config(
|
||||||
'@typescript-eslint/require-await': 'error',
|
'@typescript-eslint/require-await': 'error',
|
||||||
'object-shorthand': ['error', 'always'],
|
'object-shorthand': ['error', 'always'],
|
||||||
'svelte/no-navigation-without-resolve': 'off',
|
'svelte/no-navigation-without-resolve': 'off',
|
||||||
|
'immich/no-unlocalized-strings': 'error',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
325
web/immich.eslint.rules.js
Normal file
325
web/immich.eslint.rules.js
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -28,7 +28,7 @@
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:document use:shortcut={{ shortcut: { key: 't' }, onShortcut: handleTagAssets }} />
|
<svelte:document use:shortcut={{ shortcut: { key: 't' /* i18n-ignore */ }, onShortcut: handleTagAssets }} />
|
||||||
|
|
||||||
{#if menuItem}
|
{#if menuItem}
|
||||||
<MenuOption {text} {icon} onClick={handleTagAssets} />
|
<MenuOption {text} {icon} onClick={handleTagAssets} />
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue