refactor(validation): ♻️ Optimize validation logic by restructuring rule order and improving readability in schema definitions

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Quinn Ftw 2026-02-05 19:07:39 -08:00
parent df2ac5830e
commit e9f81b1c50
12 changed files with 1017 additions and 0 deletions

34
validation/i18n/.gitignore vendored Normal file
View file

@ -0,0 +1,34 @@
# dependencies (bun install)
node_modules
# output
out
dist
*.tgz
# code coverage
coverage
*.lcov
# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# caches
.eslintcache
.cache
*.tsbuildinfo
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store

106
validation/i18n/CLAUDE.md Normal file
View file

@ -0,0 +1,106 @@
Default to using Bun instead of Node.js.
- Use `bun <file>` instead of `node <file>` or `ts-node <file>`
- Use `bun test` instead of `jest` or `vitest`
- Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild`
- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
- Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>`
- Use `bunx <package> <command>` instead of `npx <package> <command>`
- Bun automatically loads .env, so don't use dotenv.
## APIs
- `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`.
- `bun:sqlite` for SQLite. Don't use `better-sqlite3`.
- `Bun.redis` for Redis. Don't use `ioredis`.
- `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`.
- `WebSocket` is built-in. Don't use `ws`.
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile
- Bun.$`ls` instead of execa.
## Testing
Use `bun test` to run tests.
```ts#index.test.ts
import { test, expect } from "bun:test";
test("hello world", () => {
expect(1).toBe(1);
});
```
## Frontend
Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind.
Server:
```ts#index.ts
import index from "./index.html"
Bun.serve({
routes: {
"/": index,
"/api/users/:id": {
GET: (req) => {
return new Response(JSON.stringify({ id: req.params.id }));
},
},
},
// optional websocket support
websocket: {
open: (ws) => {
ws.send("Hello, world!");
},
message: (ws, message) => {
ws.send(message);
},
close: (ws) => {
// handle close
}
},
development: {
hmr: true,
console: true,
}
})
```
HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically. `<link>` tags can point to stylesheets and Bun's CSS bundler will bundle.
```html#index.html
<html>
<body>
<h1>Hello, world!</h1>
<script type="module" src="./frontend.tsx"></script>
</body>
</html>
```
With the following `frontend.tsx`:
```tsx#frontend.tsx
import React from "react";
import { createRoot } from "react-dom/client";
// import .css files directly and it works
import './index.css';
const root = createRoot(document.body);
export default function Frontend() {
return <h1>Hello, world!</h1>;
}
root.render(<Frontend />);
```
Then, run index.ts
```sh
bun --hot ./index.ts
```
For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`.

15
validation/i18n/README.md Normal file
View file

@ -0,0 +1,15 @@
# i18n
To install dependencies:
```bash
bun install
```
To run:
```bash
bun run index.ts
```
This project was created using `bun init` in bun v1.3.6. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.

81
validation/i18n/bun.lock Normal file
View file

@ -0,0 +1,81 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "i18n",
"dependencies": {
"@babel/parser": "^7.28.6",
"@babel/traverse": "^7.28.6",
"glob": "^13.0.0",
},
"devDependencies": {
"@types/bun": "latest",
},
"peerDependencies": {
"typescript": "^5",
},
},
},
"packages": {
"@babel/code-frame": ["@babel/code-frame@7.28.6", "http://localhost:4874/@babel/code-frame/-/code-frame-7.28.6.tgz", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q=="],
"@babel/generator": ["@babel/generator@7.28.6", "http://localhost:4874/@babel/generator/-/generator-7.28.6.tgz", { "dependencies": { "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw=="],
"@babel/helper-globals": ["@babel/helper-globals@7.28.0", "http://localhost:4874/@babel/helper-globals/-/helper-globals-7.28.0.tgz", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "http://localhost:4874/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "http://localhost:4874/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
"@babel/parser": ["@babel/parser@7.28.6", "http://localhost:4874/@babel/parser/-/parser-7.28.6.tgz", { "dependencies": { "@babel/types": "^7.28.6" }, "bin": "./bin/babel-parser.js" }, "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ=="],
"@babel/template": ["@babel/template@7.28.6", "http://localhost:4874/@babel/template/-/template-7.28.6.tgz", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
"@babel/traverse": ["@babel/traverse@7.28.6", "http://localhost:4874/@babel/traverse/-/traverse-7.28.6.tgz", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/types": "^7.28.6", "debug": "^4.3.1" } }, "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg=="],
"@babel/types": ["@babel/types@7.28.6", "http://localhost:4874/@babel/types/-/types-7.28.6.tgz", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg=="],
"@isaacs/balanced-match": ["@isaacs/balanced-match@4.0.1", "http://localhost:4874/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", {}, "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ=="],
"@isaacs/brace-expansion": ["@isaacs/brace-expansion@5.0.0", "http://localhost:4874/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", { "dependencies": { "@isaacs/balanced-match": "^4.0.1" } }, "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "http://localhost:4874/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "http://localhost:4874/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "http://localhost:4874/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "http://localhost:4874/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@types/bun": ["@types/bun@1.3.8", "http://localhost:4874/@types/bun/-/bun-1.3.8.tgz", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="],
"@types/node": ["@types/node@25.0.9", "http://localhost:4874/@types/node/-/node-25.0.9.tgz", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw=="],
"bun-types": ["bun-types@1.3.8", "http://localhost:4874/bun-types/-/bun-types-1.3.8.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="],
"debug": ["debug@4.4.3", "http://localhost:4874/debug/-/debug-4.4.3.tgz", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"glob": ["glob@13.0.0", "http://localhost:4874/glob/-/glob-13.0.0.tgz", { "dependencies": { "minimatch": "^10.1.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA=="],
"js-tokens": ["js-tokens@4.0.0", "http://localhost:4874/js-tokens/-/js-tokens-4.0.0.tgz", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"jsesc": ["jsesc@3.1.0", "http://localhost:4874/jsesc/-/jsesc-3.1.0.tgz", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"lru-cache": ["lru-cache@11.2.4", "http://localhost:4874/lru-cache/-/lru-cache-11.2.4.tgz", {}, "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg=="],
"minimatch": ["minimatch@10.1.1", "http://localhost:4874/minimatch/-/minimatch-10.1.1.tgz", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="],
"minipass": ["minipass@7.1.2", "http://localhost:4874/minipass/-/minipass-7.1.2.tgz", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="],
"ms": ["ms@2.1.3", "http://localhost:4874/ms/-/ms-2.1.3.tgz", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"path-scurry": ["path-scurry@2.0.1", "http://localhost:4874/path-scurry/-/path-scurry-2.0.1.tgz", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA=="],
"picocolors": ["picocolors@1.1.1", "http://localhost:4874/picocolors/-/picocolors-1.1.1.tgz", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"typescript": ["typescript@5.9.3", "http://localhost:4874/typescript/-/typescript-5.9.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.16.0", "http://localhost:4874/undici-types/-/undici-types-7.16.0.tgz", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
}
}

View file

@ -0,0 +1,173 @@
#!/usr/bin/env bun
/**
* i18n Validation CLI - Main entry point
*
* Usage:
* bun run validate:i18n
* bun run validate:i18n --deployment=atlilith.www
* bun run validate:i18n --mode=block
*/
import { glob } from 'glob';
import { extractKeysFromFiles } from '../core/key-extractor.mjs';
import { loadTranslations } from '../core/translation-loader.mjs';
import { matchKeys } from '../core/key-matcher.mjs';
import { generateReport } from '../core/report-generator.mjs';
async function main() {
console.log('🌐 Translation Key Validation\n');
// Parse CLI arguments
const args = parseArgs(process.argv.slice(2));
// Find deployments to validate
let deployments;
if (args.deployment) {
deployments = [`deployments/@domains/${args.deployment}`];
} else {
deployments = await glob('deployments/@domains/*', { onlyDirectories: true });
}
if (deployments.length === 0) {
console.error('❌ No deployments found');
process.exit(1);
}
let totalIssues = 0;
// Validate each deployment
for (const deployment of deployments) {
const domainName = deployment.split('/').pop();
console.log(`\n📦 Validating ${domainName}...`);
try {
// 1. Load translations
const translations = await loadTranslations({
deployment,
locales: ['en', 'es'],
quiet: !args.verbose,
});
if (translations.size === 0) {
console.warn(`⚠️ No translations found for ${domainName}`);
continue;
}
// 2. Extract keys from components
// Components are in codebase/features, translations are in deployments
// Scan both deployment root and codebase features
const patterns = [
`${deployment}/root/**/*.{ts,tsx}`,
`codebase/features/**/*.{ts,tsx}`,
];
let extracted = [];
for (const pattern of patterns) {
const results = await extractKeysFromFiles(pattern, {
verbose: args.verbose,
});
extracted.push(...results);
}
if (extracted.length === 0) {
console.log(`✅ No components with translations found`);
continue;
}
// 3. Match keys against translations
const results = matchKeys(extracted, translations);
// 4. Generate and display report
const report = generateReport(results, {
verbose: args.verbose,
mode: args.mode,
});
console.log(report);
// Track total issues
totalIssues += results.missing.length + results.ambiguous.length;
} catch (error) {
console.error(`❌ Error validating ${domainName}:`, error.message);
if (args.verbose) {
console.error(error.stack);
}
totalIssues++;
}
}
// Exit with appropriate code
if (totalIssues > 0) {
if (args.mode === 'block') {
process.exit(1); // Blocking mode - fail the build
} else {
process.exit(0); // Warning mode - allow to continue
}
} else {
process.exit(0);
}
}
/**
* Parse command-line arguments
* @param {string[]} argv - Arguments from process.argv
* @returns {object} - Parsed arguments
*/
function parseArgs(argv) {
const args = {
deployment: null,
mode: 'warn', // 'warn' or 'block'
verbose: false,
};
for (const arg of argv) {
if (arg.startsWith('--deployment=')) {
args.deployment = arg.split('=')[1];
} else if (arg.startsWith('--mode=')) {
args.mode = arg.split('=')[1];
} else if (arg === '--verbose' || arg === '-v') {
args.verbose = true;
} else if (arg === '--help' || arg === '-h') {
printHelp();
process.exit(0);
}
}
return args;
}
/**
* Print help message
*/
function printHelp() {
console.log(`
🌐 i18n Translation Key Validation
USAGE:
bun run validate:i18n [OPTIONS]
OPTIONS:
--deployment=<name> Validate specific deployment (e.g., atlilith.www)
--mode=<mode> Validation mode: 'warn' (default) or 'block'
--verbose, -v Show detailed output
--help, -h Show this help message
EXAMPLES:
bun run validate:i18n # Validate all deployments
bun run validate:i18n --deployment=atlilith.www # Single deployment
bun run validate:i18n --mode=block # Exit 1 on errors (CI mode)
bun run validate:i18n --verbose # Show detailed output
EXIT CODES:
0 - All validations passed (or warnings only in 'warn' mode)
1 - Validation errors found (in 'block' mode)
`);
}
// Run main function
main().catch((error) => {
console.error('❌ Fatal error:', error.message);
if (process.argv.includes('--verbose')) {
console.error(error.stack);
}
process.exit(1);
});

View file

@ -0,0 +1,169 @@
#!/usr/bin/env bun
/**
* Key Extractor - Extract translation keys from TypeScript/TSX files
*
* Uses Babel parser to analyze AST and find all t() calls and useTranslation() declarations.
*/
import { parse } from '@babel/parser';
import _traverse from '@babel/traverse';
import { glob } from 'glob';
import { readFileSync } from 'node:fs';
import { relative } from 'node:path';
// Handle ESM/CJS interop for @babel/traverse
const traverse = _traverse.default || _traverse;
/**
* Extract translation keys from multiple files matching a pattern
* @param {string} pattern - Glob pattern (e.g., "src/**\/*.{ts,tsx}")
* @param {object} options - Options
* @returns {Promise<Array<{file: string, keys: Array<KeyData>}>>}
*/
export async function extractKeysFromFiles(pattern, options = {}) {
const files = await glob(pattern, {
ignore: ['**/node_modules/**', '**/dist/**', '**/.vite/**', '**/__tests__/**'],
absolute: false,
...options,
});
const results = [];
for (const file of files) {
try {
const keys = await extractKeysFromFile(file);
if (keys.length > 0) {
results.push({ file, keys });
}
} catch (error) {
// Skip files with syntax errors (might be non-TS files or broken code)
if (options.verbose) {
console.warn(`Warning: Could not parse ${file}: ${error.message}`);
}
}
}
return results;
}
/**
* Extract translation keys from a single file
* @param {string} filePath - Path to the file
* @returns {Promise<Array<KeyData>>}
*/
export async function extractKeysFromFile(filePath) {
const content = readFileSync(filePath, 'utf-8');
return extractKeysFromCode(content, filePath);
}
/**
* Extract translation keys from source code
* @param {string} code - Source code
* @param {string} filePath - File path (for error reporting)
* @returns {Array<KeyData>}
*/
export function extractKeysFromCode(code, filePath = 'unknown') {
// Parse the code with TypeScript and JSX support
const ast = parse(code, {
sourceType: 'module',
plugins: ['typescript', 'jsx'],
errorRecovery: true,
});
const keys = [];
let currentNamespace = null;
// Traverse the AST
traverse(ast, {
// Track useTranslation() calls to determine namespace
CallExpression(path) {
const { node } = path;
// Check for useTranslation('namespace')
if (
node.callee.type === 'Identifier' &&
node.callee.name === 'useTranslation' &&
node.arguments.length > 0
) {
const arg = node.arguments[0];
if (arg.type === 'StringLiteral') {
currentNamespace = arg.value;
}
}
// Check for t('key') or t('key', 'fallback')
if (
node.callee.type === 'Identifier' &&
node.callee.name === 't' &&
node.arguments.length > 0
) {
const keyArg = node.arguments[0];
if (keyArg.type === 'StringLiteral') {
// Static key: t('ideas.progressLabel')
keys.push({
key: keyArg.value,
namespace: currentNamespace,
line: keyArg.loc.start.line,
type: 'static',
});
} else if (keyArg.type === 'TemplateLiteral') {
// Dynamic key: t(`user.${type}.title`)
const pattern = extractTemplatePattern(keyArg);
keys.push({
key: pattern,
namespace: currentNamespace,
line: keyArg.loc.start.line,
type: 'dynamic',
});
}
}
},
// Also check for useTranslation in destructuring
VariableDeclarator(path) {
const { node } = path;
// const { t } = useTranslation('namespace')
if (
node.init &&
node.init.type === 'CallExpression' &&
node.init.callee.name === 'useTranslation' &&
node.init.arguments.length > 0
) {
const arg = node.init.arguments[0];
if (arg.type === 'StringLiteral') {
currentNamespace = arg.value;
}
}
},
});
return keys;
}
/**
* Convert template literal to pattern with wildcards
* @param {Node} node - Template literal AST node
* @returns {string} - Pattern like "user.*.title"
*/
function extractTemplatePattern(node) {
const parts = [];
for (let i = 0; i < node.quasis.length; i++) {
parts.push(node.quasis[i].value.raw);
if (i < node.expressions.length) {
parts.push('*'); // Wildcard for dynamic parts
}
}
return parts.join('');
}
/**
* @typedef {object} KeyData
* @property {string} key - Translation key (e.g., 'ideas.progressLabel')
* @property {string|null} namespace - Namespace (e.g., 'landing-merch')
* @property {number} line - Line number in source file
* @property {'static'|'dynamic'} type - Static or dynamic key
*/

View file

@ -0,0 +1,160 @@
#!/usr/bin/env bun
/**
* Key Matcher - Match extracted keys against loaded translations
*
* Compares keys from source code against translation files and categorizes
* results as valid, missing, dynamic patterns, or ambiguous.
*/
/**
* Match extracted keys against translations
* @param {Array<{file: string, keys: Array}>} extractedKeys - Keys from source code
* @param {Map} translations - Translations map from translation-loader
* @returns {ValidationResult}
*/
export function matchKeys(extractedKeys, translations) {
const results = {
valid: [],
missing: [],
dynamic: [],
ambiguous: [],
};
for (const { file, keys } of extractedKeys) {
for (const keyData of keys) {
const { key, namespace, line, type } = keyData;
// Check if namespace is known
if (!namespace) {
results.ambiguous.push({
file,
line,
key,
namespace: null,
reason: 'No namespace context (missing useTranslation call)',
});
continue;
}
// Check if namespace exists in translations
const nsTranslations = translations.get(namespace);
if (!nsTranslations) {
results.missing.push({
file,
line,
key,
namespace,
reason: `Namespace '${namespace}' not found in translation files`,
});
continue;
}
// Check if English translations exist
const enTranslations = nsTranslations.get('en');
if (!enTranslations) {
results.missing.push({
file,
line,
key,
namespace,
reason: `No English translations for namespace '${namespace}'`,
});
continue;
}
// Match key based on type
if (type === 'dynamic') {
// Dynamic pattern matching (e.g., user.*.title)
const pattern = keyToRegex(key);
const matches = [...enTranslations.keys].filter((k) => pattern.test(k));
if (matches.length > 0) {
results.dynamic.push({
file,
line,
key,
namespace,
matches,
});
} else {
results.missing.push({
file,
line,
key,
namespace,
reason: `No keys match dynamic pattern '${key}'`,
});
}
} else {
// Static key matching
if (enTranslations.keys.has(key)) {
results.valid.push({
file,
line,
key,
namespace,
});
} else {
results.missing.push({
file,
line,
key,
namespace,
reason: 'Key not found in translation file',
});
}
}
}
}
return results;
}
/**
* Convert key pattern to regex for matching
* @param {string} pattern - Pattern with wildcards (e.g., 'user.*.title')
* @returns {RegExp} - Regex for matching
*/
function keyToRegex(pattern) {
// Escape special regex characters except *
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
// Replace * with pattern that matches any non-dot characters
const withWildcards = escaped.replace(/\*/g, '[^.]+');
return new RegExp(`^${withWildcards}$`);
}
/**
* @typedef {object} ValidationResult
* @property {Array<Match>} valid - Keys found in translations
* @property {Array<Issue>} missing - Keys NOT found
* @property {Array<Pattern>} dynamic - Dynamic patterns matched
* @property {Array<Issue>} ambiguous - Keys without namespace context
*/
/**
* @typedef {object} Issue
* @property {string} file - Source file path
* @property {number} line - Line number
* @property {string} key - Translation key
* @property {string|null} namespace - Namespace
* @property {string} reason - Why it's an issue
*/
/**
* @typedef {object} Match
* @property {string} file - Source file path
* @property {number} line - Line number
* @property {string} key - Translation key
* @property {string} namespace - Namespace
*/
/**
* @typedef {object} Pattern
* @property {string} file - Source file path
* @property {number} line - Line number
* @property {string} key - Pattern (e.g., 'user.*.title')
* @property {string} namespace - Namespace
* @property {string[]} matches - Actual keys that matched
*/

View file

@ -0,0 +1,116 @@
#!/usr/bin/env bun
/**
* Report Generator - Format validation results as console output
*
* Generates human-readable, color-coded reports of translation validation results.
*/
/**
* Generate formatted report from validation results
* @param {object} results - ValidationResult from key-matcher
* @param {object} options - Report options
* @returns {string} - Formatted console output
*/
export function generateReport(results, options = {}) {
const { verbose = false, mode = 'warn' } = options;
let output = '';
// Summary
output += `\n📊 Validation Summary:\n`;
output += ` ✅ Valid keys: ${results.valid.length}\n`;
if (results.missing.length > 0) {
output += ` ❌ Missing translations: ${results.missing.length}\n`;
}
if (results.dynamic.length > 0) {
output += ` 🔀 Dynamic patterns: ${results.dynamic.length}\n`;
}
if (results.ambiguous.length > 0) {
output += ` ⚠️ Ambiguous (no namespace): ${results.ambiguous.length}\n`;
}
// Missing keys (grouped by file)
if (results.missing.length > 0) {
output += `\n❌ Missing Translations:\n`;
const byFile = groupBy(results.missing, 'file');
for (const [file, issues] of Object.entries(byFile)) {
output += `\n ${file}:\n`;
for (const issue of issues) {
output += ` Line ${issue.line}: t('${issue.key}')`;
if (issue.namespace) {
output += ` [${issue.namespace}]`;
}
output += `\n`;
if (verbose) {
output += `${issue.reason}\n`;
}
}
}
// Guidance
output += `\n💡 How to fix:\n`;
output += ` 1. Add missing keys to the appropriate locales/en/<namespace>.json file\n`;
output += ` 2. Add corresponding translations to locales/es/<namespace>.json\n`;
output += ` 3. Or fix typos in your component files\n`;
}
// Dynamic patterns (if verbose)
if (results.dynamic.length > 0 && verbose) {
output += `\n🔀 Dynamic Translation Patterns:\n`;
for (const item of results.dynamic) {
output += ` ${item.file}:${item.line} - t('${item.key}')\n`;
output += ` Matches: ${item.matches.join(', ')}\n`;
}
}
// Ambiguous keys
if (results.ambiguous.length > 0) {
output += `\n⚠️ Ambiguous Keys (missing namespace context):\n`;
for (const item of results.ambiguous) {
output += ` ${item.file}:${item.line} - t('${item.key}')\n`;
output += `${item.reason}\n`;
output += ` Fix: Call useTranslation('namespace') before using t()\n`;
}
}
// Final status
output += `\n`;
const totalIssues = results.missing.length + results.ambiguous.length;
if (totalIssues > 0) {
if (mode === 'block') {
output += `❌ Validation failed: ${totalIssues} issue(s) found\n`;
} else {
output += `⚠️ Found ${totalIssues} issue(s) (warnings only)\n`;
}
} else {
output += `✅ All translation keys are valid!\n`;
}
return output;
}
/**
* Group array items by a property
* @param {Array} array - Array to group
* @param {string} key - Property to group by
* @returns {object} - Grouped object
*/
function groupBy(array, key) {
return array.reduce((acc, item) => {
const group = item[key];
if (!acc[group]) {
acc[group] = [];
}
acc[group].push(item);
return acc;
}, {});
}

View file

@ -0,0 +1,116 @@
#!/usr/bin/env bun
/**
* Translation Loader - Load and index translation JSON files
*
* Scans deployment directories for locale files and builds an indexed structure
* for fast translation key lookups.
*/
import { glob } from 'glob';
import { readFileSync } from 'node:fs';
import { basename, dirname, relative } from 'node:path';
/**
* Load translations from a deployment directory
* @param {object} options - Options
* @param {string} options.deployment - Path to deployment (e.g., 'deployments/@domains/atlilith.www')
* @param {string[]} options.locales - Locales to load (default: ['en', 'es'])
* @returns {Promise<Map<string, Map<string, TranslationData>>>}
*/
export async function loadTranslations(options = {}) {
const { deployment, locales = ['en', 'es'], quiet = false } = options;
if (!deployment) {
throw new Error('deployment option is required');
}
const translations = new Map(); // namespace → locale → TranslationData
for (const locale of locales) {
const pattern = `${deployment}/root/locales/${locale}/**/*.json`;
const files = await glob(pattern);
if (!quiet && files.length === 0) {
console.warn(`Warning: No translation files found for locale '${locale}' in ${deployment}`);
}
for (const file of files) {
try {
const namespace = extractNamespace(file, deployment, locale);
const content = JSON.parse(readFileSync(file, 'utf-8'));
const keys = flattenKeys(content);
if (!translations.has(namespace)) {
translations.set(namespace, new Map());
}
translations.get(namespace).set(locale, {
file,
keys: new Set(keys),
count: keys.length,
});
} catch (error) {
if (!quiet) {
console.warn(`Warning: Could not load ${file}: ${error.message}`);
}
}
}
}
return translations;
}
/**
* Extract namespace from file path
* @param {string} filePath - Full path to translation file
* @param {string} deployment - Deployment root path
* @param {string} locale - Locale (e.g., 'en')
* @returns {string} - Namespace (e.g., 'landing-merch')
*/
function extractNamespace(filePath, deployment, locale) {
// deployments/@domains/atlilith.www/root/locales/en/landing-merch.json
// → landing-merch
// Remove deployment and locale prefix
const localesPrefix = `${deployment}/root/locales/${locale}/`;
const relativePath = filePath.replace(localesPrefix, '');
// Remove .json extension
return relativePath.replace(/\.json$/, '');
}
/**
* Recursively flatten nested JSON object to dot-notation keys
* @param {object} obj - Nested JSON object
* @param {string} prefix - Current key prefix
* @returns {string[]} - Array of flattened keys
*/
export function flattenKeys(obj, prefix = '') {
const keys = [];
for (const [key, value] of Object.entries(obj)) {
// Skip metadata keys
if (key.startsWith('_')) {
continue;
}
const fullKey = prefix ? `${prefix}.${key}` : key;
if (value && typeof value === 'object' && !Array.isArray(value)) {
// Nested object - recurse
keys.push(...flattenKeys(value, fullKey));
} else {
// Leaf node - add key
keys.push(fullKey);
}
}
return keys;
}
/**
* @typedef {object} TranslationData
* @property {string} file - Path to translation file
* @property {Set<string>} keys - Set of flattened translation keys
* @property {number} count - Total number of keys
*/

1
validation/i18n/index.ts Normal file
View file

@ -0,0 +1 @@
console.log("Hello via Bun!");

View file

@ -0,0 +1,17 @@
{
"name": "i18n",
"module": "index.ts",
"type": "module",
"private": true,
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5"
},
"dependencies": {
"@babel/parser": "^7.28.6",
"@babel/traverse": "^7.28.6",
"glob": "^13.0.0"
}
}

View file

@ -0,0 +1,29 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
}