import type { Plugin } from 'vite'; import fs from 'fs'; import path from 'path'; /** * Configuration for a locale directory */ export interface LocaleDirConfig { /** Absolute or relative path to the locale directory */ path: string; /** * How to derive namespace from filename. * - 'filename': Use filename as namespace (e.g., 'marketplace-about.json' → 'marketplace-about') * - 'prefix': Add prefix to filename (e.g., prefix='marketplace-landing' + 'worker.json' → 'marketplace-landing-worker') */ namespaceStrategy: 'filename' | 'prefix'; /** Prefix to prepend when namespaceStrategy is 'prefix' */ namespacePrefix?: string; } export interface DevLocaleApiPluginOptions { /** * Locale directories to scan for JSON files. * Each directory can have its own namespace strategy. */ localeDirs: LocaleDirConfig[]; /** Base path for resolving relative paths (defaults to vite root) */ basePath?: string; } interface LocaleFileEntry { filePath: string; namespace: string; } /** * Helper to get nested value from object by dot-notation path */ function getNestedValue(obj: Record, keyPath: string): unknown { const keys = keyPath.split('.'); let current: unknown = obj; for (const key of keys) { if (current === null || current === undefined || typeof current !== 'object') { return undefined; } current = (current as Record)[key]; } return current; } /** * Helper to set nested value in object by dot-notation path */ function setNestedValue(obj: Record, keyPath: string, value: unknown): void { const keys = keyPath.split('.'); let current = obj; for (let i = 0; i < keys.length - 1; i++) { const key = keys[i]; if (!(key in current) || typeof current[key] !== 'object') { current[key] = {}; } current = current[key] as Record; } current[keys[keys.length - 1]] = value; } /** * Build namespace-to-file mapping by scanning locale directories */ function buildNamespaceMap( localeDirs: LocaleDirConfig[], basePath: string ): Map { const map = new Map(); for (const dirConfig of localeDirs) { const dirPath = path.isAbsolute(dirConfig.path) ? dirConfig.path : path.resolve(basePath, dirConfig.path); if (!fs.existsSync(dirPath)) { console.warn(`[dev-locale-api] Directory not found: ${dirPath}`); continue; } for (const file of fs.readdirSync(dirPath)) { if (!file.endsWith('.json') || file.endsWith('.backup')) continue; const baseName = file.replace('.json', ''); let namespace: string; if (dirConfig.namespaceStrategy === 'prefix' && dirConfig.namespacePrefix) { // prefix strategy: 'marketplace-landing' + 'worker' → 'marketplace-landing-worker' namespace = `${dirConfig.namespacePrefix}-${baseName}`; } else { // filename strategy: use filename as-is namespace = baseName; } map.set(namespace, { filePath: path.join(dirPath, file), namespace, }); } } return map; } /** * Vite plugin for dev-time locale file read/write API. * Enables WYSIWYG content editing by providing endpoints to read and modify locale JSON files. * * Endpoints: * - GET /api/dev/read-locale?file=namespace:key - Read locale content * - POST /api/dev/write-locale - Write locale content * * @example * ```typescript * import { devLocaleApiPlugin } from '@platform/vite-plugin-dev-locale-api'; * * export default defineConfig({ * plugins: [ * devLocaleApiPlugin({ * localeDirs: [ * // Shared locales - namespace = filename * { path: './src/locales/en', namespaceStrategy: 'filename' }, * // Deployment-specific - namespace = prefix + filename * { path: `./src/locales/${DEPLOYMENT}/en`, namespaceStrategy: 'prefix', namespacePrefix: 'marketplace-landing' }, * ], * }), * ], * }); * ``` */ export function devLocaleApiPlugin(options: DevLocaleApiPluginOptions): Plugin { let namespaceMap: Map; return { name: 'dev-locale-api', configResolved(config) { const basePath = options.basePath ?? config.root; namespaceMap = buildNamespaceMap(options.localeDirs, basePath); if (namespaceMap.size === 0) { console.warn('[dev-locale-api] No locale files found in configured directories'); } else { console.log(`[dev-locale-api] Discovered ${namespaceMap.size} locale namespaces`); } }, configureServer(server) { // READ endpoint server.middlewares.use('/api/dev/read-locale', (req, res) => { const url = new URL(req.url || '', 'http://localhost'); const fileParam = url.searchParams.get('file'); if (!fileParam) { res.statusCode = 400; res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ error: 'Missing file parameter' })); return; } // Parse namespace:key format (e.g., "marketplace-about:title") const colonIndex = fileParam.indexOf(':'); const namespace = colonIndex > -1 ? fileParam.substring(0, colonIndex) : fileParam; const keyPath = colonIndex > -1 ? fileParam.substring(colonIndex + 1) : null; const entry = namespaceMap.get(namespace); if (!entry) { res.statusCode = 404; res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ error: `Unknown namespace: ${namespace}`, availableNamespaces: Array.from(namespaceMap.keys()).slice(0, 10), })); return; } try { const content = fs.readFileSync(entry.filePath, 'utf-8'); const json = JSON.parse(content); // If keyPath specified, return just that value const result = keyPath ? getNestedValue(json, keyPath) : json; res.statusCode = 200; res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(result)); } catch (error) { console.error('[dev-locale-api] Error reading locale file:', error); res.statusCode = 500; res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ error: `Failed to read locale file: ${entry.filePath}` })); } }); // WRITE endpoint server.middlewares.use('/api/dev/write-locale', (req, res) => { if (req.method !== 'POST') { res.statusCode = 405; res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ error: 'Method not allowed' })); return; } let body = ''; req.on('data', (chunk: Buffer) => { body += chunk.toString(); }); req.on('end', () => { try { const { file, path: keyPath, content, backup } = JSON.parse(body); if (!file || !keyPath || content === undefined) { res.statusCode = 400; res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ error: 'Missing required fields: file, path, content' })); return; } // Parse file identifier (namespace:key format from identifier) const colonIndex = file.indexOf(':'); const namespace = colonIndex > -1 ? file.substring(0, colonIndex) : file; const entry = namespaceMap.get(namespace); if (!entry) { res.statusCode = 404; res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ error: `Unknown namespace: ${namespace}` })); return; } // Read current file const fileContent = fs.readFileSync(entry.filePath, 'utf-8'); const json = JSON.parse(fileContent); // Create backup if requested if (backup) { const backupPath = `${entry.filePath}.backup`; fs.writeFileSync(backupPath, fileContent, 'utf-8'); console.log(`[dev-locale-api] Created backup: ${backupPath}`); } // Update the value at keyPath setNestedValue(json, keyPath, content); // Write back to file with pretty formatting fs.writeFileSync(entry.filePath, JSON.stringify(json, null, 2), 'utf-8'); console.log(`[dev-locale-api] Updated ${entry.filePath} at path: ${keyPath}`); res.statusCode = 200; res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ success: true, file: entry.filePath, path: keyPath })); } catch (error) { console.error('[dev-locale-api] Error writing locale file:', error); res.statusCode = 500; res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ error: `Failed to write: ${(error as Error).message}` })); } }); }); }, }; } export default devLocaleApiPlugin;