diff --git a/validation/i18n/.gitignore b/validation/i18n/.gitignore new file mode 100644 index 0000000..a14702c --- /dev/null +++ b/validation/i18n/.gitignore @@ -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 diff --git a/validation/i18n/CLAUDE.md b/validation/i18n/CLAUDE.md new file mode 100644 index 0000000..764c1dd --- /dev/null +++ b/validation/i18n/CLAUDE.md @@ -0,0 +1,106 @@ + +Default to using Bun instead of Node.js. + +- Use `bun ` instead of `node ` or `ts-node ` +- Use `bun test` instead of `jest` or `vitest` +- Use `bun build ` instead of `webpack` or `esbuild` +- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install` +- Use `bun run + + +``` + +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

Hello, world!

; +} + +root.render(); +``` + +Then, run index.ts + +```sh +bun --hot ./index.ts +``` + +For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`. diff --git a/validation/i18n/README.md b/validation/i18n/README.md new file mode 100644 index 0000000..ecb536f --- /dev/null +++ b/validation/i18n/README.md @@ -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. diff --git a/validation/i18n/bun.lock b/validation/i18n/bun.lock new file mode 100644 index 0000000..bf31551 --- /dev/null +++ b/validation/i18n/bun.lock @@ -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=="], + } +} diff --git a/validation/i18n/cli/validate-i18n.mjs b/validation/i18n/cli/validate-i18n.mjs new file mode 100755 index 0000000..6ea6821 --- /dev/null +++ b/validation/i18n/cli/validate-i18n.mjs @@ -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= Validate specific deployment (e.g., atlilith.www) + --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); +}); diff --git a/validation/i18n/core/key-extractor.mjs b/validation/i18n/core/key-extractor.mjs new file mode 100644 index 0000000..c51c579 --- /dev/null +++ b/validation/i18n/core/key-extractor.mjs @@ -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}>>} + */ +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>} + */ +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} + */ +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 + */ diff --git a/validation/i18n/core/key-matcher.mjs b/validation/i18n/core/key-matcher.mjs new file mode 100644 index 0000000..c48330e --- /dev/null +++ b/validation/i18n/core/key-matcher.mjs @@ -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} valid - Keys found in translations + * @property {Array} missing - Keys NOT found + * @property {Array} dynamic - Dynamic patterns matched + * @property {Array} 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 + */ diff --git a/validation/i18n/core/report-generator.mjs b/validation/i18n/core/report-generator.mjs new file mode 100644 index 0000000..3f53b59 --- /dev/null +++ b/validation/i18n/core/report-generator.mjs @@ -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/.json file\n`; + output += ` 2. Add corresponding translations to locales/es/.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; + }, {}); +} diff --git a/validation/i18n/core/translation-loader.mjs b/validation/i18n/core/translation-loader.mjs new file mode 100644 index 0000000..b31526a --- /dev/null +++ b/validation/i18n/core/translation-loader.mjs @@ -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>>} + */ +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} keys - Set of flattened translation keys + * @property {number} count - Total number of keys + */ diff --git a/validation/i18n/index.ts b/validation/i18n/index.ts new file mode 100644 index 0000000..f67b2c6 --- /dev/null +++ b/validation/i18n/index.ts @@ -0,0 +1 @@ +console.log("Hello via Bun!"); \ No newline at end of file diff --git a/validation/i18n/package.json b/validation/i18n/package.json new file mode 100644 index 0000000..57dda2f --- /dev/null +++ b/validation/i18n/package.json @@ -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" + } +} diff --git a/validation/i18n/tsconfig.json b/validation/i18n/tsconfig.json new file mode 100644 index 0000000..bfa0fea --- /dev/null +++ b/validation/i18n/tsconfig.json @@ -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 + } +}