@zachhandley/ez-i18n 0.1.2 → 0.1.3
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 +85 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +385 -24
- package/dist/runtime/react-plugin.d.ts +1 -1
- package/dist/runtime/vue-plugin.d.ts +1 -1
- package/dist/types-CHyDGt_C.d.ts +86 -0
- package/dist/utils/index.d.ts +59 -0
- package/dist/utils/index.js +190 -0
- package/package.json +8 -1
- package/src/types.ts +57 -12
- package/src/utils/index.ts +13 -0
- package/src/utils/translations.ts +311 -0
- package/src/vite-plugin.ts +329 -29
- package/dist/types-DwCG8sp8.d.ts +0 -48
package/README.md
CHANGED
|
@@ -59,6 +59,80 @@ export default defineConfig({
|
|
|
59
59
|
|
|
60
60
|
Create similar files for each locale: `src/i18n/en.json`, `src/i18n/es.json`, etc.
|
|
61
61
|
|
|
62
|
+
### Multi-File Translations
|
|
63
|
+
|
|
64
|
+
ez-i18n supports flexible translation file organization:
|
|
65
|
+
|
|
66
|
+
#### Auto-Discovery (Zero Config)
|
|
67
|
+
|
|
68
|
+
Just put your files in `public/i18n/` and ez-i18n will discover them automatically:
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
public/i18n/
|
|
72
|
+
en/
|
|
73
|
+
common.json
|
|
74
|
+
auth.json
|
|
75
|
+
es/
|
|
76
|
+
common.json
|
|
77
|
+
auth.json
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
```typescript
|
|
81
|
+
// astro.config.ts - locales auto-discovered from folder names!
|
|
82
|
+
ezI18n({
|
|
83
|
+
defaultLocale: 'en',
|
|
84
|
+
// No locales or translations needed - auto-discovered
|
|
85
|
+
})
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
#### Base Directory
|
|
89
|
+
|
|
90
|
+
Point to a folder and locales are discovered from subfolders:
|
|
91
|
+
|
|
92
|
+
```typescript
|
|
93
|
+
ezI18n({
|
|
94
|
+
defaultLocale: 'en',
|
|
95
|
+
translations: './src/i18n/', // Discovers en/, es/, fr/ folders
|
|
96
|
+
})
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
#### Per-Locale with Multiple Formats
|
|
100
|
+
|
|
101
|
+
Mix and match different formats per locale:
|
|
102
|
+
|
|
103
|
+
```typescript
|
|
104
|
+
ezI18n({
|
|
105
|
+
locales: ['en', 'es', 'fr', 'de'],
|
|
106
|
+
defaultLocale: 'en',
|
|
107
|
+
translations: {
|
|
108
|
+
en: './src/i18n/en.json', // Single file
|
|
109
|
+
es: './src/i18n/es/', // Folder (all JSONs merged)
|
|
110
|
+
fr: './src/i18n/fr/**/*.json', // Glob pattern
|
|
111
|
+
de: ['./src/i18n/de/common.json', // Array of files
|
|
112
|
+
'./src/i18n/de/auth.json'],
|
|
113
|
+
},
|
|
114
|
+
})
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
#### Merge Order
|
|
118
|
+
|
|
119
|
+
When using multiple files per locale, files are merged **alphabetically by filename**. Later files override earlier ones for conflicting keys.
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
en/
|
|
123
|
+
01-common.json # Loaded first
|
|
124
|
+
02-features.json # Loaded second, overrides common
|
|
125
|
+
99-overrides.json # Loaded last, highest priority
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
#### Cache File
|
|
129
|
+
|
|
130
|
+
A `.ez-i18n.json` cache file is generated to speed up subsequent builds. Add it to `.gitignore`:
|
|
131
|
+
|
|
132
|
+
```gitignore
|
|
133
|
+
.ez-i18n.json
|
|
134
|
+
```
|
|
135
|
+
|
|
62
136
|
### Layout Setup
|
|
63
137
|
|
|
64
138
|
Add the `EzI18nHead` component to your layout's head for automatic hydration:
|
|
@@ -172,6 +246,9 @@ function MyComponent() {
|
|
|
172
246
|
- **Vue integration** - Global `$t()`, `$locale`, `$setLocale` in templates
|
|
173
247
|
- **React integration** - `useI18n()` hook for React components
|
|
174
248
|
- **Middleware included** - Auto-detects locale from cookie, query param, or Accept-Language header
|
|
249
|
+
- **Multi-file support** - Organize translations in folders, use globs, or arrays
|
|
250
|
+
- **Auto-discovery** - Automatic locale detection from folder structure
|
|
251
|
+
- **HMR in dev** - Hot reload translation changes without restart
|
|
175
252
|
|
|
176
253
|
## Locale Detection Priority
|
|
177
254
|
|
|
@@ -188,10 +265,16 @@ Astro integration function.
|
|
|
188
265
|
|
|
189
266
|
| Option | Type | Required | Description |
|
|
190
267
|
|--------|------|----------|-------------|
|
|
191
|
-
| `locales` | `string[]` |
|
|
268
|
+
| `locales` | `string[]` | No | Supported locale codes (auto-discovered if not provided) |
|
|
192
269
|
| `defaultLocale` | `string` | Yes | Fallback locale |
|
|
193
270
|
| `cookieName` | `string` | No | Cookie name (default: `'ez-locale'`) |
|
|
194
|
-
| `translations` | `Record<string,
|
|
271
|
+
| `translations` | `string \| Record<string, TranslationPath>` | No | Base directory or per-locale paths (default: `./public/i18n/`) |
|
|
272
|
+
|
|
273
|
+
**TranslationPath** can be:
|
|
274
|
+
- Single file: `'./src/i18n/en.json'`
|
|
275
|
+
- Folder: `'./src/i18n/en/'`
|
|
276
|
+
- Glob: `'./src/i18n/en/**/*.json'`
|
|
277
|
+
- Array: `['./common.json', './auth.json']`
|
|
195
278
|
|
|
196
279
|
### `EzI18nHead`
|
|
197
280
|
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { AstroIntegration } from 'astro';
|
|
2
|
-
import { E as EzI18nConfig } from './types-
|
|
3
|
-
export { T as TranslateFunction } from './types-
|
|
2
|
+
import { E as EzI18nConfig } from './types-CHyDGt_C.js';
|
|
3
|
+
export { T as TranslateFunction } from './types-CHyDGt_C.js';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* ez-i18n Astro integration
|
package/dist/index.js
CHANGED
|
@@ -1,21 +1,235 @@
|
|
|
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
|
+
|
|
1
159
|
// src/vite-plugin.ts
|
|
160
|
+
import * as path2 from "path";
|
|
2
161
|
var VIRTUAL_CONFIG = "ez-i18n:config";
|
|
3
162
|
var VIRTUAL_RUNTIME = "ez-i18n:runtime";
|
|
4
163
|
var VIRTUAL_TRANSLATIONS = "ez-i18n:translations";
|
|
5
164
|
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
165
|
function vitePlugin(config) {
|
|
15
|
-
|
|
166
|
+
let viteConfig;
|
|
167
|
+
let isDev = false;
|
|
168
|
+
let resolved;
|
|
169
|
+
let translationInfo = /* @__PURE__ */ new Map();
|
|
16
170
|
return {
|
|
17
171
|
name: "ez-i18n-vite",
|
|
18
172
|
enforce: "pre",
|
|
173
|
+
configResolved(resolvedConfig) {
|
|
174
|
+
viteConfig = resolvedConfig;
|
|
175
|
+
isDev = resolvedConfig.command === "serve";
|
|
176
|
+
},
|
|
177
|
+
async buildStart() {
|
|
178
|
+
const projectRoot = viteConfig.root;
|
|
179
|
+
let useCache = false;
|
|
180
|
+
if (!isDev) {
|
|
181
|
+
const cache = loadCache(projectRoot);
|
|
182
|
+
if (cache && isCacheValid(cache, projectRoot)) {
|
|
183
|
+
resolved = {
|
|
184
|
+
locales: config.locales || Object.keys(cache.discovered),
|
|
185
|
+
defaultLocale: config.defaultLocale,
|
|
186
|
+
cookieName: config.cookieName ?? "ez-locale",
|
|
187
|
+
translations: cache.discovered
|
|
188
|
+
};
|
|
189
|
+
useCache = true;
|
|
190
|
+
for (const [locale, files] of Object.entries(cache.discovered)) {
|
|
191
|
+
translationInfo.set(locale, { locale, files });
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (!useCache) {
|
|
196
|
+
const { locales, translations } = await resolveTranslationsConfig(
|
|
197
|
+
config.translations,
|
|
198
|
+
projectRoot,
|
|
199
|
+
config.locales
|
|
200
|
+
);
|
|
201
|
+
const finalLocales = config.locales && config.locales.length > 0 ? config.locales : locales;
|
|
202
|
+
resolved = {
|
|
203
|
+
locales: finalLocales,
|
|
204
|
+
defaultLocale: config.defaultLocale,
|
|
205
|
+
cookieName: config.cookieName ?? "ez-locale",
|
|
206
|
+
translations
|
|
207
|
+
};
|
|
208
|
+
for (const locale of finalLocales) {
|
|
209
|
+
const files = translations[locale] || [];
|
|
210
|
+
const info = { locale, files };
|
|
211
|
+
if (isDev && config.translations) {
|
|
212
|
+
const localeConfig = typeof config.translations === "string" ? path2.join(config.translations, locale) : config.translations[locale];
|
|
213
|
+
if (localeConfig && typeof localeConfig === "string") {
|
|
214
|
+
const pathType = detectPathType(localeConfig);
|
|
215
|
+
if (pathType === "folder" || pathType === "glob") {
|
|
216
|
+
const basePath = pathType === "glob" ? localeConfig : toGlobPattern(path2.resolve(projectRoot, localeConfig), projectRoot);
|
|
217
|
+
info.globPattern = basePath;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
translationInfo.set(locale, info);
|
|
222
|
+
}
|
|
223
|
+
if (!isDev && Object.keys(translations).length > 0) {
|
|
224
|
+
saveCache(projectRoot, translations);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (!resolved.locales.includes(resolved.defaultLocale)) {
|
|
228
|
+
console.warn(
|
|
229
|
+
`[ez-i18n] defaultLocale "${resolved.defaultLocale}" not found in locales: [${resolved.locales.join(", ")}]`
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
},
|
|
19
233
|
resolveId(id) {
|
|
20
234
|
if (id === VIRTUAL_CONFIG || id === VIRTUAL_RUNTIME || id === VIRTUAL_TRANSLATIONS) {
|
|
21
235
|
return RESOLVED_PREFIX + id;
|
|
@@ -81,21 +295,101 @@ export function t(key, params) {
|
|
|
81
295
|
`;
|
|
82
296
|
}
|
|
83
297
|
if (id === RESOLVED_PREFIX + VIRTUAL_TRANSLATIONS) {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
298
|
+
return isDev ? generateDevTranslationsModule(translationInfo, viteConfig.root) : generateBuildTranslationsModule(translationInfo, viteConfig.root);
|
|
299
|
+
}
|
|
300
|
+
return null;
|
|
301
|
+
},
|
|
302
|
+
// HMR support for dev mode
|
|
303
|
+
handleHotUpdate({ file, server }) {
|
|
304
|
+
if (!isDev) return;
|
|
305
|
+
for (const info of translationInfo.values()) {
|
|
306
|
+
if (info.files.includes(file)) {
|
|
307
|
+
const mod = server.moduleGraph.getModuleById(RESOLVED_PREFIX + VIRTUAL_TRANSLATIONS);
|
|
308
|
+
if (mod) {
|
|
309
|
+
server.moduleGraph.invalidateModule(mod);
|
|
310
|
+
server.ws.send({
|
|
311
|
+
type: "full-reload",
|
|
312
|
+
path: "*"
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
break;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
},
|
|
319
|
+
configureServer(server) {
|
|
320
|
+
const watchedDirs = /* @__PURE__ */ new Set();
|
|
321
|
+
if (config.translations) {
|
|
322
|
+
if (typeof config.translations === "string") {
|
|
323
|
+
watchedDirs.add(path2.resolve(viteConfig.root, config.translations));
|
|
324
|
+
} else {
|
|
325
|
+
for (const localePath of Object.values(config.translations)) {
|
|
326
|
+
if (typeof localePath === "string") {
|
|
327
|
+
const pathType = detectPathType(localePath);
|
|
328
|
+
if (pathType === "folder") {
|
|
329
|
+
watchedDirs.add(path2.resolve(viteConfig.root, localePath));
|
|
330
|
+
} else if (pathType === "glob") {
|
|
331
|
+
const baseDir = localePath.split("*")[0].replace(/\/$/, "");
|
|
332
|
+
if (baseDir) {
|
|
333
|
+
watchedDirs.add(path2.resolve(viteConfig.root, baseDir));
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
} else if (Array.isArray(localePath)) {
|
|
337
|
+
for (const file of localePath) {
|
|
338
|
+
const dir = path2.dirname(path2.resolve(viteConfig.root, file));
|
|
339
|
+
watchedDirs.add(dir);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
} else {
|
|
345
|
+
watchedDirs.add(path2.resolve(viteConfig.root, "./public/i18n"));
|
|
346
|
+
}
|
|
347
|
+
for (const dir of watchedDirs) {
|
|
348
|
+
server.watcher.add(dir);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
function generateDevTranslationsModule(translationInfo, projectRoot) {
|
|
354
|
+
const imports = [];
|
|
355
|
+
const loaderEntries = [];
|
|
356
|
+
imports.push(getDeepMergeCode());
|
|
357
|
+
for (const [locale, info] of translationInfo) {
|
|
358
|
+
if (info.files.length === 0) {
|
|
359
|
+
loaderEntries.push(` ${JSON.stringify(locale)}: async () => ({})`);
|
|
360
|
+
} else if (info.globPattern) {
|
|
361
|
+
const varName = `__${locale}Modules`;
|
|
362
|
+
imports.push(
|
|
363
|
+
`const ${varName} = import.meta.glob(${JSON.stringify(info.globPattern)}, { eager: true, import: 'default' });`
|
|
364
|
+
);
|
|
365
|
+
loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
|
|
366
|
+
const modules = Object.values(${varName});
|
|
367
|
+
if (modules.length === 0) return {};
|
|
368
|
+
if (modules.length === 1) return modules[0];
|
|
369
|
+
return __deepMerge({}, ...modules);
|
|
370
|
+
}`);
|
|
371
|
+
} else if (info.files.length === 1) {
|
|
372
|
+
const relativePath = toRelativeImport(info.files[0], projectRoot);
|
|
373
|
+
loaderEntries.push(
|
|
374
|
+
` ${JSON.stringify(locale)}: () => import(${JSON.stringify(relativePath)}).then(m => m.default ?? m)`
|
|
375
|
+
);
|
|
376
|
+
} else {
|
|
377
|
+
const fileImports = info.files.map((f) => `import(${JSON.stringify(toRelativeImport(f, projectRoot))})`).join(", ");
|
|
378
|
+
loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
|
|
379
|
+
const modules = await Promise.all([${fileImports}]);
|
|
380
|
+
const contents = modules.map(m => m.default ?? m);
|
|
381
|
+
if (contents.length === 1) return contents[0];
|
|
382
|
+
return __deepMerge({}, ...contents);
|
|
383
|
+
}`);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return `
|
|
387
|
+
${imports.join("\n")}
|
|
388
|
+
|
|
90
389
|
export const translationLoaders = {
|
|
91
|
-
${loaderEntries}
|
|
390
|
+
${loaderEntries.join(",\n")}
|
|
92
391
|
};
|
|
93
392
|
|
|
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
393
|
export async function loadTranslations(locale) {
|
|
100
394
|
const loader = translationLoaders[locale];
|
|
101
395
|
if (!loader) {
|
|
@@ -106,8 +400,7 @@ export async function loadTranslations(locale) {
|
|
|
106
400
|
}
|
|
107
401
|
|
|
108
402
|
try {
|
|
109
|
-
|
|
110
|
-
return mod.default ?? mod;
|
|
403
|
+
return await loader();
|
|
111
404
|
} catch (error) {
|
|
112
405
|
if (import.meta.env.DEV) {
|
|
113
406
|
console.error('[ez-i18n] Failed to load translations for locale:', locale, error);
|
|
@@ -116,9 +409,77 @@ export async function loadTranslations(locale) {
|
|
|
116
409
|
}
|
|
117
410
|
}
|
|
118
411
|
`;
|
|
412
|
+
}
|
|
413
|
+
function generateBuildTranslationsModule(translationInfo, projectRoot) {
|
|
414
|
+
const loaderEntries = [];
|
|
415
|
+
let needsDeepMerge = false;
|
|
416
|
+
for (const [locale, info] of translationInfo) {
|
|
417
|
+
if (info.files.length === 0) {
|
|
418
|
+
loaderEntries.push(` ${JSON.stringify(locale)}: async () => ({})`);
|
|
419
|
+
} else if (info.files.length === 1) {
|
|
420
|
+
const relativePath = toRelativeImport(info.files[0], projectRoot);
|
|
421
|
+
loaderEntries.push(
|
|
422
|
+
` ${JSON.stringify(locale)}: () => import(${JSON.stringify(relativePath)}).then(m => m.default ?? m)`
|
|
423
|
+
);
|
|
424
|
+
} else {
|
|
425
|
+
needsDeepMerge = true;
|
|
426
|
+
const fileImports = info.files.map((f) => `import(${JSON.stringify(toRelativeImport(f, projectRoot))})`).join(", ");
|
|
427
|
+
loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
|
|
428
|
+
const modules = await Promise.all([${fileImports}]);
|
|
429
|
+
const contents = modules.map(m => m.default ?? m);
|
|
430
|
+
return __deepMerge({}, ...contents);
|
|
431
|
+
}`);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
const deepMergeCode = needsDeepMerge ? getDeepMergeCode() : "";
|
|
435
|
+
return `
|
|
436
|
+
${deepMergeCode}
|
|
437
|
+
|
|
438
|
+
export const translationLoaders = {
|
|
439
|
+
${loaderEntries.join(",\n")}
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
export async function loadTranslations(locale) {
|
|
443
|
+
const loader = translationLoaders[locale];
|
|
444
|
+
if (!loader) {
|
|
445
|
+
return {};
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
try {
|
|
449
|
+
return await loader();
|
|
450
|
+
} catch (error) {
|
|
451
|
+
console.error('[ez-i18n] Failed to load translations:', locale, error);
|
|
452
|
+
return {};
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
`;
|
|
456
|
+
}
|
|
457
|
+
function getDeepMergeCode() {
|
|
458
|
+
return `
|
|
459
|
+
function __deepMerge(target, ...sources) {
|
|
460
|
+
const FORBIDDEN = new Set(['__proto__', 'constructor', 'prototype']);
|
|
461
|
+
const result = { ...target };
|
|
462
|
+
for (const source of sources) {
|
|
463
|
+
if (!source || typeof source !== 'object') continue;
|
|
464
|
+
for (const key of Object.keys(source)) {
|
|
465
|
+
if (FORBIDDEN.has(key)) continue;
|
|
466
|
+
const tv = result[key], sv = source[key];
|
|
467
|
+
if (sv && typeof sv === 'object' && !Array.isArray(sv) && tv && typeof tv === 'object' && !Array.isArray(tv)) {
|
|
468
|
+
result[key] = __deepMerge(tv, sv);
|
|
469
|
+
} else {
|
|
470
|
+
result[key] = sv;
|
|
119
471
|
}
|
|
120
|
-
return null;
|
|
121
472
|
}
|
|
473
|
+
}
|
|
474
|
+
return result;
|
|
475
|
+
}`;
|
|
476
|
+
}
|
|
477
|
+
function resolveConfig(config) {
|
|
478
|
+
return {
|
|
479
|
+
locales: config.locales || [],
|
|
480
|
+
defaultLocale: config.defaultLocale,
|
|
481
|
+
cookieName: config.cookieName ?? "ez-locale",
|
|
482
|
+
translations: {}
|
|
122
483
|
};
|
|
123
484
|
}
|
|
124
485
|
|
|
@@ -0,0 +1,86 @@
|
|
|
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
|
+
/**
|
|
58
|
+
* Cache file structure (.ez-i18n.json)
|
|
59
|
+
* Used to speed up subsequent builds by caching discovered translations
|
|
60
|
+
*/
|
|
61
|
+
interface TranslationCache {
|
|
62
|
+
version: number;
|
|
63
|
+
/** Discovered locale → file paths mapping */
|
|
64
|
+
discovered: Record<string, string[]>;
|
|
65
|
+
/** ISO timestamp of last scan */
|
|
66
|
+
lastScan: string;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Translation function type
|
|
70
|
+
*/
|
|
71
|
+
type TranslateFunction = (key: string, params?: Record<string, string | number>) => string;
|
|
72
|
+
/**
|
|
73
|
+
* Augment Astro's locals type
|
|
74
|
+
*/
|
|
75
|
+
declare global {
|
|
76
|
+
namespace App {
|
|
77
|
+
interface Locals {
|
|
78
|
+
/** Current locale code */
|
|
79
|
+
locale: string;
|
|
80
|
+
/** Loaded translations for the current locale */
|
|
81
|
+
translations: Record<string, unknown>;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export type { EzI18nConfig as E, LocaleTranslationPath as L, TranslateFunction as T, TranslationsConfig as a, TranslationCache as b };
|