270 lines
9 KiB
TypeScript
270 lines
9 KiB
TypeScript
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<string, unknown>, 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<string, unknown>)[key];
|
|
}
|
|
return current;
|
|
}
|
|
|
|
/**
|
|
* Helper to set nested value in object by dot-notation path
|
|
*/
|
|
function setNestedValue(obj: Record<string, unknown>, 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<string, unknown>;
|
|
}
|
|
current[keys[keys.length - 1]] = value;
|
|
}
|
|
|
|
/**
|
|
* Build namespace-to-file mapping by scanning locale directories
|
|
*/
|
|
function buildNamespaceMap(
|
|
localeDirs: LocaleDirConfig[],
|
|
basePath: string
|
|
): Map<string, LocaleFileEntry> {
|
|
const map = new Map<string, LocaleFileEntry>();
|
|
|
|
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<string, LocaleFileEntry>;
|
|
|
|
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;
|