@zachhandley/ez-i18n 0.3.4 → 0.3.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +69 -0
- package/dist/middleware.js +17 -2
- package/dist/runtime/index.d.ts +1 -0
- package/dist/runtime/index.js +7 -1
- package/package.json +2 -2
- package/src/index.ts +0 -130
- package/src/middleware.ts +0 -82
- package/src/runtime/index.ts +0 -19
- package/src/runtime/store.ts +0 -122
- package/src/types.ts +0 -125
- package/src/utils/index.ts +0 -16
- package/src/utils/locales.ts +0 -240
- package/src/utils/translations.ts +0 -418
- package/src/virtual.d.ts +0 -70
- package/src/vite-plugin.ts +0 -716
package/src/vite-plugin.ts
DELETED
|
@@ -1,716 +0,0 @@
|
|
|
1
|
-
import type { Plugin, ResolvedConfig, ViteDevServer } from 'vite';
|
|
2
|
-
import type { EzI18nConfig, ResolvedEzI18nConfig } from './types';
|
|
3
|
-
import {
|
|
4
|
-
resolveTranslationsConfig,
|
|
5
|
-
toRelativeImport,
|
|
6
|
-
toGlobPattern,
|
|
7
|
-
loadCache,
|
|
8
|
-
saveCache,
|
|
9
|
-
isCacheValid,
|
|
10
|
-
detectPathType,
|
|
11
|
-
getNamespaceFromPath,
|
|
12
|
-
generateNamespaceWrapperCode,
|
|
13
|
-
isInPublicDir,
|
|
14
|
-
toPublicUrl,
|
|
15
|
-
getLocaleBaseDirForNamespace,
|
|
16
|
-
} from './utils/translations';
|
|
17
|
-
import {
|
|
18
|
-
buildLocaleNames,
|
|
19
|
-
buildLocaleToBCP47,
|
|
20
|
-
buildLocaleDirections,
|
|
21
|
-
} from './utils/locales';
|
|
22
|
-
import * as path from 'node:path';
|
|
23
|
-
|
|
24
|
-
const VIRTUAL_CONFIG = 'ez-i18n:config';
|
|
25
|
-
const VIRTUAL_RUNTIME = 'ez-i18n:runtime';
|
|
26
|
-
const VIRTUAL_TRANSLATIONS = 'ez-i18n:translations';
|
|
27
|
-
const RESOLVED_PREFIX = '\0';
|
|
28
|
-
|
|
29
|
-
interface TranslationInfo {
|
|
30
|
-
locale: string;
|
|
31
|
-
files: string[];
|
|
32
|
-
/** Glob pattern for dev mode HMR (if applicable, null for public files) */
|
|
33
|
-
globPattern?: string | null;
|
|
34
|
-
/** Base directory for this locale (used for namespace calculation) */
|
|
35
|
-
localeBaseDir?: string;
|
|
36
|
-
/** Whether files are in the public directory (use fetch instead of import) */
|
|
37
|
-
isPublic?: boolean;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Vite plugin that provides virtual modules for ez-i18n
|
|
42
|
-
*/
|
|
43
|
-
export function vitePlugin(config: EzI18nConfig): Plugin {
|
|
44
|
-
let viteConfig: ResolvedConfig;
|
|
45
|
-
let isDev = false;
|
|
46
|
-
let resolved: ResolvedEzI18nConfig;
|
|
47
|
-
let translationInfo: Map<string, TranslationInfo> = new Map();
|
|
48
|
-
|
|
49
|
-
return {
|
|
50
|
-
name: 'ez-i18n-vite',
|
|
51
|
-
enforce: 'pre',
|
|
52
|
-
|
|
53
|
-
configResolved(resolvedConfig) {
|
|
54
|
-
viteConfig = resolvedConfig;
|
|
55
|
-
isDev = resolvedConfig.command === 'serve';
|
|
56
|
-
},
|
|
57
|
-
|
|
58
|
-
async buildStart() {
|
|
59
|
-
const projectRoot = viteConfig.root;
|
|
60
|
-
|
|
61
|
-
// Determine if path-based namespacing should be enabled
|
|
62
|
-
// Default to true when using folder-based or auto-discovery config
|
|
63
|
-
const isAutoDiscovery = !config.translations || typeof config.translations === 'string';
|
|
64
|
-
const pathBasedNamespacing = config.pathBasedNamespacing ?? isAutoDiscovery;
|
|
65
|
-
|
|
66
|
-
// Calculate base translation directory
|
|
67
|
-
const translationsBaseDir = typeof config.translations === 'string'
|
|
68
|
-
? path.resolve(projectRoot, config.translations.replace(/\/$/, ''))
|
|
69
|
-
: path.resolve(projectRoot, './public/i18n');
|
|
70
|
-
|
|
71
|
-
// Try to use cache in production builds
|
|
72
|
-
let useCache = false;
|
|
73
|
-
if (!isDev) {
|
|
74
|
-
const cache = loadCache(projectRoot);
|
|
75
|
-
if (cache && isCacheValid(cache, projectRoot)) {
|
|
76
|
-
// Build locale base dirs
|
|
77
|
-
const localeBaseDirs: Record<string, string> = {};
|
|
78
|
-
for (const locale of Object.keys(cache.discovered)) {
|
|
79
|
-
localeBaseDirs[locale] = path.join(translationsBaseDir, locale);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// Use cached discovery
|
|
83
|
-
resolved = {
|
|
84
|
-
locales: config.locales || Object.keys(cache.discovered),
|
|
85
|
-
defaultLocale: config.defaultLocale,
|
|
86
|
-
cookieName: config.cookieName ?? 'ez-locale',
|
|
87
|
-
translations: cache.discovered,
|
|
88
|
-
pathBasedNamespacing,
|
|
89
|
-
localeBaseDirs,
|
|
90
|
-
};
|
|
91
|
-
useCache = true;
|
|
92
|
-
|
|
93
|
-
// Populate translationInfo from cache
|
|
94
|
-
for (const [locale, files] of Object.entries(cache.discovered)) {
|
|
95
|
-
translationInfo.set(locale, {
|
|
96
|
-
locale,
|
|
97
|
-
files,
|
|
98
|
-
localeBaseDir: localeBaseDirs[locale],
|
|
99
|
-
});
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
if (!useCache) {
|
|
105
|
-
// Resolve translations config
|
|
106
|
-
const { locales, translations } = await resolveTranslationsConfig(
|
|
107
|
-
config.translations,
|
|
108
|
-
projectRoot,
|
|
109
|
-
config.locales
|
|
110
|
-
);
|
|
111
|
-
|
|
112
|
-
// Merge with configured locales (config takes precedence if specified)
|
|
113
|
-
const finalLocales = config.locales && config.locales.length > 0
|
|
114
|
-
? config.locales
|
|
115
|
-
: locales;
|
|
116
|
-
|
|
117
|
-
// Build locale base dirs
|
|
118
|
-
const localeBaseDirs: Record<string, string> = {};
|
|
119
|
-
for (const locale of finalLocales) {
|
|
120
|
-
if (isAutoDiscovery) {
|
|
121
|
-
// For auto-discovery, locale folder is under the base dir
|
|
122
|
-
localeBaseDirs[locale] = path.join(translationsBaseDir, locale);
|
|
123
|
-
} else if (typeof config.translations === 'object' && config.translations[locale]) {
|
|
124
|
-
// For explicit config, determine base from the config value
|
|
125
|
-
const localeConfig = config.translations[locale];
|
|
126
|
-
if (typeof localeConfig === 'string') {
|
|
127
|
-
const pathType = detectPathType(localeConfig);
|
|
128
|
-
if (pathType === 'folder' || pathType === 'file') {
|
|
129
|
-
// Use the folder itself or parent of file
|
|
130
|
-
const resolved = path.resolve(projectRoot, localeConfig.replace(/\/$/, ''));
|
|
131
|
-
localeBaseDirs[locale] = pathType === 'folder' ? resolved : path.dirname(resolved);
|
|
132
|
-
} else {
|
|
133
|
-
// Glob - use the non-glob prefix
|
|
134
|
-
const baseDir = localeConfig.split('*')[0].replace(/\/$/, '');
|
|
135
|
-
localeBaseDirs[locale] = path.resolve(projectRoot, baseDir);
|
|
136
|
-
}
|
|
137
|
-
} else {
|
|
138
|
-
// Array - use common parent directory
|
|
139
|
-
localeBaseDirs[locale] = translationsBaseDir;
|
|
140
|
-
}
|
|
141
|
-
} else {
|
|
142
|
-
localeBaseDirs[locale] = translationsBaseDir;
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
resolved = {
|
|
147
|
-
locales: finalLocales,
|
|
148
|
-
defaultLocale: config.defaultLocale,
|
|
149
|
-
cookieName: config.cookieName ?? 'ez-locale',
|
|
150
|
-
translations,
|
|
151
|
-
pathBasedNamespacing,
|
|
152
|
-
localeBaseDirs,
|
|
153
|
-
};
|
|
154
|
-
|
|
155
|
-
// Build translation info for each locale
|
|
156
|
-
for (const locale of finalLocales) {
|
|
157
|
-
const files = translations[locale] || [];
|
|
158
|
-
// Check if files are in public directory
|
|
159
|
-
const filesInPublic = files.length > 0 && isInPublicDir(files[0], projectRoot);
|
|
160
|
-
|
|
161
|
-
const info: TranslationInfo = {
|
|
162
|
-
locale,
|
|
163
|
-
files,
|
|
164
|
-
localeBaseDir: localeBaseDirs[locale],
|
|
165
|
-
isPublic: filesInPublic,
|
|
166
|
-
};
|
|
167
|
-
|
|
168
|
-
// For dev mode, determine if we can use import.meta.glob (not for public files)
|
|
169
|
-
if (isDev && config.translations && !filesInPublic) {
|
|
170
|
-
const localeConfig = typeof config.translations === 'string'
|
|
171
|
-
? path.join(config.translations, locale) + '/' // Trailing slash ensures detectPathType returns 'folder'
|
|
172
|
-
: config.translations[locale];
|
|
173
|
-
|
|
174
|
-
if (localeConfig && typeof localeConfig === 'string') {
|
|
175
|
-
const pathType = detectPathType(localeConfig);
|
|
176
|
-
if (pathType === 'folder' || pathType === 'glob') {
|
|
177
|
-
// Can use import.meta.glob for HMR (returns null for public files)
|
|
178
|
-
const basePath = pathType === 'glob'
|
|
179
|
-
? localeConfig
|
|
180
|
-
: toGlobPattern(path.resolve(projectRoot, localeConfig), projectRoot);
|
|
181
|
-
info.globPattern = basePath;
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
translationInfo.set(locale, info);
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
// Save cache for future builds
|
|
190
|
-
if (!isDev && Object.keys(translations).length > 0) {
|
|
191
|
-
saveCache(projectRoot, translations);
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
// Validate defaultLocale
|
|
196
|
-
if (!resolved.locales.includes(resolved.defaultLocale)) {
|
|
197
|
-
console.warn(
|
|
198
|
-
`[ez-i18n] defaultLocale "${resolved.defaultLocale}" not found in locales: [${resolved.locales.join(', ')}]`
|
|
199
|
-
);
|
|
200
|
-
}
|
|
201
|
-
},
|
|
202
|
-
|
|
203
|
-
resolveId(id) {
|
|
204
|
-
if (id === VIRTUAL_CONFIG || id === VIRTUAL_RUNTIME || id === VIRTUAL_TRANSLATIONS) {
|
|
205
|
-
return RESOLVED_PREFIX + id;
|
|
206
|
-
}
|
|
207
|
-
return null;
|
|
208
|
-
},
|
|
209
|
-
|
|
210
|
-
load(id) {
|
|
211
|
-
// ez-i18n:config - Static config values
|
|
212
|
-
if (id === RESOLVED_PREFIX + VIRTUAL_CONFIG) {
|
|
213
|
-
const localeNames = buildLocaleNames(resolved.locales);
|
|
214
|
-
const localeToBCP47 = buildLocaleToBCP47(resolved.locales);
|
|
215
|
-
const localeDirections = buildLocaleDirections(resolved.locales);
|
|
216
|
-
|
|
217
|
-
return `
|
|
218
|
-
export const locales = ${JSON.stringify(resolved.locales)};
|
|
219
|
-
export const defaultLocale = ${JSON.stringify(resolved.defaultLocale)};
|
|
220
|
-
export const cookieName = ${JSON.stringify(resolved.cookieName)};
|
|
221
|
-
|
|
222
|
-
/** Display names for each locale (in native language) */
|
|
223
|
-
export const localeNames = ${JSON.stringify(localeNames)};
|
|
224
|
-
|
|
225
|
-
/** BCP47 language tags for each locale */
|
|
226
|
-
export const localeToBCP47 = ${JSON.stringify(localeToBCP47)};
|
|
227
|
-
|
|
228
|
-
/** Text direction for each locale ('ltr' or 'rtl') */
|
|
229
|
-
export const localeDirections = ${JSON.stringify(localeDirections)};
|
|
230
|
-
`;
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
// ez-i18n:runtime - Runtime exports for Astro files
|
|
234
|
-
if (id === RESOLVED_PREFIX + VIRTUAL_RUNTIME) {
|
|
235
|
-
return `
|
|
236
|
-
import { effectiveLocale, translations, setLocale, initLocale } from '@zachhandley/ez-i18n/runtime';
|
|
237
|
-
|
|
238
|
-
export { setLocale, initLocale };
|
|
239
|
-
export { effectiveLocale as locale };
|
|
240
|
-
|
|
241
|
-
/**
|
|
242
|
-
* Get nested value from object using dot notation
|
|
243
|
-
*/
|
|
244
|
-
function getNestedValue(obj, path) {
|
|
245
|
-
const keys = path.split('.');
|
|
246
|
-
let value = obj;
|
|
247
|
-
for (const key of keys) {
|
|
248
|
-
if (value == null || typeof value !== 'object') return undefined;
|
|
249
|
-
value = value[key];
|
|
250
|
-
}
|
|
251
|
-
return value;
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
/**
|
|
255
|
-
* Interpolate params into string
|
|
256
|
-
*/
|
|
257
|
-
function interpolate(str, params) {
|
|
258
|
-
if (!params) return str;
|
|
259
|
-
return str.replace(/\\{(\\w+)\\}/g, (match, key) => {
|
|
260
|
-
return key in params ? String(params[key]) : match;
|
|
261
|
-
});
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
/**
|
|
265
|
-
* Translate a key to the current locale
|
|
266
|
-
* @param key - Dot-notation key (e.g., 'common.welcome')
|
|
267
|
-
* @param params - Optional interpolation params
|
|
268
|
-
*/
|
|
269
|
-
export function t(key, params) {
|
|
270
|
-
const trans = translations.get();
|
|
271
|
-
const value = getNestedValue(trans, key);
|
|
272
|
-
|
|
273
|
-
if (typeof value !== 'string') {
|
|
274
|
-
if (import.meta.env.DEV) {
|
|
275
|
-
console.warn('[ez-i18n] Missing translation:', key);
|
|
276
|
-
}
|
|
277
|
-
return key;
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
return interpolate(value, params);
|
|
281
|
-
}
|
|
282
|
-
`;
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
// ez-i18n:translations - Translation loaders
|
|
286
|
-
if (id === RESOLVED_PREFIX + VIRTUAL_TRANSLATIONS) {
|
|
287
|
-
return isDev
|
|
288
|
-
? generateDevTranslationsModule(translationInfo, viteConfig.root, resolved.pathBasedNamespacing)
|
|
289
|
-
: generateBuildTranslationsModule(translationInfo, viteConfig.root, resolved.pathBasedNamespacing);
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
return null;
|
|
293
|
-
},
|
|
294
|
-
|
|
295
|
-
// HMR support for dev mode
|
|
296
|
-
handleHotUpdate({ file, server }) {
|
|
297
|
-
if (!isDev) return;
|
|
298
|
-
|
|
299
|
-
// Only process JSON files
|
|
300
|
-
if (!file.endsWith('.json')) return;
|
|
301
|
-
|
|
302
|
-
// Check if the changed file is a translation file
|
|
303
|
-
for (const info of translationInfo.values()) {
|
|
304
|
-
if (info.files.includes(file)) {
|
|
305
|
-
// Invalidate the virtual translations module
|
|
306
|
-
const mod = server.moduleGraph.getModuleById(RESOLVED_PREFIX + VIRTUAL_TRANSLATIONS);
|
|
307
|
-
if (mod) {
|
|
308
|
-
server.moduleGraph.invalidateModule(mod);
|
|
309
|
-
// Full reload to ensure translations are updated everywhere
|
|
310
|
-
server.ws.send({
|
|
311
|
-
type: 'full-reload',
|
|
312
|
-
path: '*',
|
|
313
|
-
});
|
|
314
|
-
}
|
|
315
|
-
break;
|
|
316
|
-
}
|
|
317
|
-
}
|
|
318
|
-
},
|
|
319
|
-
|
|
320
|
-
configureServer(server: ViteDevServer) {
|
|
321
|
-
// Watch translation directories for new/deleted files
|
|
322
|
-
const watchedDirs = new Set<string>();
|
|
323
|
-
|
|
324
|
-
if (config.translations) {
|
|
325
|
-
if (typeof config.translations === 'string') {
|
|
326
|
-
// Base directory
|
|
327
|
-
watchedDirs.add(path.resolve(viteConfig.root, config.translations));
|
|
328
|
-
} else {
|
|
329
|
-
// Per-locale config
|
|
330
|
-
for (const localePath of Object.values(config.translations)) {
|
|
331
|
-
if (typeof localePath === 'string') {
|
|
332
|
-
const pathType = detectPathType(localePath);
|
|
333
|
-
if (pathType === 'folder') {
|
|
334
|
-
watchedDirs.add(path.resolve(viteConfig.root, localePath));
|
|
335
|
-
} else if (pathType === 'glob') {
|
|
336
|
-
// Extract base directory from glob
|
|
337
|
-
const baseDir = localePath.split('*')[0].replace(/\/$/, '');
|
|
338
|
-
if (baseDir) {
|
|
339
|
-
watchedDirs.add(path.resolve(viteConfig.root, baseDir));
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
} else if (Array.isArray(localePath)) {
|
|
343
|
-
// Array of files - watch parent directories
|
|
344
|
-
for (const file of localePath) {
|
|
345
|
-
const dir = path.dirname(path.resolve(viteConfig.root, file));
|
|
346
|
-
watchedDirs.add(dir);
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
} else {
|
|
352
|
-
// Default directory
|
|
353
|
-
watchedDirs.add(path.resolve(viteConfig.root, './public/i18n'));
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
// Add directories to watcher
|
|
357
|
-
for (const dir of watchedDirs) {
|
|
358
|
-
server.watcher.add(dir);
|
|
359
|
-
}
|
|
360
|
-
},
|
|
361
|
-
};
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
/**
|
|
365
|
-
* Generate the translations virtual module for dev mode.
|
|
366
|
-
* Uses import.meta.glob where possible for HMR support.
|
|
367
|
-
* Uses fetch() for files in public/ directory (with fs fallback for SSR).
|
|
368
|
-
*/
|
|
369
|
-
function generateDevTranslationsModule(
|
|
370
|
-
translationInfo: Map<string, TranslationInfo>,
|
|
371
|
-
projectRoot: string,
|
|
372
|
-
pathBasedNamespacing: boolean
|
|
373
|
-
): string {
|
|
374
|
-
const imports: string[] = [];
|
|
375
|
-
const loaderEntries: string[] = [];
|
|
376
|
-
let needsPublicLoader = false;
|
|
377
|
-
|
|
378
|
-
// Add deepMerge inline for runtime merging
|
|
379
|
-
imports.push(getDeepMergeCode());
|
|
380
|
-
|
|
381
|
-
// Add namespace wrapper if needed
|
|
382
|
-
if (pathBasedNamespacing) {
|
|
383
|
-
imports.push(generateNamespaceWrapperCode());
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
for (const [locale, info] of translationInfo) {
|
|
387
|
-
if (info.files.length === 0) {
|
|
388
|
-
// No files - return empty object
|
|
389
|
-
loaderEntries.push(` ${JSON.stringify(locale)}: async () => ({})`);
|
|
390
|
-
} else if (info.isPublic) {
|
|
391
|
-
// Public directory files - use fetch in browser, fs in SSR
|
|
392
|
-
needsPublicLoader = true;
|
|
393
|
-
if (pathBasedNamespacing && info.localeBaseDir) {
|
|
394
|
-
const fileEntries = info.files.map(f => {
|
|
395
|
-
const url = toPublicUrl(f, projectRoot);
|
|
396
|
-
const absolutePath = f.replace(/\\/g, '/');
|
|
397
|
-
const namespace = getNamespaceFromPath(f, info.localeBaseDir!);
|
|
398
|
-
return `{ url: ${JSON.stringify(url)}, path: ${JSON.stringify(absolutePath)}, namespace: ${JSON.stringify(namespace)} }`;
|
|
399
|
-
});
|
|
400
|
-
|
|
401
|
-
loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
|
|
402
|
-
const fileInfos = [${fileEntries.join(', ')}];
|
|
403
|
-
const responses = await Promise.all(fileInfos.map(f => __loadPublicJson(f.url, f.path)));
|
|
404
|
-
const wrapped = responses.map((content, i) => __wrapWithNamespace(fileInfos[i].namespace, content));
|
|
405
|
-
return __deepMerge({}, ...wrapped);
|
|
406
|
-
}`);
|
|
407
|
-
} else {
|
|
408
|
-
const fileEntries = info.files.map(f => {
|
|
409
|
-
const url = toPublicUrl(f, projectRoot);
|
|
410
|
-
const absolutePath = f.replace(/\\/g, '/');
|
|
411
|
-
return `{ url: ${JSON.stringify(url)}, path: ${JSON.stringify(absolutePath)} }`;
|
|
412
|
-
});
|
|
413
|
-
loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
|
|
414
|
-
const files = [${fileEntries.join(', ')}];
|
|
415
|
-
const responses = await Promise.all(files.map(f => __loadPublicJson(f.url, f.path)));
|
|
416
|
-
if (responses.length === 1) return responses[0];
|
|
417
|
-
return __deepMerge({}, ...responses);
|
|
418
|
-
}`);
|
|
419
|
-
}
|
|
420
|
-
} else if (info.globPattern && pathBasedNamespacing && info.localeBaseDir) {
|
|
421
|
-
// Use import.meta.glob with namespace wrapping
|
|
422
|
-
const varName = `__${locale}Modules`;
|
|
423
|
-
const localeBaseDirForNs = getLocaleBaseDirForNamespace(info.localeBaseDir, projectRoot);
|
|
424
|
-
imports.push(
|
|
425
|
-
`const ${varName} = import.meta.glob(${JSON.stringify(info.globPattern)}, { eager: true, import: 'default' });`
|
|
426
|
-
);
|
|
427
|
-
|
|
428
|
-
loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
|
|
429
|
-
const entries = Object.entries(${varName});
|
|
430
|
-
if (entries.length === 0) return {};
|
|
431
|
-
const localeBaseDir = ${JSON.stringify(localeBaseDirForNs)};
|
|
432
|
-
const wrapped = entries.map(([filePath, content]) => {
|
|
433
|
-
// Extract relative path from locale base dir - filePath starts with /
|
|
434
|
-
const normalizedPath = filePath.startsWith('/') ? filePath.slice(1) : filePath;
|
|
435
|
-
const relativePath = normalizedPath.replace(localeBaseDir + '/', '').replace(/\\.json$/i, '');
|
|
436
|
-
const namespace = relativePath.replace(/[\\/]/g, '.').replace(/\\.index$/, '');
|
|
437
|
-
return __wrapWithNamespace(namespace, content);
|
|
438
|
-
});
|
|
439
|
-
return __deepMerge({}, ...wrapped);
|
|
440
|
-
}`);
|
|
441
|
-
} else if (info.globPattern) {
|
|
442
|
-
// Use import.meta.glob without namespace wrapping
|
|
443
|
-
const varName = `__${locale}Modules`;
|
|
444
|
-
imports.push(
|
|
445
|
-
`const ${varName} = import.meta.glob(${JSON.stringify(info.globPattern)}, { eager: true, import: 'default' });`
|
|
446
|
-
);
|
|
447
|
-
|
|
448
|
-
loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
|
|
449
|
-
const modules = Object.values(${varName});
|
|
450
|
-
if (modules.length === 0) return {};
|
|
451
|
-
if (modules.length === 1) return modules[0];
|
|
452
|
-
return __deepMerge({}, ...modules);
|
|
453
|
-
}`);
|
|
454
|
-
} else if (info.files.length === 1) {
|
|
455
|
-
// Single file - use import
|
|
456
|
-
const relativePath = toRelativeImport(info.files[0], projectRoot);
|
|
457
|
-
if (pathBasedNamespacing && info.localeBaseDir) {
|
|
458
|
-
const namespace = getNamespaceFromPath(info.files[0], info.localeBaseDir);
|
|
459
|
-
loaderEntries.push(
|
|
460
|
-
` ${JSON.stringify(locale)}: () => import(${JSON.stringify(relativePath)}).then(m => __wrapWithNamespace(${JSON.stringify(namespace)}, m.default ?? m))`
|
|
461
|
-
);
|
|
462
|
-
} else {
|
|
463
|
-
loaderEntries.push(
|
|
464
|
-
` ${JSON.stringify(locale)}: () => import(${JSON.stringify(relativePath)}).then(m => m.default ?? m)`
|
|
465
|
-
);
|
|
466
|
-
}
|
|
467
|
-
} else {
|
|
468
|
-
// Multiple explicit files - import all and merge
|
|
469
|
-
if (pathBasedNamespacing && info.localeBaseDir) {
|
|
470
|
-
const fileEntries = info.files.map(f => {
|
|
471
|
-
const relativePath = toRelativeImport(f, projectRoot);
|
|
472
|
-
const namespace = getNamespaceFromPath(f, info.localeBaseDir!);
|
|
473
|
-
return `{ path: ${JSON.stringify(relativePath)}, namespace: ${JSON.stringify(namespace)} }`;
|
|
474
|
-
});
|
|
475
|
-
|
|
476
|
-
loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
|
|
477
|
-
const fileInfos = [${fileEntries.join(', ')}];
|
|
478
|
-
const modules = await Promise.all(fileInfos.map(f => import(/* @vite-ignore */ f.path)));
|
|
479
|
-
const wrapped = modules.map((m, i) => __wrapWithNamespace(fileInfos[i].namespace, m.default ?? m));
|
|
480
|
-
return __deepMerge({}, ...wrapped);
|
|
481
|
-
}`);
|
|
482
|
-
} else {
|
|
483
|
-
const fileImports = info.files
|
|
484
|
-
.map(f => `import(${JSON.stringify(toRelativeImport(f, projectRoot))})`)
|
|
485
|
-
.join(', ');
|
|
486
|
-
|
|
487
|
-
loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
|
|
488
|
-
const modules = await Promise.all([${fileImports}]);
|
|
489
|
-
const contents = modules.map(m => m.default ?? m);
|
|
490
|
-
if (contents.length === 1) return contents[0];
|
|
491
|
-
return __deepMerge({}, ...contents);
|
|
492
|
-
}`);
|
|
493
|
-
}
|
|
494
|
-
}
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
// Add public loader helper if needed
|
|
498
|
-
if (needsPublicLoader) {
|
|
499
|
-
imports.push(getPublicLoaderCode());
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
return `
|
|
503
|
-
${imports.join('\n')}
|
|
504
|
-
|
|
505
|
-
export const translationLoaders = {
|
|
506
|
-
${loaderEntries.join(',\n')}
|
|
507
|
-
};
|
|
508
|
-
|
|
509
|
-
export async function loadTranslations(locale) {
|
|
510
|
-
const loader = translationLoaders[locale];
|
|
511
|
-
if (!loader) {
|
|
512
|
-
if (import.meta.env.DEV) {
|
|
513
|
-
console.warn('[ez-i18n] No translations configured for locale:', locale);
|
|
514
|
-
}
|
|
515
|
-
return {};
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
try {
|
|
519
|
-
return await loader();
|
|
520
|
-
} catch (error) {
|
|
521
|
-
if (import.meta.env.DEV) {
|
|
522
|
-
console.error('[ez-i18n] Failed to load translations for locale:', locale, error);
|
|
523
|
-
}
|
|
524
|
-
return {};
|
|
525
|
-
}
|
|
526
|
-
}
|
|
527
|
-
`;
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
/**
|
|
531
|
-
* Generate the translations virtual module for production builds.
|
|
532
|
-
* Pre-resolves all imports for optimal bundling.
|
|
533
|
-
* Uses fetch() for files in public/ directory (with fs fallback for SSR).
|
|
534
|
-
*/
|
|
535
|
-
function generateBuildTranslationsModule(
|
|
536
|
-
translationInfo: Map<string, TranslationInfo>,
|
|
537
|
-
projectRoot: string,
|
|
538
|
-
pathBasedNamespacing: boolean
|
|
539
|
-
): string {
|
|
540
|
-
const loaderEntries: string[] = [];
|
|
541
|
-
let needsDeepMerge = false;
|
|
542
|
-
let needsNamespaceWrapper = false;
|
|
543
|
-
let needsPublicLoader = false;
|
|
544
|
-
|
|
545
|
-
for (const [locale, info] of translationInfo) {
|
|
546
|
-
if (info.files.length === 0) {
|
|
547
|
-
loaderEntries.push(` ${JSON.stringify(locale)}: async () => ({})`);
|
|
548
|
-
} else if (info.isPublic) {
|
|
549
|
-
// Public directory files - use fetch in browser, fs in SSR
|
|
550
|
-
needsPublicLoader = true;
|
|
551
|
-
needsDeepMerge = info.files.length > 1;
|
|
552
|
-
if (pathBasedNamespacing && info.localeBaseDir) {
|
|
553
|
-
needsNamespaceWrapper = true;
|
|
554
|
-
const fileEntries = info.files.map(f => {
|
|
555
|
-
const url = toPublicUrl(f, projectRoot);
|
|
556
|
-
const absolutePath = f.replace(/\\/g, '/');
|
|
557
|
-
const namespace = getNamespaceFromPath(f, info.localeBaseDir!);
|
|
558
|
-
return `{ url: ${JSON.stringify(url)}, path: ${JSON.stringify(absolutePath)}, namespace: ${JSON.stringify(namespace)} }`;
|
|
559
|
-
});
|
|
560
|
-
|
|
561
|
-
loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
|
|
562
|
-
const fileInfos = [${fileEntries.join(', ')}];
|
|
563
|
-
const responses = await Promise.all(fileInfos.map(f => __loadPublicJson(f.url, f.path)));
|
|
564
|
-
const wrapped = responses.map((content, i) => __wrapWithNamespace(fileInfos[i].namespace, content));
|
|
565
|
-
return __deepMerge({}, ...wrapped);
|
|
566
|
-
}`);
|
|
567
|
-
} else {
|
|
568
|
-
const fileEntries = info.files.map(f => {
|
|
569
|
-
const url = toPublicUrl(f, projectRoot);
|
|
570
|
-
const absolutePath = f.replace(/\\/g, '/');
|
|
571
|
-
return `{ url: ${JSON.stringify(url)}, path: ${JSON.stringify(absolutePath)} }`;
|
|
572
|
-
});
|
|
573
|
-
if (fileEntries.length === 1) {
|
|
574
|
-
const f = info.files[0];
|
|
575
|
-
const url = toPublicUrl(f, projectRoot);
|
|
576
|
-
const absolutePath = f.replace(/\\/g, '/');
|
|
577
|
-
loaderEntries.push(` ${JSON.stringify(locale)}: () => __loadPublicJson(${JSON.stringify(url)}, ${JSON.stringify(absolutePath)})`);
|
|
578
|
-
} else {
|
|
579
|
-
loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
|
|
580
|
-
const files = [${fileEntries.join(', ')}];
|
|
581
|
-
const responses = await Promise.all(files.map(f => __loadPublicJson(f.url, f.path)));
|
|
582
|
-
return __deepMerge({}, ...responses);
|
|
583
|
-
}`);
|
|
584
|
-
}
|
|
585
|
-
}
|
|
586
|
-
} else if (info.files.length === 1) {
|
|
587
|
-
// Single file - use import
|
|
588
|
-
const relativePath = toRelativeImport(info.files[0], projectRoot);
|
|
589
|
-
if (pathBasedNamespacing && info.localeBaseDir) {
|
|
590
|
-
needsNamespaceWrapper = true;
|
|
591
|
-
const namespace = getNamespaceFromPath(info.files[0], info.localeBaseDir);
|
|
592
|
-
loaderEntries.push(
|
|
593
|
-
` ${JSON.stringify(locale)}: () => import(${JSON.stringify(relativePath)}).then(m => __wrapWithNamespace(${JSON.stringify(namespace)}, m.default ?? m))`
|
|
594
|
-
);
|
|
595
|
-
} else {
|
|
596
|
-
loaderEntries.push(
|
|
597
|
-
` ${JSON.stringify(locale)}: () => import(${JSON.stringify(relativePath)}).then(m => m.default ?? m)`
|
|
598
|
-
);
|
|
599
|
-
}
|
|
600
|
-
} else {
|
|
601
|
-
// Multiple files - import and merge
|
|
602
|
-
needsDeepMerge = true;
|
|
603
|
-
|
|
604
|
-
if (pathBasedNamespacing && info.localeBaseDir) {
|
|
605
|
-
needsNamespaceWrapper = true;
|
|
606
|
-
const fileEntries = info.files.map(f => {
|
|
607
|
-
const relativePath = toRelativeImport(f, projectRoot);
|
|
608
|
-
const namespace = getNamespaceFromPath(f, info.localeBaseDir!);
|
|
609
|
-
return `{ path: ${JSON.stringify(relativePath)}, namespace: ${JSON.stringify(namespace)} }`;
|
|
610
|
-
});
|
|
611
|
-
|
|
612
|
-
loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
|
|
613
|
-
const fileInfos = [${fileEntries.join(', ')}];
|
|
614
|
-
const modules = await Promise.all(fileInfos.map(f => import(/* @vite-ignore */ f.path)));
|
|
615
|
-
const wrapped = modules.map((m, i) => __wrapWithNamespace(fileInfos[i].namespace, m.default ?? m));
|
|
616
|
-
return __deepMerge({}, ...wrapped);
|
|
617
|
-
}`);
|
|
618
|
-
} else {
|
|
619
|
-
const fileImports = info.files
|
|
620
|
-
.map(f => `import(${JSON.stringify(toRelativeImport(f, projectRoot))})`)
|
|
621
|
-
.join(', ');
|
|
622
|
-
|
|
623
|
-
loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
|
|
624
|
-
const modules = await Promise.all([${fileImports}]);
|
|
625
|
-
const contents = modules.map(m => m.default ?? m);
|
|
626
|
-
return __deepMerge({}, ...contents);
|
|
627
|
-
}`);
|
|
628
|
-
}
|
|
629
|
-
}
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
const helperCode = [
|
|
633
|
-
needsDeepMerge ? getDeepMergeCode() : '',
|
|
634
|
-
needsNamespaceWrapper ? generateNamespaceWrapperCode() : '',
|
|
635
|
-
needsPublicLoader ? getPublicLoaderCode() : '',
|
|
636
|
-
].filter(Boolean).join('\n');
|
|
637
|
-
|
|
638
|
-
return `
|
|
639
|
-
${helperCode}
|
|
640
|
-
|
|
641
|
-
export const translationLoaders = {
|
|
642
|
-
${loaderEntries.join(',\n')}
|
|
643
|
-
};
|
|
644
|
-
|
|
645
|
-
export async function loadTranslations(locale) {
|
|
646
|
-
const loader = translationLoaders[locale];
|
|
647
|
-
if (!loader) {
|
|
648
|
-
return {};
|
|
649
|
-
}
|
|
650
|
-
|
|
651
|
-
try {
|
|
652
|
-
return await loader();
|
|
653
|
-
} catch (error) {
|
|
654
|
-
console.error('[ez-i18n] Failed to load translations:', locale, error);
|
|
655
|
-
return {};
|
|
656
|
-
}
|
|
657
|
-
}
|
|
658
|
-
`;
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
/**
|
|
662
|
-
* Inline deepMerge function for the virtual module
|
|
663
|
-
*/
|
|
664
|
-
function getDeepMergeCode(): string {
|
|
665
|
-
return `
|
|
666
|
-
function __deepMerge(target, ...sources) {
|
|
667
|
-
const FORBIDDEN = new Set(['__proto__', 'constructor', 'prototype']);
|
|
668
|
-
const result = { ...target };
|
|
669
|
-
for (const source of sources) {
|
|
670
|
-
if (!source || typeof source !== 'object') continue;
|
|
671
|
-
for (const key of Object.keys(source)) {
|
|
672
|
-
if (FORBIDDEN.has(key)) continue;
|
|
673
|
-
const tv = result[key], sv = source[key];
|
|
674
|
-
if (sv && typeof sv === 'object' && !Array.isArray(sv) && tv && typeof tv === 'object' && !Array.isArray(tv)) {
|
|
675
|
-
result[key] = __deepMerge(tv, sv);
|
|
676
|
-
} else {
|
|
677
|
-
result[key] = sv;
|
|
678
|
-
}
|
|
679
|
-
}
|
|
680
|
-
}
|
|
681
|
-
return result;
|
|
682
|
-
}`;
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
/**
|
|
686
|
-
* Inline public JSON loader for the virtual module.
|
|
687
|
-
* Uses fetch in browser, fs.readFileSync in SSR/Node.
|
|
688
|
-
*/
|
|
689
|
-
function getPublicLoaderCode(): string {
|
|
690
|
-
return `
|
|
691
|
-
async function __loadPublicJson(url, absolutePath) {
|
|
692
|
-
if (typeof window !== 'undefined') {
|
|
693
|
-
// Browser - use fetch with relative URL
|
|
694
|
-
return fetch(url).then(r => r.json());
|
|
695
|
-
} else {
|
|
696
|
-
// SSR/Node - read from filesystem
|
|
697
|
-
const fs = await import('node:fs');
|
|
698
|
-
const content = fs.readFileSync(absolutePath, 'utf-8');
|
|
699
|
-
return JSON.parse(content);
|
|
700
|
-
}
|
|
701
|
-
}`;
|
|
702
|
-
}
|
|
703
|
-
|
|
704
|
-
// Re-export resolveConfig for backwards compatibility
|
|
705
|
-
export function resolveConfig(config: EzI18nConfig): ResolvedEzI18nConfig {
|
|
706
|
-
// This is now a simplified version - full resolution happens in buildStart
|
|
707
|
-
const isAutoDiscovery = !config.translations || typeof config.translations === 'string';
|
|
708
|
-
return {
|
|
709
|
-
locales: config.locales || [],
|
|
710
|
-
defaultLocale: config.defaultLocale,
|
|
711
|
-
cookieName: config.cookieName ?? 'ez-locale',
|
|
712
|
-
translations: {},
|
|
713
|
-
pathBasedNamespacing: config.pathBasedNamespacing ?? isAutoDiscovery,
|
|
714
|
-
localeBaseDirs: {},
|
|
715
|
-
};
|
|
716
|
-
}
|