@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 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 { EzI18nHead } from '@zachhandley/ez-i18n/astro';
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
- - **Composable API** - `useI18n()` for Composition API usage
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
- Vue composable for Composition API usage.
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.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
- "files": [
32
- "dist",
33
- "src"
34
- ],
35
- "scripts": {
36
- "build": "tsup",
37
- "dev": "tsup --watch",
38
- "typecheck": "tsc --noEmit",
39
- "prepublishOnly": "pnpm build",
40
- "release": "pnpm build && npm publish",
41
- "release:dry": "pnpm build && npm publish --dry-run"
42
- },
43
- "keywords": [
44
- "astro",
45
- "astro-integration",
46
- "i18n",
47
- "internationalization",
48
- "vue",
49
- "cookie-based",
50
- "no-url-prefix"
51
- ],
52
- "author": "Zach Handley <zachhandley@gmail.com>",
53
- "license": "MIT",
54
- "homepage": "https://github.com/zachhandley/ez-i18n#readme",
55
- "repository": {
56
- "type": "git",
57
- "url": "git+https://github.com/zachhandley/ez-i18n.git"
58
- },
59
- "bugs": {
60
- "url": "https://github.com/zachhandley/ez-i18n/issues"
61
- },
62
- "peerDependencies": {
63
- "astro": "^4.0.0 || ^5.0.0",
64
- "vue": "^3.4.0",
65
- "nanostores": "^0.9.0 || ^0.10.0 || ^0.11.0",
66
- "@nanostores/persistent": "^0.10.0",
67
- "@nanostores/vue": "^0.10.0"
68
- },
69
- "peerDependenciesMeta": {
70
- "vue": {
71
- "optional": true
72
- },
73
- "@nanostores/vue": {
74
- "optional": true
75
- }
76
- },
77
- "devDependencies": {
78
- "@types/node": "^22.0.0",
79
- "astro": "^5.1.1",
80
- "nanostores": "^0.11.3",
81
- "@nanostores/persistent": "^0.10.2",
82
- "@nanostores/vue": "^0.10.0",
83
- "tsup": "^8.3.5",
84
- "typescript": "^5.7.2",
85
- "vue": "^3.5.13",
86
- "vite": "^6.0.3"
87
- },
88
- "packageManager": "pnpm@10.17.1+sha512.17c560fca4867ae9473a3899ad84a88334914f379be46d455cbf92e5cf4b39d34985d452d2583baf19967fa76cb5c17bc9e245529d0b98745721aa7200ecaf7a"
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
+ }
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * @example
9
9
  * ---
10
- * import { EzI18nHead } from '@zachhandley/ez-i18n/astro';
10
+ * import EzI18nHead from '@zachhandley/ez-i18n/astro';
11
11
  * const { locale, translations } = Astro.locals;
12
12
  * ---
13
13
  * <html lang={locale}>
@@ -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
+ }