@zachhandley/ez-i18n 0.1.1 → 0.1.2
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 +40 -4
- package/dist/runtime/react-plugin.d.ts +29 -0
- package/dist/runtime/react-plugin.js +85 -0
- package/package.json +105 -89
- package/src/components/EzI18nHead.astro +1 -1
- package/src/runtime/react-plugin.ts +78 -0
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
|
|
@@ -62,7 +66,7 @@ Add the `EzI18nHead` component to your layout's head for automatic hydration:
|
|
|
62
66
|
```astro
|
|
63
67
|
---
|
|
64
68
|
// src/layouts/Layout.astro
|
|
65
|
-
import
|
|
69
|
+
import EzI18nHead from '@zachhandley/ez-i18n/astro';
|
|
66
70
|
const { locale, translations } = Astro.locals;
|
|
67
71
|
---
|
|
68
72
|
|
|
@@ -134,13 +138,39 @@ export default (app: App) => {
|
|
|
134
138
|
};
|
|
135
139
|
```
|
|
136
140
|
|
|
141
|
+
### In React Components
|
|
142
|
+
|
|
143
|
+
```tsx
|
|
144
|
+
import { useI18n } from '@zachhandley/ez-i18n/react';
|
|
145
|
+
import { translationLoaders } from 'ez-i18n:translations';
|
|
146
|
+
|
|
147
|
+
function MyComponent() {
|
|
148
|
+
const { t, locale, setLocale } = useI18n();
|
|
149
|
+
|
|
150
|
+
async function switchLocale(newLocale: string) {
|
|
151
|
+
await setLocale(newLocale, {
|
|
152
|
+
loadTranslations: translationLoaders[newLocale],
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return (
|
|
157
|
+
<div>
|
|
158
|
+
<h1>{t('common.welcome')}</h1>
|
|
159
|
+
<p>{t('greeting', { name: 'World' })}</p>
|
|
160
|
+
<button onClick={() => switchLocale('es')}>Español</button>
|
|
161
|
+
<button onClick={() => switchLocale('fr')}>Français</button>
|
|
162
|
+
</div>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
```
|
|
166
|
+
|
|
137
167
|
## Features
|
|
138
168
|
|
|
139
169
|
- **No URL prefixes** - Locale stored in cookie, not URL path
|
|
140
170
|
- **Reactive** - Language changes update immediately without page reload
|
|
141
171
|
- **SSR compatible** - Proper hydration with server-rendered locale
|
|
142
172
|
- **Vue integration** - Global `$t()`, `$locale`, `$setLocale` in templates
|
|
143
|
-
- **
|
|
173
|
+
- **React integration** - `useI18n()` hook for React components
|
|
144
174
|
- **Middleware included** - Auto-detects locale from cookie, query param, or Accept-Language header
|
|
145
175
|
|
|
146
176
|
## Locale Detection Priority
|
|
@@ -195,9 +225,15 @@ setLocale('es', { loadTranslations: translationLoaders['es'] });
|
|
|
195
225
|
|
|
196
226
|
### `useI18n()`
|
|
197
227
|
|
|
198
|
-
|
|
228
|
+
Hook for Vue (Composition API) and React.
|
|
199
229
|
|
|
200
230
|
```typescript
|
|
231
|
+
// Vue
|
|
232
|
+
import { useI18n } from '@zachhandley/ez-i18n/vue';
|
|
233
|
+
|
|
234
|
+
// React
|
|
235
|
+
import { useI18n } from '@zachhandley/ez-i18n/react';
|
|
236
|
+
|
|
201
237
|
const { t, locale, setLocale } = useI18n();
|
|
202
238
|
```
|
|
203
239
|
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { setLocale } from './index.js';
|
|
2
|
+
import { T as TranslateFunction } from '../types-DwCG8sp8.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 };
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// src/runtime/react-plugin.ts
|
|
2
|
+
import { useStore } from "@nanostores/react";
|
|
3
|
+
|
|
4
|
+
// src/runtime/store.ts
|
|
5
|
+
import { atom, computed } from "nanostores";
|
|
6
|
+
import { persistentAtom } from "@nanostores/persistent";
|
|
7
|
+
var serverLocale = atom(null);
|
|
8
|
+
var localePreference = persistentAtom("ez-locale", "en", {
|
|
9
|
+
encode: (value) => value,
|
|
10
|
+
decode: (value) => value
|
|
11
|
+
});
|
|
12
|
+
var effectiveLocale = computed(
|
|
13
|
+
[serverLocale, localePreference],
|
|
14
|
+
(server, client) => server ?? client
|
|
15
|
+
);
|
|
16
|
+
var translations = atom({});
|
|
17
|
+
var localeLoading = atom(false);
|
|
18
|
+
async function setLocale(locale, options = {}) {
|
|
19
|
+
const opts = typeof options === "string" ? { cookieName: options } : options;
|
|
20
|
+
const { cookieName = "ez-locale", loadTranslations } = opts;
|
|
21
|
+
localeLoading.set(true);
|
|
22
|
+
try {
|
|
23
|
+
if (loadTranslations) {
|
|
24
|
+
const mod = await loadTranslations();
|
|
25
|
+
const trans = "default" in mod ? mod.default : mod;
|
|
26
|
+
translations.set(trans);
|
|
27
|
+
}
|
|
28
|
+
localePreference.set(locale);
|
|
29
|
+
serverLocale.set(locale);
|
|
30
|
+
if (typeof document !== "undefined") {
|
|
31
|
+
document.cookie = `${cookieName}=${locale}; path=/; max-age=31536000; samesite=lax`;
|
|
32
|
+
}
|
|
33
|
+
if (typeof document !== "undefined") {
|
|
34
|
+
document.dispatchEvent(
|
|
35
|
+
new CustomEvent("ez-i18n:locale-changed", {
|
|
36
|
+
detail: { locale },
|
|
37
|
+
bubbles: true
|
|
38
|
+
})
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
} finally {
|
|
42
|
+
localeLoading.set(false);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// src/runtime/react-plugin.ts
|
|
47
|
+
function getNestedValue(obj, path) {
|
|
48
|
+
const keys = path.split(".");
|
|
49
|
+
let value = obj;
|
|
50
|
+
for (const key of keys) {
|
|
51
|
+
if (value == null || typeof value !== "object") {
|
|
52
|
+
return void 0;
|
|
53
|
+
}
|
|
54
|
+
value = value[key];
|
|
55
|
+
}
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
58
|
+
function interpolate(str, params) {
|
|
59
|
+
if (!params) return str;
|
|
60
|
+
return str.replace(/\{(\w+)\}/g, (match, key) => {
|
|
61
|
+
return key in params ? String(params[key]) : match;
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
function useI18n() {
|
|
65
|
+
const locale = useStore(effectiveLocale);
|
|
66
|
+
const trans = useStore(translations);
|
|
67
|
+
const t = (key, params) => {
|
|
68
|
+
const value = getNestedValue(trans, key);
|
|
69
|
+
if (typeof value !== "string") {
|
|
70
|
+
if (import.meta.env?.DEV) {
|
|
71
|
+
console.warn("[ez-i18n] Missing translation:", key);
|
|
72
|
+
}
|
|
73
|
+
return key;
|
|
74
|
+
}
|
|
75
|
+
return interpolate(value, params);
|
|
76
|
+
};
|
|
77
|
+
return {
|
|
78
|
+
t,
|
|
79
|
+
locale,
|
|
80
|
+
setLocale
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
export {
|
|
84
|
+
useI18n
|
|
85
|
+
};
|
package/package.json
CHANGED
|
@@ -1,89 +1,105 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@zachhandley/ez-i18n",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"publishConfig": {
|
|
5
|
-
"access": "public"
|
|
6
|
-
},
|
|
7
|
-
"description": "Cookie-based i18n for Astro + Vue. No URL prefixes, reactive language switching.",
|
|
8
|
-
"type": "module",
|
|
9
|
-
"main": "./dist/index.js",
|
|
10
|
-
"types": "./dist/index.d.ts",
|
|
11
|
-
"exports": {
|
|
12
|
-
".": {
|
|
13
|
-
"types": "./dist/index.d.ts",
|
|
14
|
-
"import": "./dist/index.js"
|
|
15
|
-
},
|
|
16
|
-
"./vue": {
|
|
17
|
-
"types": "./dist/runtime/vue-plugin.d.ts",
|
|
18
|
-
"import": "./dist/runtime/vue-plugin.js"
|
|
19
|
-
},
|
|
20
|
-
"./middleware": {
|
|
21
|
-
"import": "./dist/middleware.js"
|
|
22
|
-
},
|
|
23
|
-
"./runtime": {
|
|
24
|
-
"types": "./dist/runtime/index.d.ts",
|
|
25
|
-
"import": "./dist/runtime/index.js"
|
|
26
|
-
},
|
|
27
|
-
"./astro": {
|
|
28
|
-
"import": "./src/components/EzI18nHead.astro"
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
"
|
|
36
|
-
"
|
|
37
|
-
"
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
"
|
|
41
|
-
"
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
"
|
|
45
|
-
"
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
"
|
|
49
|
-
"
|
|
50
|
-
"
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
"
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
"
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
"vue":
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
"
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
"
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
"
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
"
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@zachhandley/ez-i18n",
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"publishConfig": {
|
|
5
|
+
"access": "public"
|
|
6
|
+
},
|
|
7
|
+
"description": "Cookie-based i18n for Astro + Vue + React. No URL prefixes, reactive language switching.",
|
|
8
|
+
"type": "module",
|
|
9
|
+
"main": "./dist/index.js",
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"import": "./dist/index.js"
|
|
15
|
+
},
|
|
16
|
+
"./vue": {
|
|
17
|
+
"types": "./dist/runtime/vue-plugin.d.ts",
|
|
18
|
+
"import": "./dist/runtime/vue-plugin.js"
|
|
19
|
+
},
|
|
20
|
+
"./middleware": {
|
|
21
|
+
"import": "./dist/middleware.js"
|
|
22
|
+
},
|
|
23
|
+
"./runtime": {
|
|
24
|
+
"types": "./dist/runtime/index.d.ts",
|
|
25
|
+
"import": "./dist/runtime/index.js"
|
|
26
|
+
},
|
|
27
|
+
"./astro": {
|
|
28
|
+
"import": "./src/components/EzI18nHead.astro"
|
|
29
|
+
},
|
|
30
|
+
"./react": {
|
|
31
|
+
"types": "./dist/runtime/react-plugin.d.ts",
|
|
32
|
+
"import": "./dist/runtime/react-plugin.js"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"dist",
|
|
37
|
+
"src"
|
|
38
|
+
],
|
|
39
|
+
"scripts": {
|
|
40
|
+
"build": "tsup",
|
|
41
|
+
"dev": "tsup --watch",
|
|
42
|
+
"typecheck": "tsc --noEmit",
|
|
43
|
+
"prepublishOnly": "pnpm build",
|
|
44
|
+
"release": "pnpm build && npm publish",
|
|
45
|
+
"release:dry": "pnpm build && npm publish --dry-run"
|
|
46
|
+
},
|
|
47
|
+
"keywords": [
|
|
48
|
+
"astro",
|
|
49
|
+
"astro-integration",
|
|
50
|
+
"i18n",
|
|
51
|
+
"internationalization",
|
|
52
|
+
"vue",
|
|
53
|
+
"react",
|
|
54
|
+
"cookie-based",
|
|
55
|
+
"no-url-prefix"
|
|
56
|
+
],
|
|
57
|
+
"author": "Zach Handley <zachhandley@gmail.com>",
|
|
58
|
+
"license": "MIT",
|
|
59
|
+
"homepage": "https://github.com/zachhandley/ez-i18n#readme",
|
|
60
|
+
"repository": {
|
|
61
|
+
"type": "git",
|
|
62
|
+
"url": "git+https://github.com/zachhandley/ez-i18n.git"
|
|
63
|
+
},
|
|
64
|
+
"bugs": {
|
|
65
|
+
"url": "https://github.com/zachhandley/ez-i18n/issues"
|
|
66
|
+
},
|
|
67
|
+
"peerDependencies": {
|
|
68
|
+
"@nanostores/persistent": "^0.10.0",
|
|
69
|
+
"@nanostores/react": "^0.7.0 || ^0.8.0 || ^1.0.0",
|
|
70
|
+
"@nanostores/vue": "^0.10.0",
|
|
71
|
+
"astro": "^4.0.0 || ^5.0.0",
|
|
72
|
+
"nanostores": "^0.9.0 || ^0.10.0 || ^0.11.0",
|
|
73
|
+
"react": "^18.0.0 || ^19.0.0",
|
|
74
|
+
"vue": "^3.4.0"
|
|
75
|
+
},
|
|
76
|
+
"peerDependenciesMeta": {
|
|
77
|
+
"vue": {
|
|
78
|
+
"optional": true
|
|
79
|
+
},
|
|
80
|
+
"react": {
|
|
81
|
+
"optional": true
|
|
82
|
+
},
|
|
83
|
+
"@nanostores/vue": {
|
|
84
|
+
"optional": true
|
|
85
|
+
},
|
|
86
|
+
"@nanostores/react": {
|
|
87
|
+
"optional": true
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
"devDependencies": {
|
|
91
|
+
"@nanostores/persistent": "^0.10.2",
|
|
92
|
+
"@nanostores/react": "^1.0.0",
|
|
93
|
+
"@nanostores/vue": "^0.10.0",
|
|
94
|
+
"@types/node": "^22.0.0",
|
|
95
|
+
"@types/react": "^19.2.7",
|
|
96
|
+
"astro": "^5.1.1",
|
|
97
|
+
"nanostores": "^0.11.3",
|
|
98
|
+
"react": "^19.2.1",
|
|
99
|
+
"tsup": "^8.3.5",
|
|
100
|
+
"typescript": "^5.7.2",
|
|
101
|
+
"vite": "^6.0.3",
|
|
102
|
+
"vue": "^3.5.13"
|
|
103
|
+
},
|
|
104
|
+
"packageManager": "pnpm@10.17.1+sha512.17c560fca4867ae9473a3899ad84a88334914f379be46d455cbf92e5cf4b39d34985d452d2583baf19967fa76cb5c17bc9e245529d0b98745721aa7200ecaf7a"
|
|
105
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { useStore } from '@nanostores/react';
|
|
2
|
+
import { effectiveLocale, translations, setLocale } from './store';
|
|
3
|
+
import type { TranslateFunction } from '../types';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Get nested value from object using dot notation
|
|
7
|
+
*/
|
|
8
|
+
function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
|
|
9
|
+
const keys = path.split('.');
|
|
10
|
+
let value: unknown = obj;
|
|
11
|
+
|
|
12
|
+
for (const key of keys) {
|
|
13
|
+
if (value == null || typeof value !== 'object') {
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
value = (value as Record<string, unknown>)[key];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Interpolate params into string
|
|
24
|
+
*/
|
|
25
|
+
function interpolate(
|
|
26
|
+
str: string,
|
|
27
|
+
params?: Record<string, string | number>
|
|
28
|
+
): string {
|
|
29
|
+
if (!params) return str;
|
|
30
|
+
return str.replace(/\{(\w+)\}/g, (match, key) => {
|
|
31
|
+
return key in params ? String(params[key]) : match;
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* React hook for i18n
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* import { useI18n } from '@zachhandley/ez-i18n/react';
|
|
40
|
+
*
|
|
41
|
+
* function MyComponent() {
|
|
42
|
+
* const { t, locale, setLocale } = useI18n();
|
|
43
|
+
*
|
|
44
|
+
* return (
|
|
45
|
+
* <div>
|
|
46
|
+
* <h1>{t('common.welcome')}</h1>
|
|
47
|
+
* <p>{t('greeting', { name: 'World' })}</p>
|
|
48
|
+
* <button onClick={() => setLocale('es')}>Español</button>
|
|
49
|
+
* </div>
|
|
50
|
+
* );
|
|
51
|
+
* }
|
|
52
|
+
*/
|
|
53
|
+
export function useI18n() {
|
|
54
|
+
const locale = useStore(effectiveLocale);
|
|
55
|
+
const trans = useStore(translations);
|
|
56
|
+
|
|
57
|
+
const t: TranslateFunction = (
|
|
58
|
+
key: string,
|
|
59
|
+
params?: Record<string, string | number>
|
|
60
|
+
): string => {
|
|
61
|
+
const value = getNestedValue(trans, key);
|
|
62
|
+
|
|
63
|
+
if (typeof value !== 'string') {
|
|
64
|
+
if (import.meta.env?.DEV) {
|
|
65
|
+
console.warn('[ez-i18n] Missing translation:', key);
|
|
66
|
+
}
|
|
67
|
+
return key;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return interpolate(value, params);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
t,
|
|
75
|
+
locale,
|
|
76
|
+
setLocale,
|
|
77
|
+
};
|
|
78
|
+
}
|