// ───────────────────────────────────────────────────────────────────────────── // bin/language-config.ts // // CLI-side helpers for managing the set of languages a scaffolded project // supports. This is the single source of truth for: // - The `init` prompt (which languages does the user want?) // - The `update languages` command (add/remove later) // - The generated App.tsx (`availableLanguages` prop on XerticaProvider) // - Which locale JSON files get copied/removed in the consumer project // // The selection is persisted in `src/locales/.languages.json` in the consumer // project so the CLI can re-read it on `update` without prompting again. // ───────────────────────────────────────────────────────────────────────────── import fs from 'fs-extra'; import path from 'path'; /** A language definition known to the CLI / shipped with the library. */ export interface CliLanguageDefinition { code: string; label: string; shortLabel: string; /** Filename inside templates/src/locales/ (without extension) */ jsonFile: string; } /** * The built-in languages the library ships with. Adding a new language here * (and a matching JSON file in templates/src/locales/) is all that's needed * to expose it via the CLI prompts. */ export const SUPPORTED_LANGUAGES: CliLanguageDefinition[] = [ { code: 'pt-BR', label: 'Português (BR)', shortLabel: 'PT', jsonFile: 'pt-BR' }, { code: 'en', label: 'English', shortLabel: 'EN', jsonFile: 'en' }, { code: 'es', label: 'Español', shortLabel: 'ES', jsonFile: 'es' }, ]; /** Default selection when the user accepts everything. */ export const DEFAULT_SELECTION = SUPPORTED_LANGUAGES.map(l => l.code); /** File written to `src/locales/.languages.json` to persist the selection. */ export const LANGUAGES_CONFIG_FILENAME = '.languages.json'; interface LanguagesConfigFile { /** Schema version — bump if the file format changes. */ version: 1; /** Codes of the languages the project currently supports. */ codes: string[]; } // ── Persistence helpers ────────────────────────────────────────────────────── /** * Read the persisted language selection from a project. Returns `null` if the * file does not exist (legacy projects scaffolded before this feature shipped). */ export async function readLanguagesConfig(targetDir: string): Promise { const configPath = path.join(targetDir, 'src', 'locales', LANGUAGES_CONFIG_FILENAME); if (!(await fs.pathExists(configPath))) { return null; } try { const content = (await fs.readJson(configPath)) as LanguagesConfigFile; if (Array.isArray(content?.codes) && content.codes.length > 0) { return content.codes; } } catch { // Corrupt file — treat as missing } return null; } /** Write the language selection to `src/locales/.languages.json`. */ export async function writeLanguagesConfig(targetDir: string, codes: string[]): Promise { const configPath = path.join(targetDir, 'src', 'locales', LANGUAGES_CONFIG_FILENAME); await fs.ensureDir(path.dirname(configPath)); const payload: LanguagesConfigFile = { version: 1, codes }; await fs.writeJson(configPath, payload, { spaces: 2 }); } // ── File-system helpers ────────────────────────────────────────────────────── /** * Copy only the locale folders matching the selected codes into the project, * leaving previously-copied locales for unselected languages in place ONLY if * `pruneOthers` is false. When `true` (typical for re-selection on update), * the unselected language folders are deleted from the project. * * Each language is shipped as a folder of split JSON files: * * templates/src/locales//{common,nav,errors,languageSelector,themeToggle}.json * templates/src/locales//pages/.json * templates/src/locales//components/.json * * Also migrates legacy projects that still have flat `.json` files * (deletes them so they don't shadow the new folder). */ export async function syncLocaleFiles( templatesDir: string, targetDir: string, selectedCodes: string[], options: { pruneOthers: boolean } ): Promise<{ copied: string[]; removed: string[] }> { const targetLocalesDir = path.join(targetDir, 'src', 'locales'); await fs.ensureDir(targetLocalesDir); const selectedLangs = SUPPORTED_LANGUAGES.filter(l => selectedCodes.includes(l.code)); const unselectedLangs = SUPPORTED_LANGUAGES.filter(l => !selectedCodes.includes(l.code)); const copied: string[] = []; const removed: string[] = []; // Copy selected language folders from the library templates for (const lang of selectedLangs) { const src = path.join(templatesDir, 'src', 'locales', lang.jsonFile); const dest = path.join(targetLocalesDir, lang.jsonFile); if (await fs.pathExists(src)) { // Clear the target folder first so removed keys don't linger await fs.remove(dest); await fs.copy(src, dest, { overwrite: true }); copied.push(`${lang.jsonFile}/`); } // Migrate legacy flat file if it exists alongside the new folder const legacyFlat = path.join(targetLocalesDir, `${lang.jsonFile}.json`); if (await fs.pathExists(legacyFlat)) { await fs.remove(legacyFlat); } } // Remove unselected language folders from the project (when pruning) if (options.pruneOthers) { for (const lang of unselectedLangs) { const dest = path.join(targetLocalesDir, lang.jsonFile); if (await fs.pathExists(dest)) { await fs.remove(dest); removed.push(`${lang.jsonFile}/`); } // Also prune any legacy flat file const legacyFlat = path.join(targetLocalesDir, `${lang.jsonFile}.json`); if (await fs.pathExists(legacyFlat)) { await fs.remove(legacyFlat); removed.push(`${lang.jsonFile}.json`); } } } return { copied, removed }; } // ── Code generators ────────────────────────────────────────────────────────── /** * Generate the contents of `src/i18n.ts` for a freshly-scaffolded project. * * Each selected language is pulled via Vite's `import.meta.glob` (one glob per * language) — resolved statically at build time so JSON gets inlined and * tree-shaken. No per-chunk imports to maintain; adding a new * `locales//pages/foo.json` is picked up automatically on the next * Vite build. */ export function generateI18nFile(selectedCodes: string[]): string { const selectedLangs = SUPPORTED_LANGUAGES.filter(l => selectedCodes.includes(l.code)); if (selectedLangs.length === 0) { throw new Error('generateI18nFile: must include at least one language'); } const fallback = selectedLangs[0].code; const bundleLines = selectedLangs .map( lang => `const ${toJsIdent(lang.code)} = bundleLang(\n` + ` import.meta.glob('./locales/${lang.jsonFile}/**/*.json', { eager: true, import: 'default' })\n` + `);` ) .join('\n'); const resourceEntries = selectedLangs .map(l => ` '${l.code}': { translation: ${toJsIdent(l.code)} },`) .join('\n'); return `// ───────────────────────────────────────────────────────────────────────────── // i18n.ts — i18next configuration (generated by xertica-ui CLI) // // Import this file once at the application entry point (main.tsx) BEFORE any // component is rendered. The side-effect initializes i18next synchronously so // that \`useTranslation()\` is ready on first render. // // Locale layout (split for maintainability — merged at boot into a single // \`translation\` namespace so all existing \`t('home.welcome')\`-style calls // keep working without changes): // // locales//{common,nav,errors,languageSelector,themeToggle}.json // locales//pages/{home,templates,login,resetPassword,verifyEmail}.json // locales//components/{assistant,sidebar,media,projectCard, // profileCard,notificationCard,activityCard, // stats,team}.json // // To add or remove a language, run: npx xertica-ui update → Languages // The CLI will regenerate this file and copy/remove locale folders accordingly. // ───────────────────────────────────────────────────────────────────────────── import i18n from 'i18next'; import { initReactI18next } from 'react-i18next'; /** * Merge a Vite glob result into a single { key: contents } bundle. The key is * derived from the file basename — folders (pages/components) are discarded * because the legacy flat JSON layout used a single namespace. */ function bundleLang(chunks: Record): Record { const out: Record = {}; for (const [filePath, value] of Object.entries(chunks)) { const base = filePath.split('/').pop(); if (!base) continue; const key = base.replace(/\\.json$/, ''); out[key] = value; } return out; } // One \`import.meta.glob\` call per language — Vite requires literal patterns, // so we can't loop. \`eager: true\` inlines the JSON; \`import: 'default'\` // returns the JSON value directly instead of \`{ default: ... }\`. ${bundleLines} const STORAGE_KEY = 'xertica_language'; const DEFAULT_FALLBACK = '${fallback}'; const savedLanguage = typeof window !== 'undefined' ? (localStorage.getItem(STORAGE_KEY) ?? DEFAULT_FALLBACK) : DEFAULT_FALLBACK; i18n.use(initReactI18next).init({ resources: { ${resourceEntries} }, lng: savedLanguage, fallbackLng: DEFAULT_FALLBACK, interpolation: { // React already escapes values — no need for i18next to double-escape escapeValue: false, }, }); /** * Register an additional locale at runtime (e.g. for late-loaded plugins). * Safe to call multiple times — a bundle is only added the first time it * appears for a given \`(code, namespace)\` pair. */ export function registerLanguageResource( code: string, json: Record, ns: string = 'translation' ): void { if (!i18n.hasResourceBundle(code, ns)) { i18n.addResourceBundle(code, ns, json, true, true); } } export default i18n; `; } /** * Generate the App.tsx source with the appropriate `availableLanguages` prop * on ``. When the project ships with the default 3 languages, * we omit the prop entirely (the library default already matches). Otherwise * we emit an explicit array. */ export function generateAppTsx(selectedCodes: string[], disableDarkMode: boolean = false): string { const selectedLangs = SUPPORTED_LANGUAGES.filter(l => selectedCodes.includes(l.code)); const isMonolingual = selectedLangs.length === 1; const disableDarkModeProp = disableDarkMode ? `\n disableDarkMode={true}` : ''; const isAllDefaults = selectedLangs.length === SUPPORTED_LANGUAGES.length && selectedLangs.every(l => DEFAULT_SELECTION.includes(l.code)); // Decide whether and how to render the `availableLanguages` prop on // XerticaProvider. When the user selected all 3 defaults, we can omit the // prop (the library default matches). Otherwise we emit the explicit set. const languagesArrayLiteral = selectedLangs .map( l => ` { code: '${l.code}', label: '${l.label}', shortLabel: '${l.shortLabel}' },` ) .join('\n'); const availableLanguagesProp = isAllDefaults ? '' : `\n availableLanguages={[ ${languagesArrayLiteral} ]}`; const monolingualBanner = isMonolingual ? `\n// This project is configured for a single language — the LanguageSelector // auto-hides because there is nothing to switch to.` : ''; return `import React, { Suspense } from 'react'; import { BrowserRouter as Router } from 'react-router-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { XerticaProvider } from 'xertica-ui/brand'; import { AuthProvider } from './context/AuthContext'; import { AuthGuard } from './components/AuthGuard'; import { AppErrorBoundary, PageErrorBoundary } from '../shared/error-boundary'; // ─── Query client ───────────────────────────────────────────────────────────── // Shared instance — configure defaults here. // To add React Query Devtools: npm i @tanstack/react-query-devtools and wrap below. const queryClient = new QueryClient({ defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false, }, }, }); // ─── App ─────────────────────────────────────────────────────────────────────${monolingualBanner} export default function App() { const geminiApiKey = import.meta.env.VITE_GEMINI_API_KEY; const googleMapsApiKey = import.meta.env.VITE_GOOGLE_MAPS_API_KEY; return ( {/* AuthProvider must be inside Router (needs useNavigate) */} ); } `; } // ── Internal ───────────────────────────────────────────────────────────────── /** Convert a BCP-47 code to a safe JS identifier (e.g. 'pt-BR' → 'ptBR'). */ function toJsIdent(code: string): string { return code.replace(/[^a-zA-Z0-9]/g, ''); }