@zachhandley/ez-i18n 0.1.2 → 0.1.5
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 +125 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +516 -24
- package/dist/runtime/react-plugin.d.ts +1 -1
- package/dist/runtime/vue-plugin.d.ts +1 -1
- package/dist/types-Cd9e7Lkc.d.ts +98 -0
- package/dist/utils/index.d.ts +85 -0
- package/dist/utils/index.js +220 -0
- package/package.json +8 -1
- package/src/types.ts +74 -12
- package/src/utils/index.ts +16 -0
- package/src/utils/translations.ts +378 -0
- package/src/vite-plugin.ts +471 -29
- package/dist/types-DwCG8sp8.d.ts +0 -48
package/dist/index.js
CHANGED
|
@@ -1,21 +1,294 @@
|
|
|
1
|
+
// src/utils/translations.ts
|
|
2
|
+
import { glob } from "tinyglobby";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
import * as fs from "fs";
|
|
5
|
+
var CACHE_FILE = ".ez-i18n.json";
|
|
6
|
+
var CACHE_VERSION = 1;
|
|
7
|
+
var DEFAULT_I18N_DIR = "./public/i18n";
|
|
8
|
+
function detectPathType(input) {
|
|
9
|
+
if (Array.isArray(input)) return "array";
|
|
10
|
+
if (input.includes("*")) return "glob";
|
|
11
|
+
if (input.endsWith("/") || input.endsWith(path.sep)) return "folder";
|
|
12
|
+
return "file";
|
|
13
|
+
}
|
|
14
|
+
function isDirectory(filePath) {
|
|
15
|
+
try {
|
|
16
|
+
return fs.statSync(filePath).isDirectory();
|
|
17
|
+
} catch {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
async function resolveTranslationPaths(input, projectRoot) {
|
|
22
|
+
const type = detectPathType(input);
|
|
23
|
+
let files = [];
|
|
24
|
+
switch (type) {
|
|
25
|
+
case "array":
|
|
26
|
+
for (const entry of input) {
|
|
27
|
+
const resolved = await resolveTranslationPaths(entry, projectRoot);
|
|
28
|
+
files.push(...resolved);
|
|
29
|
+
}
|
|
30
|
+
break;
|
|
31
|
+
case "glob":
|
|
32
|
+
files = await glob(input, {
|
|
33
|
+
cwd: projectRoot,
|
|
34
|
+
absolute: true
|
|
35
|
+
});
|
|
36
|
+
break;
|
|
37
|
+
case "folder": {
|
|
38
|
+
const folderPath = path.resolve(projectRoot, input.replace(/\/$/, ""));
|
|
39
|
+
files = await glob("**/*.json", {
|
|
40
|
+
cwd: folderPath,
|
|
41
|
+
absolute: true
|
|
42
|
+
});
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
case "file":
|
|
46
|
+
default: {
|
|
47
|
+
const filePath = path.resolve(projectRoot, input);
|
|
48
|
+
if (isDirectory(filePath)) {
|
|
49
|
+
files = await glob("**/*.json", {
|
|
50
|
+
cwd: filePath,
|
|
51
|
+
absolute: true
|
|
52
|
+
});
|
|
53
|
+
} else {
|
|
54
|
+
files = [filePath];
|
|
55
|
+
}
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return [...new Set(files)].sort((a, b) => a.localeCompare(b));
|
|
60
|
+
}
|
|
61
|
+
async function autoDiscoverTranslations(baseDir, projectRoot, configuredLocales) {
|
|
62
|
+
const absoluteBaseDir = path.resolve(projectRoot, baseDir.replace(/\/$/, ""));
|
|
63
|
+
if (!isDirectory(absoluteBaseDir)) {
|
|
64
|
+
console.warn(`[ez-i18n] Translation directory not found: ${absoluteBaseDir}`);
|
|
65
|
+
return { locales: configuredLocales || [], translations: {} };
|
|
66
|
+
}
|
|
67
|
+
const translations = {};
|
|
68
|
+
const discoveredLocales = [];
|
|
69
|
+
const entries = fs.readdirSync(absoluteBaseDir, { withFileTypes: true });
|
|
70
|
+
for (const entry of entries) {
|
|
71
|
+
if (entry.isDirectory()) {
|
|
72
|
+
const locale = entry.name;
|
|
73
|
+
if (configuredLocales && configuredLocales.length > 0) {
|
|
74
|
+
if (!configuredLocales.includes(locale)) continue;
|
|
75
|
+
}
|
|
76
|
+
const localePath = path.join(absoluteBaseDir, locale);
|
|
77
|
+
const files = await glob("**/*.json", {
|
|
78
|
+
cwd: localePath,
|
|
79
|
+
absolute: true
|
|
80
|
+
});
|
|
81
|
+
if (files.length > 0) {
|
|
82
|
+
discoveredLocales.push(locale);
|
|
83
|
+
translations[locale] = files.sort((a, b) => a.localeCompare(b));
|
|
84
|
+
}
|
|
85
|
+
} else if (entry.isFile() && entry.name.endsWith(".json")) {
|
|
86
|
+
const locale = path.basename(entry.name, ".json");
|
|
87
|
+
if (configuredLocales && configuredLocales.length > 0) {
|
|
88
|
+
if (!configuredLocales.includes(locale)) continue;
|
|
89
|
+
}
|
|
90
|
+
const filePath = path.join(absoluteBaseDir, entry.name);
|
|
91
|
+
if (!translations[locale]) {
|
|
92
|
+
discoveredLocales.push(locale);
|
|
93
|
+
translations[locale] = [];
|
|
94
|
+
}
|
|
95
|
+
translations[locale].push(filePath);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const sortedLocales = [...new Set(discoveredLocales)].sort();
|
|
99
|
+
return { locales: sortedLocales, translations };
|
|
100
|
+
}
|
|
101
|
+
async function resolveTranslationsConfig(config, projectRoot, configuredLocales) {
|
|
102
|
+
if (!config) {
|
|
103
|
+
return autoDiscoverTranslations(DEFAULT_I18N_DIR, projectRoot, configuredLocales);
|
|
104
|
+
}
|
|
105
|
+
if (typeof config === "string") {
|
|
106
|
+
return autoDiscoverTranslations(config, projectRoot, configuredLocales);
|
|
107
|
+
}
|
|
108
|
+
const translations = {};
|
|
109
|
+
const locales = Object.keys(config);
|
|
110
|
+
for (const [locale, localePath] of Object.entries(config)) {
|
|
111
|
+
translations[locale] = await resolveTranslationPaths(localePath, projectRoot);
|
|
112
|
+
}
|
|
113
|
+
return { locales, translations };
|
|
114
|
+
}
|
|
115
|
+
function loadCache(projectRoot) {
|
|
116
|
+
const cachePath = path.join(projectRoot, CACHE_FILE);
|
|
117
|
+
try {
|
|
118
|
+
if (!fs.existsSync(cachePath)) return null;
|
|
119
|
+
const content = fs.readFileSync(cachePath, "utf-8");
|
|
120
|
+
const cache = JSON.parse(content);
|
|
121
|
+
if (cache.version !== CACHE_VERSION) return null;
|
|
122
|
+
return cache;
|
|
123
|
+
} catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
function saveCache(projectRoot, discovered) {
|
|
128
|
+
const cachePath = path.join(projectRoot, CACHE_FILE);
|
|
129
|
+
const cache = {
|
|
130
|
+
version: CACHE_VERSION,
|
|
131
|
+
discovered,
|
|
132
|
+
lastScan: (/* @__PURE__ */ new Date()).toISOString()
|
|
133
|
+
};
|
|
134
|
+
try {
|
|
135
|
+
fs.writeFileSync(cachePath, JSON.stringify(cache, null, 2));
|
|
136
|
+
} catch (error) {
|
|
137
|
+
console.warn("[ez-i18n] Failed to write cache file:", error);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
function isCacheValid(cache, projectRoot) {
|
|
141
|
+
for (const files of Object.values(cache.discovered)) {
|
|
142
|
+
for (const file of files) {
|
|
143
|
+
if (!fs.existsSync(file)) return false;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
function toRelativeImport(absolutePath, projectRoot) {
|
|
149
|
+
const relativePath = path.relative(projectRoot, absolutePath);
|
|
150
|
+
const normalized = relativePath.replace(/\\/g, "/");
|
|
151
|
+
return normalized.startsWith(".") ? normalized : "./" + normalized;
|
|
152
|
+
}
|
|
153
|
+
function toGlobPattern(baseDir, projectRoot) {
|
|
154
|
+
const relativePath = path.relative(projectRoot, baseDir).replace(/\\/g, "/");
|
|
155
|
+
const normalized = relativePath.startsWith(".") ? relativePath : "./" + relativePath;
|
|
156
|
+
return `${normalized}/**/*.json`;
|
|
157
|
+
}
|
|
158
|
+
function getNamespaceFromPath(filePath, localeDir) {
|
|
159
|
+
const relative2 = path.relative(localeDir, filePath);
|
|
160
|
+
const withoutExt = relative2.replace(/\.json$/i, "");
|
|
161
|
+
const namespace = withoutExt.replace(/[\\/]/g, ".");
|
|
162
|
+
return namespace.replace(/\.index$/, "");
|
|
163
|
+
}
|
|
164
|
+
function generateNamespaceWrapperCode() {
|
|
165
|
+
return `
|
|
166
|
+
function __wrapWithNamespace(namespace, content) {
|
|
167
|
+
if (!namespace) return content;
|
|
168
|
+
const parts = namespace.split('.');
|
|
169
|
+
let result = content;
|
|
170
|
+
for (let i = parts.length - 1; i >= 0; i--) {
|
|
171
|
+
result = { [parts[i]]: result };
|
|
172
|
+
}
|
|
173
|
+
return result;
|
|
174
|
+
}`;
|
|
175
|
+
}
|
|
176
|
+
|
|
1
177
|
// src/vite-plugin.ts
|
|
178
|
+
import * as path2 from "path";
|
|
2
179
|
var VIRTUAL_CONFIG = "ez-i18n:config";
|
|
3
180
|
var VIRTUAL_RUNTIME = "ez-i18n:runtime";
|
|
4
181
|
var VIRTUAL_TRANSLATIONS = "ez-i18n:translations";
|
|
5
182
|
var RESOLVED_PREFIX = "\0";
|
|
6
|
-
function resolveConfig(config) {
|
|
7
|
-
return {
|
|
8
|
-
locales: config.locales,
|
|
9
|
-
defaultLocale: config.defaultLocale,
|
|
10
|
-
cookieName: config.cookieName ?? "ez-locale",
|
|
11
|
-
translations: config.translations ?? {}
|
|
12
|
-
};
|
|
13
|
-
}
|
|
14
183
|
function vitePlugin(config) {
|
|
15
|
-
|
|
184
|
+
let viteConfig;
|
|
185
|
+
let isDev = false;
|
|
186
|
+
let resolved;
|
|
187
|
+
let translationInfo = /* @__PURE__ */ new Map();
|
|
16
188
|
return {
|
|
17
189
|
name: "ez-i18n-vite",
|
|
18
190
|
enforce: "pre",
|
|
191
|
+
configResolved(resolvedConfig) {
|
|
192
|
+
viteConfig = resolvedConfig;
|
|
193
|
+
isDev = resolvedConfig.command === "serve";
|
|
194
|
+
},
|
|
195
|
+
async buildStart() {
|
|
196
|
+
const projectRoot = viteConfig.root;
|
|
197
|
+
const isAutoDiscovery = !config.translations || typeof config.translations === "string";
|
|
198
|
+
const pathBasedNamespacing = config.pathBasedNamespacing ?? isAutoDiscovery;
|
|
199
|
+
const translationsBaseDir = typeof config.translations === "string" ? path2.resolve(projectRoot, config.translations.replace(/\/$/, "")) : path2.resolve(projectRoot, "./public/i18n");
|
|
200
|
+
let useCache = false;
|
|
201
|
+
if (!isDev) {
|
|
202
|
+
const cache = loadCache(projectRoot);
|
|
203
|
+
if (cache && isCacheValid(cache, projectRoot)) {
|
|
204
|
+
const localeBaseDirs = {};
|
|
205
|
+
for (const locale of Object.keys(cache.discovered)) {
|
|
206
|
+
localeBaseDirs[locale] = path2.join(translationsBaseDir, locale);
|
|
207
|
+
}
|
|
208
|
+
resolved = {
|
|
209
|
+
locales: config.locales || Object.keys(cache.discovered),
|
|
210
|
+
defaultLocale: config.defaultLocale,
|
|
211
|
+
cookieName: config.cookieName ?? "ez-locale",
|
|
212
|
+
translations: cache.discovered,
|
|
213
|
+
pathBasedNamespacing,
|
|
214
|
+
localeBaseDirs
|
|
215
|
+
};
|
|
216
|
+
useCache = true;
|
|
217
|
+
for (const [locale, files] of Object.entries(cache.discovered)) {
|
|
218
|
+
translationInfo.set(locale, {
|
|
219
|
+
locale,
|
|
220
|
+
files,
|
|
221
|
+
localeBaseDir: localeBaseDirs[locale]
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (!useCache) {
|
|
227
|
+
const { locales, translations } = await resolveTranslationsConfig(
|
|
228
|
+
config.translations,
|
|
229
|
+
projectRoot,
|
|
230
|
+
config.locales
|
|
231
|
+
);
|
|
232
|
+
const finalLocales = config.locales && config.locales.length > 0 ? config.locales : locales;
|
|
233
|
+
const localeBaseDirs = {};
|
|
234
|
+
for (const locale of finalLocales) {
|
|
235
|
+
if (isAutoDiscovery) {
|
|
236
|
+
localeBaseDirs[locale] = path2.join(translationsBaseDir, locale);
|
|
237
|
+
} else if (typeof config.translations === "object" && config.translations[locale]) {
|
|
238
|
+
const localeConfig = config.translations[locale];
|
|
239
|
+
if (typeof localeConfig === "string") {
|
|
240
|
+
const pathType = detectPathType(localeConfig);
|
|
241
|
+
if (pathType === "folder" || pathType === "file") {
|
|
242
|
+
const resolved2 = path2.resolve(projectRoot, localeConfig.replace(/\/$/, ""));
|
|
243
|
+
localeBaseDirs[locale] = pathType === "folder" ? resolved2 : path2.dirname(resolved2);
|
|
244
|
+
} else {
|
|
245
|
+
const baseDir = localeConfig.split("*")[0].replace(/\/$/, "");
|
|
246
|
+
localeBaseDirs[locale] = path2.resolve(projectRoot, baseDir);
|
|
247
|
+
}
|
|
248
|
+
} else {
|
|
249
|
+
localeBaseDirs[locale] = translationsBaseDir;
|
|
250
|
+
}
|
|
251
|
+
} else {
|
|
252
|
+
localeBaseDirs[locale] = translationsBaseDir;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
resolved = {
|
|
256
|
+
locales: finalLocales,
|
|
257
|
+
defaultLocale: config.defaultLocale,
|
|
258
|
+
cookieName: config.cookieName ?? "ez-locale",
|
|
259
|
+
translations,
|
|
260
|
+
pathBasedNamespacing,
|
|
261
|
+
localeBaseDirs
|
|
262
|
+
};
|
|
263
|
+
for (const locale of finalLocales) {
|
|
264
|
+
const files = translations[locale] || [];
|
|
265
|
+
const info = {
|
|
266
|
+
locale,
|
|
267
|
+
files,
|
|
268
|
+
localeBaseDir: localeBaseDirs[locale]
|
|
269
|
+
};
|
|
270
|
+
if (isDev && config.translations) {
|
|
271
|
+
const localeConfig = typeof config.translations === "string" ? path2.join(config.translations, locale) : config.translations[locale];
|
|
272
|
+
if (localeConfig && typeof localeConfig === "string") {
|
|
273
|
+
const pathType = detectPathType(localeConfig);
|
|
274
|
+
if (pathType === "folder" || pathType === "glob") {
|
|
275
|
+
const basePath = pathType === "glob" ? localeConfig : toGlobPattern(path2.resolve(projectRoot, localeConfig), projectRoot);
|
|
276
|
+
info.globPattern = basePath;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
translationInfo.set(locale, info);
|
|
281
|
+
}
|
|
282
|
+
if (!isDev && Object.keys(translations).length > 0) {
|
|
283
|
+
saveCache(projectRoot, translations);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
if (!resolved.locales.includes(resolved.defaultLocale)) {
|
|
287
|
+
console.warn(
|
|
288
|
+
`[ez-i18n] defaultLocale "${resolved.defaultLocale}" not found in locales: [${resolved.locales.join(", ")}]`
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
},
|
|
19
292
|
resolveId(id) {
|
|
20
293
|
if (id === VIRTUAL_CONFIG || id === VIRTUAL_RUNTIME || id === VIRTUAL_TRANSLATIONS) {
|
|
21
294
|
return RESOLVED_PREFIX + id;
|
|
@@ -81,21 +354,143 @@ export function t(key, params) {
|
|
|
81
354
|
`;
|
|
82
355
|
}
|
|
83
356
|
if (id === RESOLVED_PREFIX + VIRTUAL_TRANSLATIONS) {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
357
|
+
return isDev ? generateDevTranslationsModule(translationInfo, viteConfig.root, resolved.pathBasedNamespacing) : generateBuildTranslationsModule(translationInfo, viteConfig.root, resolved.pathBasedNamespacing);
|
|
358
|
+
}
|
|
359
|
+
return null;
|
|
360
|
+
},
|
|
361
|
+
// HMR support for dev mode
|
|
362
|
+
handleHotUpdate({ file, server }) {
|
|
363
|
+
if (!isDev) return;
|
|
364
|
+
for (const info of translationInfo.values()) {
|
|
365
|
+
if (info.files.includes(file)) {
|
|
366
|
+
const mod = server.moduleGraph.getModuleById(RESOLVED_PREFIX + VIRTUAL_TRANSLATIONS);
|
|
367
|
+
if (mod) {
|
|
368
|
+
server.moduleGraph.invalidateModule(mod);
|
|
369
|
+
server.ws.send({
|
|
370
|
+
type: "full-reload",
|
|
371
|
+
path: "*"
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
break;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
},
|
|
378
|
+
configureServer(server) {
|
|
379
|
+
const watchedDirs = /* @__PURE__ */ new Set();
|
|
380
|
+
if (config.translations) {
|
|
381
|
+
if (typeof config.translations === "string") {
|
|
382
|
+
watchedDirs.add(path2.resolve(viteConfig.root, config.translations));
|
|
383
|
+
} else {
|
|
384
|
+
for (const localePath of Object.values(config.translations)) {
|
|
385
|
+
if (typeof localePath === "string") {
|
|
386
|
+
const pathType = detectPathType(localePath);
|
|
387
|
+
if (pathType === "folder") {
|
|
388
|
+
watchedDirs.add(path2.resolve(viteConfig.root, localePath));
|
|
389
|
+
} else if (pathType === "glob") {
|
|
390
|
+
const baseDir = localePath.split("*")[0].replace(/\/$/, "");
|
|
391
|
+
if (baseDir) {
|
|
392
|
+
watchedDirs.add(path2.resolve(viteConfig.root, baseDir));
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
} else if (Array.isArray(localePath)) {
|
|
396
|
+
for (const file of localePath) {
|
|
397
|
+
const dir = path2.dirname(path2.resolve(viteConfig.root, file));
|
|
398
|
+
watchedDirs.add(dir);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
} else {
|
|
404
|
+
watchedDirs.add(path2.resolve(viteConfig.root, "./public/i18n"));
|
|
405
|
+
}
|
|
406
|
+
for (const dir of watchedDirs) {
|
|
407
|
+
server.watcher.add(dir);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
function generateDevTranslationsModule(translationInfo, projectRoot, pathBasedNamespacing) {
|
|
413
|
+
const imports = [];
|
|
414
|
+
const loaderEntries = [];
|
|
415
|
+
imports.push(getDeepMergeCode());
|
|
416
|
+
if (pathBasedNamespacing) {
|
|
417
|
+
imports.push(generateNamespaceWrapperCode());
|
|
418
|
+
}
|
|
419
|
+
for (const [locale, info] of translationInfo) {
|
|
420
|
+
if (info.files.length === 0) {
|
|
421
|
+
loaderEntries.push(` ${JSON.stringify(locale)}: async () => ({})`);
|
|
422
|
+
} else if (info.globPattern && pathBasedNamespacing && info.localeBaseDir) {
|
|
423
|
+
const varName = `__${locale}Modules`;
|
|
424
|
+
const localeBaseDir = info.localeBaseDir.replace(/\\/g, "/");
|
|
425
|
+
imports.push(
|
|
426
|
+
`const ${varName} = import.meta.glob(${JSON.stringify(info.globPattern)}, { eager: true, import: 'default' });`
|
|
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(localeBaseDir)};
|
|
432
|
+
const wrapped = entries.map(([filePath, content]) => {
|
|
433
|
+
// Extract relative path from locale base dir
|
|
434
|
+
const relativePath = filePath.replace(localeBaseDir + '/', '').replace(/\\.json$/i, '');
|
|
435
|
+
const namespace = relativePath.replace(/[\\/]/g, '.').replace(/\\.index$/, '');
|
|
436
|
+
return __wrapWithNamespace(namespace, content);
|
|
437
|
+
});
|
|
438
|
+
return __deepMerge({}, ...wrapped);
|
|
439
|
+
}`);
|
|
440
|
+
} else if (info.globPattern) {
|
|
441
|
+
const varName = `__${locale}Modules`;
|
|
442
|
+
imports.push(
|
|
443
|
+
`const ${varName} = import.meta.glob(${JSON.stringify(info.globPattern)}, { eager: true, import: 'default' });`
|
|
444
|
+
);
|
|
445
|
+
loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
|
|
446
|
+
const modules = Object.values(${varName});
|
|
447
|
+
if (modules.length === 0) return {};
|
|
448
|
+
if (modules.length === 1) return modules[0];
|
|
449
|
+
return __deepMerge({}, ...modules);
|
|
450
|
+
}`);
|
|
451
|
+
} else if (info.files.length === 1) {
|
|
452
|
+
const relativePath = toRelativeImport(info.files[0], projectRoot);
|
|
453
|
+
if (pathBasedNamespacing && info.localeBaseDir) {
|
|
454
|
+
const namespace = getNamespaceFromPath(info.files[0], info.localeBaseDir);
|
|
455
|
+
loaderEntries.push(
|
|
456
|
+
` ${JSON.stringify(locale)}: () => import(${JSON.stringify(relativePath)}).then(m => __wrapWithNamespace(${JSON.stringify(namespace)}, m.default ?? m))`
|
|
457
|
+
);
|
|
458
|
+
} else {
|
|
459
|
+
loaderEntries.push(
|
|
460
|
+
` ${JSON.stringify(locale)}: () => import(${JSON.stringify(relativePath)}).then(m => m.default ?? m)`
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
} else {
|
|
464
|
+
if (pathBasedNamespacing && info.localeBaseDir) {
|
|
465
|
+
const fileEntries = info.files.map((f) => {
|
|
466
|
+
const relativePath = toRelativeImport(f, projectRoot);
|
|
467
|
+
const namespace = getNamespaceFromPath(f, info.localeBaseDir);
|
|
468
|
+
return `{ path: ${JSON.stringify(relativePath)}, namespace: ${JSON.stringify(namespace)} }`;
|
|
469
|
+
});
|
|
470
|
+
loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
|
|
471
|
+
const fileInfos = [${fileEntries.join(", ")}];
|
|
472
|
+
const modules = await Promise.all(fileInfos.map(f => import(f.path)));
|
|
473
|
+
const wrapped = modules.map((m, i) => __wrapWithNamespace(fileInfos[i].namespace, m.default ?? m));
|
|
474
|
+
return __deepMerge({}, ...wrapped);
|
|
475
|
+
}`);
|
|
476
|
+
} else {
|
|
477
|
+
const fileImports = info.files.map((f) => `import(${JSON.stringify(toRelativeImport(f, projectRoot))})`).join(", ");
|
|
478
|
+
loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
|
|
479
|
+
const modules = await Promise.all([${fileImports}]);
|
|
480
|
+
const contents = modules.map(m => m.default ?? m);
|
|
481
|
+
if (contents.length === 1) return contents[0];
|
|
482
|
+
return __deepMerge({}, ...contents);
|
|
483
|
+
}`);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
return `
|
|
488
|
+
${imports.join("\n")}
|
|
489
|
+
|
|
90
490
|
export const translationLoaders = {
|
|
91
|
-
${loaderEntries}
|
|
491
|
+
${loaderEntries.join(",\n")}
|
|
92
492
|
};
|
|
93
493
|
|
|
94
|
-
/**
|
|
95
|
-
* Load translations for a specific locale
|
|
96
|
-
* @param locale - Locale code to load translations for
|
|
97
|
-
* @returns Translations object or empty object if not found
|
|
98
|
-
*/
|
|
99
494
|
export async function loadTranslations(locale) {
|
|
100
495
|
const loader = translationLoaders[locale];
|
|
101
496
|
if (!loader) {
|
|
@@ -106,8 +501,7 @@ export async function loadTranslations(locale) {
|
|
|
106
501
|
}
|
|
107
502
|
|
|
108
503
|
try {
|
|
109
|
-
|
|
110
|
-
return mod.default ?? mod;
|
|
504
|
+
return await loader();
|
|
111
505
|
} catch (error) {
|
|
112
506
|
if (import.meta.env.DEV) {
|
|
113
507
|
console.error('[ez-i18n] Failed to load translations for locale:', locale, error);
|
|
@@ -116,9 +510,107 @@ export async function loadTranslations(locale) {
|
|
|
116
510
|
}
|
|
117
511
|
}
|
|
118
512
|
`;
|
|
513
|
+
}
|
|
514
|
+
function generateBuildTranslationsModule(translationInfo, projectRoot, pathBasedNamespacing) {
|
|
515
|
+
const loaderEntries = [];
|
|
516
|
+
let needsDeepMerge = false;
|
|
517
|
+
let needsNamespaceWrapper = false;
|
|
518
|
+
for (const [locale, info] of translationInfo) {
|
|
519
|
+
if (info.files.length === 0) {
|
|
520
|
+
loaderEntries.push(` ${JSON.stringify(locale)}: async () => ({})`);
|
|
521
|
+
} else if (info.files.length === 1) {
|
|
522
|
+
const relativePath = toRelativeImport(info.files[0], projectRoot);
|
|
523
|
+
if (pathBasedNamespacing && info.localeBaseDir) {
|
|
524
|
+
needsNamespaceWrapper = true;
|
|
525
|
+
const namespace = getNamespaceFromPath(info.files[0], info.localeBaseDir);
|
|
526
|
+
loaderEntries.push(
|
|
527
|
+
` ${JSON.stringify(locale)}: () => import(${JSON.stringify(relativePath)}).then(m => __wrapWithNamespace(${JSON.stringify(namespace)}, m.default ?? m))`
|
|
528
|
+
);
|
|
529
|
+
} else {
|
|
530
|
+
loaderEntries.push(
|
|
531
|
+
` ${JSON.stringify(locale)}: () => import(${JSON.stringify(relativePath)}).then(m => m.default ?? m)`
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
} else {
|
|
535
|
+
needsDeepMerge = true;
|
|
536
|
+
if (pathBasedNamespacing && info.localeBaseDir) {
|
|
537
|
+
needsNamespaceWrapper = true;
|
|
538
|
+
const fileEntries = info.files.map((f) => {
|
|
539
|
+
const relativePath = toRelativeImport(f, projectRoot);
|
|
540
|
+
const namespace = getNamespaceFromPath(f, info.localeBaseDir);
|
|
541
|
+
return `{ path: ${JSON.stringify(relativePath)}, namespace: ${JSON.stringify(namespace)} }`;
|
|
542
|
+
});
|
|
543
|
+
loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
|
|
544
|
+
const fileInfos = [${fileEntries.join(", ")}];
|
|
545
|
+
const modules = await Promise.all(fileInfos.map(f => import(f.path)));
|
|
546
|
+
const wrapped = modules.map((m, i) => __wrapWithNamespace(fileInfos[i].namespace, m.default ?? m));
|
|
547
|
+
return __deepMerge({}, ...wrapped);
|
|
548
|
+
}`);
|
|
549
|
+
} else {
|
|
550
|
+
const fileImports = info.files.map((f) => `import(${JSON.stringify(toRelativeImport(f, projectRoot))})`).join(", ");
|
|
551
|
+
loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
|
|
552
|
+
const modules = await Promise.all([${fileImports}]);
|
|
553
|
+
const contents = modules.map(m => m.default ?? m);
|
|
554
|
+
return __deepMerge({}, ...contents);
|
|
555
|
+
}`);
|
|
119
556
|
}
|
|
120
|
-
return null;
|
|
121
557
|
}
|
|
558
|
+
}
|
|
559
|
+
const helperCode = [
|
|
560
|
+
needsDeepMerge ? getDeepMergeCode() : "",
|
|
561
|
+
needsNamespaceWrapper ? generateNamespaceWrapperCode() : ""
|
|
562
|
+
].filter(Boolean).join("\n");
|
|
563
|
+
return `
|
|
564
|
+
${helperCode}
|
|
565
|
+
|
|
566
|
+
export const translationLoaders = {
|
|
567
|
+
${loaderEntries.join(",\n")}
|
|
568
|
+
};
|
|
569
|
+
|
|
570
|
+
export async function loadTranslations(locale) {
|
|
571
|
+
const loader = translationLoaders[locale];
|
|
572
|
+
if (!loader) {
|
|
573
|
+
return {};
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
try {
|
|
577
|
+
return await loader();
|
|
578
|
+
} catch (error) {
|
|
579
|
+
console.error('[ez-i18n] Failed to load translations:', locale, error);
|
|
580
|
+
return {};
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
`;
|
|
584
|
+
}
|
|
585
|
+
function getDeepMergeCode() {
|
|
586
|
+
return `
|
|
587
|
+
function __deepMerge(target, ...sources) {
|
|
588
|
+
const FORBIDDEN = new Set(['__proto__', 'constructor', 'prototype']);
|
|
589
|
+
const result = { ...target };
|
|
590
|
+
for (const source of sources) {
|
|
591
|
+
if (!source || typeof source !== 'object') continue;
|
|
592
|
+
for (const key of Object.keys(source)) {
|
|
593
|
+
if (FORBIDDEN.has(key)) continue;
|
|
594
|
+
const tv = result[key], sv = source[key];
|
|
595
|
+
if (sv && typeof sv === 'object' && !Array.isArray(sv) && tv && typeof tv === 'object' && !Array.isArray(tv)) {
|
|
596
|
+
result[key] = __deepMerge(tv, sv);
|
|
597
|
+
} else {
|
|
598
|
+
result[key] = sv;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
return result;
|
|
603
|
+
}`;
|
|
604
|
+
}
|
|
605
|
+
function resolveConfig(config) {
|
|
606
|
+
const isAutoDiscovery = !config.translations || typeof config.translations === "string";
|
|
607
|
+
return {
|
|
608
|
+
locales: config.locales || [],
|
|
609
|
+
defaultLocale: config.defaultLocale,
|
|
610
|
+
cookieName: config.cookieName ?? "ez-locale",
|
|
611
|
+
translations: {},
|
|
612
|
+
pathBasedNamespacing: config.pathBasedNamespacing ?? isAutoDiscovery,
|
|
613
|
+
localeBaseDirs: {}
|
|
122
614
|
};
|
|
123
615
|
}
|
|
124
616
|
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Translation path for a single locale:
|
|
3
|
+
* - Single file: `./src/i18n/en.json`
|
|
4
|
+
* - Folder: `./src/i18n/en/` (auto-discover all JSONs inside)
|
|
5
|
+
* - Glob: `./src/i18n/en/**.json` (recursive)
|
|
6
|
+
* - Array: `['./common.json', './auth.json']`
|
|
7
|
+
*/
|
|
8
|
+
type LocaleTranslationPath = string | string[];
|
|
9
|
+
/**
|
|
10
|
+
* Translation config can be:
|
|
11
|
+
* - A base directory string (auto-discovers locale folders): './public/i18n/'
|
|
12
|
+
* - Per-locale mapping: { en: './src/i18n/en/', es: './src/i18n/es.json' }
|
|
13
|
+
*/
|
|
14
|
+
type TranslationsConfig = string | Record<string, LocaleTranslationPath>;
|
|
15
|
+
/**
|
|
16
|
+
* Configuration for ez-i18n Astro integration
|
|
17
|
+
*/
|
|
18
|
+
interface EzI18nConfig {
|
|
19
|
+
/**
|
|
20
|
+
* List of supported locale codes (e.g., ['en', 'es', 'fr'])
|
|
21
|
+
* Optional if using directory-based auto-discovery - locales will be
|
|
22
|
+
* detected from folder names in the translations directory.
|
|
23
|
+
*/
|
|
24
|
+
locales?: string[];
|
|
25
|
+
/**
|
|
26
|
+
* Default locale to use when no preference is detected.
|
|
27
|
+
* Required - this tells us what to fall back to.
|
|
28
|
+
*/
|
|
29
|
+
defaultLocale: string;
|
|
30
|
+
/**
|
|
31
|
+
* Cookie name for storing locale preference
|
|
32
|
+
* @default 'ez-locale'
|
|
33
|
+
*/
|
|
34
|
+
cookieName?: string;
|
|
35
|
+
/**
|
|
36
|
+
* Translation file paths configuration.
|
|
37
|
+
* Paths are relative to your project root.
|
|
38
|
+
*
|
|
39
|
+
* Can be:
|
|
40
|
+
* - A base directory (auto-discovers locale folders):
|
|
41
|
+
* translations: './public/i18n/'
|
|
42
|
+
* → Scans for en/, es/, fr/ folders and their JSON files
|
|
43
|
+
* → Auto-populates `locales` from discovered folders
|
|
44
|
+
*
|
|
45
|
+
* - Per-locale mapping with flexible path types:
|
|
46
|
+
* translations: {
|
|
47
|
+
* en: './src/i18n/en.json', // single file
|
|
48
|
+
* es: './src/i18n/es/', // folder (all JSONs)
|
|
49
|
+
* fr: './src/i18n/fr/**.json', // glob pattern
|
|
50
|
+
* de: ['./common.json', './auth.json'] // array of files
|
|
51
|
+
* }
|
|
52
|
+
*
|
|
53
|
+
* If not specified, auto-discovers from ./public/i18n/
|
|
54
|
+
*/
|
|
55
|
+
translations?: TranslationsConfig;
|
|
56
|
+
/**
|
|
57
|
+
* Derive namespace from file path relative to locale folder.
|
|
58
|
+
*
|
|
59
|
+
* When enabled:
|
|
60
|
+
* - `en/auth/login.json` with `{ "title": "..." }` → `$t('auth.login.title')`
|
|
61
|
+
* - `en/common.json` with `{ "actions": {...} }` → `$t('common.actions.save')`
|
|
62
|
+
*
|
|
63
|
+
* The file path (minus locale folder and .json extension) becomes the key prefix.
|
|
64
|
+
*
|
|
65
|
+
* @default true when using folder-based translations config
|
|
66
|
+
*/
|
|
67
|
+
pathBasedNamespacing?: boolean;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Cache file structure (.ez-i18n.json)
|
|
71
|
+
* Used to speed up subsequent builds by caching discovered translations
|
|
72
|
+
*/
|
|
73
|
+
interface TranslationCache {
|
|
74
|
+
version: number;
|
|
75
|
+
/** Discovered locale → file paths mapping */
|
|
76
|
+
discovered: Record<string, string[]>;
|
|
77
|
+
/** ISO timestamp of last scan */
|
|
78
|
+
lastScan: string;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Translation function type
|
|
82
|
+
*/
|
|
83
|
+
type TranslateFunction = (key: string, params?: Record<string, string | number>) => string;
|
|
84
|
+
/**
|
|
85
|
+
* Augment Astro's locals type
|
|
86
|
+
*/
|
|
87
|
+
declare global {
|
|
88
|
+
namespace App {
|
|
89
|
+
interface Locals {
|
|
90
|
+
/** Current locale code */
|
|
91
|
+
locale: string;
|
|
92
|
+
/** Loaded translations for the current locale */
|
|
93
|
+
translations: Record<string, unknown>;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export type { EzI18nConfig as E, LocaleTranslationPath as L, TranslateFunction as T, TranslationsConfig as a, TranslationCache as b };
|