@zachhandley/ez-i18n 0.1.0 → 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
- # ez-i18n
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
- pnpm add ez-i18n nanostores @nanostores/persistent
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
@@ -18,7 +22,7 @@ pnpm add @nanostores/vue
18
22
  // astro.config.ts
19
23
  import { defineConfig } from 'astro/config';
20
24
  import vue from '@astrojs/vue';
21
- import ezI18n from 'ez-i18n';
25
+ import ezI18n from '@zachhandley/ez-i18n';
22
26
 
23
27
  export default defineConfig({
24
28
  integrations: [
@@ -28,9 +32,9 @@ export default defineConfig({
28
32
  defaultLocale: 'en',
29
33
  cookieName: 'my-locale', // optional, defaults to 'ez-locale'
30
34
  translations: {
31
- en: './src/i18n/en.ts',
32
- es: './src/i18n/es.ts',
33
- fr: './src/i18n/fr.ts',
35
+ en: './src/i18n/en.json',
36
+ es: './src/i18n/es.json',
37
+ fr: './src/i18n/fr.json',
34
38
  },
35
39
  }),
36
40
  ],
@@ -39,21 +43,22 @@ export default defineConfig({
39
43
 
40
44
  ### Translation Files
41
45
 
42
- ```typescript
43
- // src/i18n/en.ts
44
- export default {
45
- common: {
46
- welcome: 'Welcome',
47
- save: 'Save',
48
- cancel: 'Cancel',
49
- },
50
- auth: {
51
- login: 'Log in',
52
- signup: 'Sign up',
46
+ ```json
47
+ {
48
+ "common": {
49
+ "welcome": "Welcome",
50
+ "save": "Save",
51
+ "cancel": "Cancel"
53
52
  },
54
- };
53
+ "auth": {
54
+ "login": "Log in",
55
+ "signup": "Sign up"
56
+ }
57
+ }
55
58
  ```
56
59
 
60
+ Create similar files for each locale: `src/i18n/en.json`, `src/i18n/es.json`, etc.
61
+
57
62
  ### Layout Setup
58
63
 
59
64
  Add the `EzI18nHead` component to your layout's head for automatic hydration:
@@ -61,7 +66,7 @@ Add the `EzI18nHead` component to your layout's head for automatic hydration:
61
66
  ```astro
62
67
  ---
63
68
  // src/layouts/Layout.astro
64
- import { EzI18nHead } from 'ez-i18n/astro';
69
+ import EzI18nHead from '@zachhandley/ez-i18n/astro';
65
70
  const { locale, translations } = Astro.locals;
66
71
  ---
67
72
 
@@ -93,7 +98,7 @@ const { locale, translations } = Astro.locals;
93
98
 
94
99
  ```vue
95
100
  <script setup lang="ts">
96
- import { useI18n } from 'ez-i18n/vue';
101
+ import { useI18n } from '@zachhandley/ez-i18n/vue';
97
102
  import { translationLoaders } from 'ez-i18n:translations';
98
103
 
99
104
  const { t, locale, setLocale } = useI18n();
@@ -126,20 +131,46 @@ Register the Vue plugin in your entrypoint:
126
131
  ```typescript
127
132
  // src/_vueEntrypoint.ts
128
133
  import type { App } from 'vue';
129
- import { ezI18nVue } from 'ez-i18n/vue';
134
+ import { ezI18nVue } from '@zachhandley/ez-i18n/vue';
130
135
 
131
136
  export default (app: App) => {
132
137
  app.use(ezI18nVue);
133
138
  };
134
139
  ```
135
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
+
136
167
  ## Features
137
168
 
138
169
  - **No URL prefixes** - Locale stored in cookie, not URL path
139
170
  - **Reactive** - Language changes update immediately without page reload
140
171
  - **SSR compatible** - Proper hydration with server-rendered locale
141
172
  - **Vue integration** - Global `$t()`, `$locale`, `$setLocale` in templates
142
- - **Composable API** - `useI18n()` for Composition API usage
173
+ - **React integration** - `useI18n()` hook for React components
143
174
  - **Middleware included** - Auto-detects locale from cookie, query param, or Accept-Language header
144
175
 
145
176
  ## Locale Detection Priority
@@ -194,9 +225,15 @@ setLocale('es', { loadTranslations: translationLoaders['es'] });
194
225
 
195
226
  ### `useI18n()`
196
227
 
197
- Vue composable for Composition API usage.
228
+ Hook for Vue (Composition API) and React.
198
229
 
199
230
  ```typescript
231
+ // Vue
232
+ import { useI18n } from '@zachhandley/ez-i18n/vue';
233
+
234
+ // React
235
+ import { useI18n } from '@zachhandley/ez-i18n/react';
236
+
200
237
  const { t, locale, setLocale } = useI18n();
201
238
  ```
202
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
+ };
@@ -9,7 +9,7 @@ import 'nanostores';
9
9
  *
10
10
  * @example
11
11
  * // In _vueEntrypoint.ts or main.ts
12
- * import { ezI18nVue } from 'ez-i18n/vue';
12
+ * import { ezI18nVue } from '@zachhandley/ez-i18n/vue';
13
13
  *
14
14
  * export default (app) => {
15
15
  * app.use(ezI18nVue);
@@ -29,7 +29,7 @@ declare const ezI18nVue: Plugin;
29
29
  *
30
30
  * @example
31
31
  * <script setup>
32
- * import { useI18n } from 'ez-i18n/vue';
32
+ * import { useI18n } from '@zachhandley/ez-i18n/vue';
33
33
  *
34
34
  * const { t, locale, setLocale } = useI18n();
35
35
  * const greeting = t('welcome.greeting');
package/package.json CHANGED
@@ -1,89 +1,105 @@
1
- {
2
- "name": "@zachhandley/ez-i18n",
3
- "version": "0.1.0",
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
+ }
@@ -60,7 +60,7 @@ function createTranslateFunction(
60
60
  *
61
61
  * @example
62
62
  * // In _vueEntrypoint.ts or main.ts
63
- * import { ezI18nVue } from 'ez-i18n/vue';
63
+ * import { ezI18nVue } from '@zachhandley/ez-i18n/vue';
64
64
  *
65
65
  * export default (app) => {
66
66
  * app.use(ezI18nVue);
@@ -105,7 +105,7 @@ export const ezI18nVue: Plugin = {
105
105
  *
106
106
  * @example
107
107
  * <script setup>
108
- * import { useI18n } from 'ez-i18n/vue';
108
+ * import { useI18n } from '@zachhandley/ez-i18n/vue';
109
109
  *
110
110
  * const { t, locale, setLocale } = useI18n();
111
111
  * const greeting = t('welcome.greeting');