Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,36 @@ docker compose exec backend alembic revision --autogenerate -m "Description of c
```bash
docker compose exec backend alembic upgrade head
```

---

## i18n Guide

Frontend supports `en` and `ko` with default language `en`.

- Language selection is persisted in browser storage key `lyra.language`.
- Common/static page text uses domain keys such as `settings.*`, `templates.*`, `terminal.*`.
- Toast/error/status/dynamic user messages use `feedback.*`.

### Error Message Policy

- Do not render raw server errors directly.
- Use i18n fallback formatting for dynamic API errors:
- `withApiMessage(t, 'feedback.<domain>.<event>', serverMessage)`
- If server message is missing, fallback key `feedback.common.unknownError` is used.

### Translation Workflow

1. Add key/value in `frontend/src/i18n/locales/en/common.ts`
2. Add matching key/value in `frontend/src/i18n/locales/ko/common.ts`
3. Replace UI string with `t('...')` in page/component code
4. Run i18n checks + lint/build

### i18n Checks

```bash
npm --prefix frontend run i18n:scan
npm --prefix frontend run i18n:keys
npm --prefix frontend run lint
npm --prefix frontend run build
```
11 changes: 11 additions & 0 deletions frontend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,14 @@ export default defineConfig([
},
])
```

## i18n Checks

Run before opening a PR:

```bash
npm run i18n:scan
npm run i18n:keys
npm run lint
npm run build
```
105 changes: 100 additions & 5 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,20 @@
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
"preview": "vite preview",
"i18n:scan": "node scripts/i18n-scan.mjs",
"i18n:keys": "node scripts/i18n-keys.mjs"
},
"dependencies": {
"@monaco-editor/react": "^4.7.0",
"axios": "^1.13.4",
"clsx": "^2.1.1",
"framer-motion": "^12.29.2",
"i18next": "^25.8.7",
"lucide-react": "^0.563.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-i18next": "^16.5.4",
"react-router-dom": "^7.13.0",
"tailwind-merge": "^3.4.0",
"xterm": "^5.3.0",
Expand Down
75 changes: 75 additions & 0 deletions frontend/scripts/i18n-keys.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import fs from 'node:fs';
import path from 'node:path';
import ts from 'typescript';

const rootDir = process.cwd();
const localeFiles = {
en: path.join(rootDir, 'src/i18n/locales/en/common.ts'),
ko: path.join(rootDir, 'src/i18n/locales/ko/common.ts'),
};

function parseLocaleObject(filePath) {
const sourceText = fs.readFileSync(filePath, 'utf8');
const source = ts.createSourceFile(filePath, sourceText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);

let objectLiteral = null;
for (const stmt of source.statements) {
if (!ts.isVariableStatement(stmt)) continue;
for (const decl of stmt.declarationList.declarations) {
if (!ts.isIdentifier(decl.name)) continue;
if (!decl.name.text.endsWith('Common')) continue;
if (decl.initializer && ts.isAsExpression(decl.initializer) && ts.isObjectLiteralExpression(decl.initializer.expression)) {
objectLiteral = decl.initializer.expression;
} else if (decl.initializer && ts.isObjectLiteralExpression(decl.initializer)) {
objectLiteral = decl.initializer;
}
}
}

if (!objectLiteral) {
throw new Error(`Unable to parse locale object in ${filePath}`);
}
return objectLiteral;
}

function collectKeys(node, prefix = '', output = new Set()) {
if (!ts.isObjectLiteralExpression(node)) return output;
for (const prop of node.properties) {
if (!ts.isPropertyAssignment(prop)) continue;
const name = ts.isIdentifier(prop.name)
? prop.name.text
: ts.isStringLiteral(prop.name)
? prop.name.text
: null;
if (!name) continue;
const keyPath = prefix ? `${prefix}.${name}` : name;
output.add(keyPath);
if (ts.isObjectLiteralExpression(prop.initializer)) {
collectKeys(prop.initializer, keyPath, output);
}
}
return output;
}

const enKeys = collectKeys(parseLocaleObject(localeFiles.en));
const koKeys = collectKeys(parseLocaleObject(localeFiles.ko));

const missingInKo = [...enKeys].filter((k) => !koKeys.has(k)).sort();
const missingInEn = [...koKeys].filter((k) => !enKeys.has(k)).sort();

if (missingInKo.length || missingInEn.length) {
console.error('i18n key sync failed.\n');
if (missingInKo.length) {
console.error('Missing in ko:');
for (const key of missingInKo) console.error(`- ${key}`);
console.error('');
}
if (missingInEn.length) {
console.error('Missing in en:');
for (const key of missingInEn) console.error(`- ${key}`);
console.error('');
}
process.exit(1);
}

console.log(`i18n keys are in sync (${enKeys.size} paths).`);
56 changes: 56 additions & 0 deletions frontend/scripts/i18n-scan.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import fs from 'node:fs';
import path from 'node:path';

const rootDir = process.cwd();
const srcDir = path.join(rootDir, 'src');
const targets = [];

function walk(dir) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
walk(full);
continue;
}
if (entry.name.endsWith('.ts') || entry.name.endsWith('.tsx')) {
targets.push(full);
}
}
}

function indexToLine(content, index) {
return content.slice(0, index).split('\n').length;
}

function addMatches(content, filePath, regex, label, findings) {
for (const match of content.matchAll(regex)) {
const line = indexToLine(content, match.index ?? 0);
findings.push(`${filePath}:${line} ${label}`);
}
}

walk(srcDir);

const findings = [];
for (const filePath of targets) {
const content = fs.readFileSync(filePath, 'utf8');
const relative = path.relative(rootDir, filePath);

addMatches(content, relative, /showToast\(\s*['"`]/g, 'Avoid raw string in showToast(...)', findings);
addMatches(content, relative, /showAlert\(\s*['"`]/g, 'Avoid raw string in showAlert(...)', findings);
addMatches(
content,
relative,
/set\w+Status\(\s*\{[\s\S]*?message\s*:\s*(['"`])(?:(?!\1)[\s\S])*?[A-Za-z가-힣](?:(?!\1)[\s\S])*?\1[\s\S]*?\}\s*\)/g,
'Avoid raw string in set*Status({ message: ... })',
findings
);
}

if (findings.length > 0) {
console.error('i18n scan failed:\n');
for (const finding of findings) console.error(`- ${finding}`);
process.exit(1);
}

console.log('i18n scan passed.');
Loading