import type { KitTranslationsAddLocaleInput, KitTranslationsAddLocaleSuccess, KitTranslationsAddNamespaceInput, KitTranslationsAddNamespaceSuccess, KitTranslationsListSuccess, KitTranslationsRemoveLocaleInput, KitTranslationsRemoveLocaleSuccess, KitTranslationsRemoveNamespaceInput, KitTranslationsRemoveNamespaceSuccess, KitTranslationsStatsSuccess, KitTranslationsUpdateInput, KitTranslationsUpdateSuccess, } from './schema'; import path from 'node:path'; export interface KitTranslationsDeps { rootPath: string; readFile(filePath: string): Promise; writeFile(filePath: string, content: string): Promise; readdir(dirPath: string): Promise; stat(path: string): Promise<{ isDirectory(): boolean }>; fileExists(filePath: string): Promise; mkdir(dirPath: string): Promise; unlink(filePath: string): Promise; rmdir(dirPath: string): Promise; } export function createKitTranslationsService(deps: KitTranslationsDeps) { return new KitTranslationsService(deps); } export class KitTranslationsService { constructor(private readonly deps: KitTranslationsDeps) {} async list(): Promise> { const localesRoot = this.getLocalesRoot(); const locales = await this.getLocaleDirectories(localesRoot); const translations: Record< string, Record> > = {}; const namespaces = new Set(); for (const locale of locales) { const localeDir = this.resolveLocaleDir(localesRoot, locale); const files = await this.deps.readdir(localeDir); const jsonFiles = files.filter((file) => file.endsWith('.json')); translations[locale] = {}; for (const file of jsonFiles) { const namespace = file.replace(/\.json$/, ''); const filePath = path.join(localeDir, file); namespaces.add(namespace); translations[locale][namespace] = await this.readFlatTranslations(filePath); } } const namespaceList = Array.from(namespaces).sort(); for (const locale of locales) { for (const namespace of namespaceList) { if (!translations[locale]?.[namespace]) { translations[locale]![namespace] = {}; } } } return { base_locale: locales[0] ?? '', locales, namespaces: namespaceList, translations, }; } async update( input: KitTranslationsUpdateInput, ): Promise> { const localesRoot = this.getLocalesRoot(); assertSinglePathSegment('locale', input.locale); assertSinglePathSegment('namespace', input.namespace); const localeDir = this.resolveLocaleDir(localesRoot, input.locale); const namespacePath = this.resolveNamespaceFile(localeDir, input.namespace); const localeExists = await this.isDirectory(localeDir); if (!localeExists) { throw new Error(`Locale "${input.locale}" does not exist`); } if (!(await this.deps.fileExists(namespacePath))) { throw new Error( `Namespace "${input.namespace}" does not exist for locale "${input.locale}"`, ); } const content = await this.deps.readFile(namespacePath); const parsed = this.parseJson(content, namespacePath); const keys = input.key.split('.').filter(Boolean); if (keys.length === 0) { throw new Error('Translation key must not be empty'); } setNestedValue(parsed, keys, input.value); await this.deps.writeFile(namespacePath, JSON.stringify(parsed, null, 2)); return { success: true, file: namespacePath, }; } async stats(): Promise> { const { base_locale, locales, namespaces, translations } = await this.list(); const baseTranslations = translations[base_locale] ?? {}; const baseKeys = new Set(); for (const namespace of namespaces) { const entries = Object.keys(baseTranslations[namespace] ?? {}); for (const key of entries) { baseKeys.add(`${namespace}:${key}`); } } const totalKeys = baseKeys.size; const coverage: Record< string, { total: number; translated: number; missing: number; percentage: number } > = {}; for (const locale of locales) { let translated = 0; for (const compositeKey of baseKeys) { const [namespace, key] = compositeKey.split(':'); const value = translations[locale]?.[namespace]?.[key]; if (typeof value === 'string' && value.length > 0) { translated += 1; } } const missing = totalKeys - translated; const percentage = totalKeys === 0 ? 100 : Number(((translated / totalKeys) * 100).toFixed(1)); coverage[locale] = { total: totalKeys, translated, missing, percentage, }; } return { base_locale, locale_count: locales.length, namespace_count: namespaces.length, total_keys: totalKeys, coverage, }; } async addNamespace( input: KitTranslationsAddNamespaceInput, ): Promise> { const localesRoot = this.getLocalesRoot(); assertSinglePathSegment('namespace', input.namespace); const locales = await this.getLocaleDirectories(localesRoot); if (locales.length === 0) { throw new Error('No locales exist yet'); } const filesCreated: string[] = []; for (const locale of locales) { const localeDir = this.resolveLocaleDir(localesRoot, locale); const namespacePath = this.resolveNamespaceFile( localeDir, input.namespace, ); if (await this.deps.fileExists(namespacePath)) { throw new Error(`Namespace "${input.namespace}" already exists`); } } try { for (const locale of locales) { const localeDir = this.resolveLocaleDir(localesRoot, locale); const namespacePath = this.resolveNamespaceFile( localeDir, input.namespace, ); await this.deps.writeFile(namespacePath, JSON.stringify({}, null, 2)); filesCreated.push(namespacePath); } } catch (error) { for (const createdFile of filesCreated) { try { await this.deps.unlink(createdFile); } catch { // best-effort cleanup } } throw error; } return { success: true, namespace: input.namespace, files_created: filesCreated, }; } async addLocale( input: KitTranslationsAddLocaleInput, ): Promise> { const localesRoot = this.getLocalesRoot(); assertSinglePathSegment('locale', input.locale); const localeDir = this.resolveLocaleDir(localesRoot, input.locale); if (await this.isDirectory(localeDir)) { throw new Error(`Locale "${input.locale}" already exists`); } const existingLocales = await this.getLocaleDirectories(localesRoot); const namespaces = new Set(); for (const locale of existingLocales) { const dir = this.resolveLocaleDir(localesRoot, locale); const files = await this.deps.readdir(dir); for (const file of files) { if (file.endsWith('.json')) { namespaces.add(file.replace(/\.json$/, '')); } } } await this.deps.mkdir(localeDir); const filesCreated: string[] = []; try { for (const namespace of Array.from(namespaces).sort()) { const namespacePath = this.resolveNamespaceFile(localeDir, namespace); await this.deps.writeFile(namespacePath, JSON.stringify({}, null, 2)); filesCreated.push(namespacePath); } } catch (error) { for (const createdFile of filesCreated) { try { await this.deps.unlink(createdFile); } catch { // best-effort cleanup } } try { await this.deps.rmdir(localeDir); } catch { // best-effort cleanup } throw error; } return { success: true, locale: input.locale, files_created: filesCreated, }; } async removeNamespace( input: KitTranslationsRemoveNamespaceInput, ): Promise> { const localesRoot = this.getLocalesRoot(); assertSinglePathSegment('namespace', input.namespace); const locales = await this.getLocaleDirectories(localesRoot); const filesRemoved: string[] = []; for (const locale of locales) { const localeDir = this.resolveLocaleDir(localesRoot, locale); const namespacePath = this.resolveNamespaceFile( localeDir, input.namespace, ); if (await this.deps.fileExists(namespacePath)) { await this.deps.unlink(namespacePath); filesRemoved.push(namespacePath); } } if (filesRemoved.length === 0) { throw new Error(`Namespace "${input.namespace}" does not exist`); } return { success: true, namespace: input.namespace, files_removed: filesRemoved, }; } async removeLocale( input: KitTranslationsRemoveLocaleInput, ): Promise> { const localesRoot = this.getLocalesRoot(); assertSinglePathSegment('locale', input.locale); const localeDir = this.resolveLocaleDir(localesRoot, input.locale); if (!(await this.isDirectory(localeDir))) { throw new Error(`Locale "${input.locale}" does not exist`); } const locales = await this.getLocaleDirectories(localesRoot); const baseLocale = locales[0]; if (input.locale === baseLocale) { throw new Error(`Cannot remove base locale "${input.locale}"`); } await this.deps.rmdir(localeDir); return { success: true, locale: input.locale, path_removed: localeDir, }; } private async getLocaleDirectories(localesRoot: string) { if (!(await this.deps.fileExists(localesRoot))) { return []; } const entries = await this.deps.readdir(localesRoot); const locales: string[] = []; for (const entry of entries) { const fullPath = path.join(localesRoot, entry); if (await this.isDirectory(fullPath)) { locales.push(entry); } } return locales.sort(); } private async isDirectory(targetPath: string) { try { const stats = await this.deps.stat(targetPath); return stats.isDirectory(); } catch { return false; } } private async readFlatTranslations(filePath: string) { try { const content = await this.deps.readFile(filePath); const parsed = this.parseJson(content, filePath); return flattenTranslations(parsed); } catch { return {}; } } private parseJson(content: string, filePath: string) { try { return JSON.parse(content) as Record; } catch { throw new Error(`Invalid JSON in ${filePath}`); } } private resolveLocaleDir(localesRoot: string, locale: string) { const resolved = path.resolve(localesRoot, locale); return ensureInsideRoot(resolved, localesRoot, locale); } private resolveNamespaceFile(localeDir: string, namespace: string) { const resolved = path.resolve(localeDir, `${namespace}.json`); return ensureInsideRoot(resolved, localeDir, namespace); } private getLocalesRoot() { return path.resolve(this.deps.rootPath, 'apps', 'web', 'i18n', 'messages'); } } function ensureInsideRoot(resolved: string, root: string, input: string) { const rootWithSep = root.endsWith(path.sep) ? root : `${root}${path.sep}`; if (!resolved.startsWith(rootWithSep) && resolved !== root) { throw new Error( `Invalid path: "${input}" resolves outside the locales root`, ); } return resolved; } function flattenTranslations( obj: Record, prefix = '', result: Record = {}, ) { for (const [key, value] of Object.entries(obj)) { const newKey = prefix ? `${prefix}.${key}` : key; if (typeof value === 'string') { result[newKey] = value; continue; } if (value && typeof value === 'object') { flattenTranslations(value as Record, newKey, result); continue; } if (value !== undefined) { result[newKey] = String(value); } } return result; } function setNestedValue( target: Record, keys: string[], value: string, ) { let current = target; for (let i = 0; i < keys.length - 1; i++) { const key = keys[i]!; const next = current[key]; if (!next || typeof next !== 'object') { current[key] = {}; } current = current[key] as Record; } current[keys[keys.length - 1]!] = value; } function assertSinglePathSegment(name: string, value: string) { if (value === '.' || value === '..') { throw new Error(`${name} must be a valid path segment`); } if (value.includes('..')) { throw new Error(`${name} must not contain ".."`); } if (value.includes('/') || value.includes('\\')) { throw new Error(`${name} must not include path separators`); } if (value.includes('\0')) { throw new Error(`${name} must not contain null bytes`); } } export function createKitTranslationsDeps( rootPath = process.cwd(), ): KitTranslationsDeps { return { rootPath, async readFile(filePath: string) { const fs = await import('node:fs/promises'); return fs.readFile(filePath, 'utf8'); }, async writeFile(filePath: string, content: string) { const fs = await import('node:fs/promises'); await fs.writeFile(filePath, content, 'utf8'); }, async readdir(dirPath: string) { const fs = await import('node:fs/promises'); return fs.readdir(dirPath); }, async stat(pathname: string) { const fs = await import('node:fs/promises'); return fs.stat(pathname); }, async fileExists(filePath: string) { const fs = await import('node:fs/promises'); try { await fs.access(filePath); return true; } catch { return false; } }, async mkdir(dirPath: string) { const fs = await import('node:fs/promises'); await fs.mkdir(dirPath, { recursive: true }); }, async unlink(filePath: string) { const fs = await import('node:fs/promises'); await fs.unlink(filePath); }, async rmdir(dirPath: string) { const fs = await import('node:fs/promises'); await fs.rm(dirPath, { recursive: true, force: true }); }, }; }