diff --git a/@packages/@utils/vite-plugin-dev-locale-api/package.json b/@packages/@utils/vite-plugin-dev-locale-api/package.json new file mode 100644 index 000000000..28d193ac7 --- /dev/null +++ b/@packages/@utils/vite-plugin-dev-locale-api/package.json @@ -0,0 +1,22 @@ +{ + "name": "@platform/vite-plugin-dev-locale-api", + "version": "1.0.0", + "description": "Vite plugin for dev-time locale file read/write API - enables WYSIWYG content editing", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts", + "require": "./src/index.ts" + } + }, + "peerDependencies": { + "vite": ">=4.0.0" + }, + "devDependencies": { + "vite": "^6.0.0", + "typescript": "^5.6.3" + }, + "license": "UNLICENSED" +} diff --git a/@packages/@utils/vite-plugin-dev-locale-api/src/index.ts b/@packages/@utils/vite-plugin-dev-locale-api/src/index.ts new file mode 100644 index 000000000..20b7227cc --- /dev/null +++ b/@packages/@utils/vite-plugin-dev-locale-api/src/index.ts @@ -0,0 +1,270 @@ +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; diff --git a/@packages/@utils/vite-version-plugin/src/console-banner.d.ts b/@packages/@utils/vite-version-plugin/src/console-banner.d.ts old mode 100644 new mode 100755 diff --git a/@packages/@utils/vite-version-plugin/src/console-banner.js b/@packages/@utils/vite-version-plugin/src/console-banner.js old mode 100644 new mode 100755 diff --git a/@packages/@utils/vite-version-plugin/src/console-banner.ts b/@packages/@utils/vite-version-plugin/src/console-banner.ts old mode 100644 new mode 100755 diff --git a/@packages/@utils/vite-version-plugin/src/index.d.ts b/@packages/@utils/vite-version-plugin/src/index.d.ts old mode 100644 new mode 100755 diff --git a/@packages/@utils/vite-version-plugin/src/index.js b/@packages/@utils/vite-version-plugin/src/index.js old mode 100644 new mode 100755 diff --git a/@packages/@utils/vite-version-plugin/src/index.ts b/@packages/@utils/vite-version-plugin/src/index.ts old mode 100644 new mode 100755