@zachhandley/ez-i18n 0.1.1 → 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 +125 -6
- package/dist/index.d.ts +2 -2
- package/dist/index.js +385 -24
- package/dist/runtime/react-plugin.d.ts +29 -0
- package/dist/runtime/react-plugin.js +85 -0
- 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 +112 -89
- package/src/components/EzI18nHead.astro +1 -1
- package/src/runtime/react-plugin.ts +78 -0
- 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
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
# @zachhandley/ez-i18n
|
|
2
2
|
|
|
3
|
-
Cookie-based i18n for Astro + Vue. No URL prefixes, reactive language switching.
|
|
3
|
+
Cookie-based i18n for Astro + Vue + React. No URL prefixes, reactive language switching.
|
|
4
4
|
|
|
5
5
|
## Installation
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
8
|
pnpm add @zachhandley/ez-i18n nanostores @nanostores/persistent
|
|
9
|
+
|
|
9
10
|
# If using Vue:
|
|
10
11
|
pnpm add @nanostores/vue
|
|
12
|
+
|
|
13
|
+
# If using React:
|
|
14
|
+
pnpm add @nanostores/react
|
|
11
15
|
```
|
|
12
16
|
|
|
13
17
|
## Usage
|
|
@@ -55,6 +59,80 @@ export default defineConfig({
|
|
|
55
59
|
|
|
56
60
|
Create similar files for each locale: `src/i18n/en.json`, `src/i18n/es.json`, etc.
|
|
57
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
|
+
|
|
58
136
|
### Layout Setup
|
|
59
137
|
|
|
60
138
|
Add the `EzI18nHead` component to your layout's head for automatic hydration:
|
|
@@ -62,7 +140,7 @@ Add the `EzI18nHead` component to your layout's head for automatic hydration:
|
|
|
62
140
|
```astro
|
|
63
141
|
---
|
|
64
142
|
// src/layouts/Layout.astro
|
|
65
|
-
import
|
|
143
|
+
import EzI18nHead from '@zachhandley/ez-i18n/astro';
|
|
66
144
|
const { locale, translations } = Astro.locals;
|
|
67
145
|
---
|
|
68
146
|
|
|
@@ -134,14 +212,43 @@ export default (app: App) => {
|
|
|
134
212
|
};
|
|
135
213
|
```
|
|
136
214
|
|
|
215
|
+
### In React Components
|
|
216
|
+
|
|
217
|
+
```tsx
|
|
218
|
+
import { useI18n } from '@zachhandley/ez-i18n/react';
|
|
219
|
+
import { translationLoaders } from 'ez-i18n:translations';
|
|
220
|
+
|
|
221
|
+
function MyComponent() {
|
|
222
|
+
const { t, locale, setLocale } = useI18n();
|
|
223
|
+
|
|
224
|
+
async function switchLocale(newLocale: string) {
|
|
225
|
+
await setLocale(newLocale, {
|
|
226
|
+
loadTranslations: translationLoaders[newLocale],
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return (
|
|
231
|
+
<div>
|
|
232
|
+
<h1>{t('common.welcome')}</h1>
|
|
233
|
+
<p>{t('greeting', { name: 'World' })}</p>
|
|
234
|
+
<button onClick={() => switchLocale('es')}>Español</button>
|
|
235
|
+
<button onClick={() => switchLocale('fr')}>Français</button>
|
|
236
|
+
</div>
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
```
|
|
240
|
+
|
|
137
241
|
## Features
|
|
138
242
|
|
|
139
243
|
- **No URL prefixes** - Locale stored in cookie, not URL path
|
|
140
244
|
- **Reactive** - Language changes update immediately without page reload
|
|
141
245
|
- **SSR compatible** - Proper hydration with server-rendered locale
|
|
142
246
|
- **Vue integration** - Global `$t()`, `$locale`, `$setLocale` in templates
|
|
143
|
-
- **
|
|
247
|
+
- **React integration** - `useI18n()` hook for React components
|
|
144
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
|
|
145
252
|
|
|
146
253
|
## Locale Detection Priority
|
|
147
254
|
|
|
@@ -158,10 +265,16 @@ Astro integration function.
|
|
|
158
265
|
|
|
159
266
|
| Option | Type | Required | Description |
|
|
160
267
|
|--------|------|----------|-------------|
|
|
161
|
-
| `locales` | `string[]` |
|
|
268
|
+
| `locales` | `string[]` | No | Supported locale codes (auto-discovered if not provided) |
|
|
162
269
|
| `defaultLocale` | `string` | Yes | Fallback locale |
|
|
163
270
|
| `cookieName` | `string` | No | Cookie name (default: `'ez-locale'`) |
|
|
164
|
-
| `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']`
|
|
165
278
|
|
|
166
279
|
### `EzI18nHead`
|
|
167
280
|
|
|
@@ -195,9 +308,15 @@ setLocale('es', { loadTranslations: translationLoaders['es'] });
|
|
|
195
308
|
|
|
196
309
|
### `useI18n()`
|
|
197
310
|
|
|
198
|
-
|
|
311
|
+
Hook for Vue (Composition API) and React.
|
|
199
312
|
|
|
200
313
|
```typescript
|
|
314
|
+
// Vue
|
|
315
|
+
import { useI18n } from '@zachhandley/ez-i18n/vue';
|
|
316
|
+
|
|
317
|
+
// React
|
|
318
|
+
import { useI18n } from '@zachhandley/ez-i18n/react';
|
|
319
|
+
|
|
201
320
|
const { t, locale, setLocale } = useI18n();
|
|
202
321
|
```
|
|
203
322
|
|
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,29 @@
|
|
|
1
|
+
import { setLocale } from './index.js';
|
|
2
|
+
import { T as TranslateFunction } from '../types-CHyDGt_C.js';
|
|
3
|
+
import 'nanostores';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* React hook for i18n
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* import { useI18n } from '@zachhandley/ez-i18n/react';
|
|
10
|
+
*
|
|
11
|
+
* function MyComponent() {
|
|
12
|
+
* const { t, locale, setLocale } = useI18n();
|
|
13
|
+
*
|
|
14
|
+
* return (
|
|
15
|
+
* <div>
|
|
16
|
+
* <h1>{t('common.welcome')}</h1>
|
|
17
|
+
* <p>{t('greeting', { name: 'World' })}</p>
|
|
18
|
+
* <button onClick={() => setLocale('es')}>Español</button>
|
|
19
|
+
* </div>
|
|
20
|
+
* );
|
|
21
|
+
* }
|
|
22
|
+
*/
|
|
23
|
+
declare function useI18n(): {
|
|
24
|
+
t: TranslateFunction;
|
|
25
|
+
locale: string;
|
|
26
|
+
setLocale: typeof setLocale;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export { useI18n };
|