@zachhandley/ez-i18n 0.1.0

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 ADDED
@@ -0,0 +1,211 @@
1
+ # ez-i18n
2
+
3
+ Cookie-based i18n for Astro + Vue. No URL prefixes, reactive language switching.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pnpm add ez-i18n nanostores @nanostores/persistent
9
+ # If using Vue:
10
+ pnpm add @nanostores/vue
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ### Astro Config
16
+
17
+ ```typescript
18
+ // astro.config.ts
19
+ import { defineConfig } from 'astro/config';
20
+ import vue from '@astrojs/vue';
21
+ import ezI18n from 'ez-i18n';
22
+
23
+ export default defineConfig({
24
+ integrations: [
25
+ vue(),
26
+ ezI18n({
27
+ locales: ['en', 'es', 'fr'],
28
+ defaultLocale: 'en',
29
+ cookieName: 'my-locale', // optional, defaults to 'ez-locale'
30
+ translations: {
31
+ en: './src/i18n/en.ts',
32
+ es: './src/i18n/es.ts',
33
+ fr: './src/i18n/fr.ts',
34
+ },
35
+ }),
36
+ ],
37
+ });
38
+ ```
39
+
40
+ ### Translation Files
41
+
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',
53
+ },
54
+ };
55
+ ```
56
+
57
+ ### Layout Setup
58
+
59
+ Add the `EzI18nHead` component to your layout's head for automatic hydration:
60
+
61
+ ```astro
62
+ ---
63
+ // src/layouts/Layout.astro
64
+ import { EzI18nHead } from 'ez-i18n/astro';
65
+ const { locale, translations } = Astro.locals;
66
+ ---
67
+
68
+ <html lang={locale}>
69
+ <head>
70
+ <meta charset="utf-8" />
71
+ <EzI18nHead locale={locale} translations={translations} />
72
+ </head>
73
+ <body>
74
+ <slot />
75
+ </body>
76
+ </html>
77
+ ```
78
+
79
+ ### In Astro Files
80
+
81
+ ```astro
82
+ ---
83
+ import { t, locale } from 'ez-i18n:runtime';
84
+ // Or access from locals (auto-loaded by middleware):
85
+ const { locale, translations } = Astro.locals;
86
+ ---
87
+
88
+ <h1>{t('common.welcome')}</h1>
89
+ <p>Current locale: {locale}</p>
90
+ ```
91
+
92
+ ### In Vue Components
93
+
94
+ ```vue
95
+ <script setup lang="ts">
96
+ import { useI18n } from 'ez-i18n/vue';
97
+ import { translationLoaders } from 'ez-i18n:translations';
98
+
99
+ const { t, locale, setLocale } = useI18n();
100
+
101
+ // Change locale with dynamic translation loading
102
+ async function switchLocale(newLocale: string) {
103
+ await setLocale(newLocale, {
104
+ loadTranslations: translationLoaders[newLocale],
105
+ });
106
+ }
107
+ </script>
108
+
109
+ <template>
110
+ <!-- Global $t is available automatically -->
111
+ <h1>{{ $t('common.welcome') }}</h1>
112
+
113
+ <!-- Interpolation -->
114
+ <p>{{ $t('greeting', { name: 'World' }) }}</p>
115
+
116
+ <!-- Change language with dynamic loading -->
117
+ <button @click="switchLocale('es')">Español</button>
118
+ <button @click="switchLocale('fr')">Français</button>
119
+ </template>
120
+ ```
121
+
122
+ ### Vue Plugin Setup
123
+
124
+ Register the Vue plugin in your entrypoint:
125
+
126
+ ```typescript
127
+ // src/_vueEntrypoint.ts
128
+ import type { App } from 'vue';
129
+ import { ezI18nVue } from 'ez-i18n/vue';
130
+
131
+ export default (app: App) => {
132
+ app.use(ezI18nVue);
133
+ };
134
+ ```
135
+
136
+ ## Features
137
+
138
+ - **No URL prefixes** - Locale stored in cookie, not URL path
139
+ - **Reactive** - Language changes update immediately without page reload
140
+ - **SSR compatible** - Proper hydration with server-rendered locale
141
+ - **Vue integration** - Global `$t()`, `$locale`, `$setLocale` in templates
142
+ - **Composable API** - `useI18n()` for Composition API usage
143
+ - **Middleware included** - Auto-detects locale from cookie, query param, or Accept-Language header
144
+
145
+ ## Locale Detection Priority
146
+
147
+ 1. `?lang=xx` query parameter
148
+ 2. Cookie value
149
+ 3. Accept-Language header
150
+ 4. Default locale
151
+
152
+ ## API
153
+
154
+ ### `ezI18n(config)`
155
+
156
+ Astro integration function.
157
+
158
+ | Option | Type | Required | Description |
159
+ |--------|------|----------|-------------|
160
+ | `locales` | `string[]` | Yes | Supported locale codes |
161
+ | `defaultLocale` | `string` | Yes | Fallback locale |
162
+ | `cookieName` | `string` | No | Cookie name (default: `'ez-locale'`) |
163
+ | `translations` | `Record<string, string>` | No | Paths to translation files (auto-loaded) |
164
+
165
+ ### `EzI18nHead`
166
+
167
+ Astro component for i18n hydration. Place in your layout's `<head>`.
168
+
169
+ ```astro
170
+ <EzI18nHead locale={Astro.locals.locale} translations={Astro.locals.translations} />
171
+ ```
172
+
173
+ ### `$t(key, params?)`
174
+
175
+ Translate a key with optional interpolation.
176
+
177
+ ```typescript
178
+ $t('greeting'); // "Hello"
179
+ $t('greeting', { name: 'World' }); // "Hello, {name}" -> "Hello, World"
180
+ ```
181
+
182
+ ### `setLocale(locale, options?)`
183
+
184
+ Change the current locale. Updates cookie and triggers reactive update.
185
+
186
+ ```typescript
187
+ // Simple usage
188
+ setLocale('es');
189
+
190
+ // With dynamic translation loading
191
+ import { translationLoaders } from 'ez-i18n:translations';
192
+ setLocale('es', { loadTranslations: translationLoaders['es'] });
193
+ ```
194
+
195
+ ### `useI18n()`
196
+
197
+ Vue composable for Composition API usage.
198
+
199
+ ```typescript
200
+ const { t, locale, setLocale } = useI18n();
201
+ ```
202
+
203
+ ### Virtual Modules
204
+
205
+ - `ez-i18n:config` - Static config (locales, defaultLocale, cookieName)
206
+ - `ez-i18n:runtime` - Runtime functions (t, setLocale, initLocale, locale store)
207
+ - `ez-i18n:translations` - Translation loaders (loadTranslations, translationLoaders)
208
+
209
+ ## License
210
+
211
+ MIT
@@ -0,0 +1,29 @@
1
+ import { AstroIntegration } from 'astro';
2
+ import { E as EzI18nConfig } from './types-DwCG8sp8.js';
3
+ export { T as TranslateFunction } from './types-DwCG8sp8.js';
4
+
5
+ /**
6
+ * ez-i18n Astro integration
7
+ *
8
+ * Provides cookie-based i18n without URL prefixes.
9
+ *
10
+ * @example
11
+ * // astro.config.ts
12
+ * import ezI18n from '@zachhandley/ez-i18n';
13
+ *
14
+ * export default defineConfig({
15
+ * integrations: [
16
+ * ezI18n({
17
+ * locales: ['en', 'es', 'fr'],
18
+ * defaultLocale: 'en',
19
+ * translations: {
20
+ * en: './src/i18n/en.json',
21
+ * es: './src/i18n/es.json',
22
+ * },
23
+ * }),
24
+ * ],
25
+ * });
26
+ */
27
+ declare function ezI18n(config: EzI18nConfig): AstroIntegration;
28
+
29
+ export { EzI18nConfig, ezI18n as default };
package/dist/index.js ADDED
@@ -0,0 +1,212 @@
1
+ // src/vite-plugin.ts
2
+ var VIRTUAL_CONFIG = "ez-i18n:config";
3
+ var VIRTUAL_RUNTIME = "ez-i18n:runtime";
4
+ var VIRTUAL_TRANSLATIONS = "ez-i18n:translations";
5
+ 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
+ function vitePlugin(config) {
15
+ const resolved = resolveConfig(config);
16
+ return {
17
+ name: "ez-i18n-vite",
18
+ enforce: "pre",
19
+ resolveId(id) {
20
+ if (id === VIRTUAL_CONFIG || id === VIRTUAL_RUNTIME || id === VIRTUAL_TRANSLATIONS) {
21
+ return RESOLVED_PREFIX + id;
22
+ }
23
+ return null;
24
+ },
25
+ load(id) {
26
+ if (id === RESOLVED_PREFIX + VIRTUAL_CONFIG) {
27
+ return `
28
+ export const locales = ${JSON.stringify(resolved.locales)};
29
+ export const defaultLocale = ${JSON.stringify(resolved.defaultLocale)};
30
+ export const cookieName = ${JSON.stringify(resolved.cookieName)};
31
+ `;
32
+ }
33
+ if (id === RESOLVED_PREFIX + VIRTUAL_RUNTIME) {
34
+ return `
35
+ import { effectiveLocale, translations, setLocale, initLocale } from '@zachhandley/ez-i18n/runtime';
36
+
37
+ export { setLocale, initLocale };
38
+ export { effectiveLocale as locale };
39
+
40
+ /**
41
+ * Get nested value from object using dot notation
42
+ */
43
+ function getNestedValue(obj, path) {
44
+ const keys = path.split('.');
45
+ let value = obj;
46
+ for (const key of keys) {
47
+ if (value == null || typeof value !== 'object') return undefined;
48
+ value = value[key];
49
+ }
50
+ return value;
51
+ }
52
+
53
+ /**
54
+ * Interpolate params into string
55
+ */
56
+ function interpolate(str, params) {
57
+ if (!params) return str;
58
+ return str.replace(/\\{(\\w+)\\}/g, (match, key) => {
59
+ return key in params ? String(params[key]) : match;
60
+ });
61
+ }
62
+
63
+ /**
64
+ * Translate a key to the current locale
65
+ * @param key - Dot-notation key (e.g., 'common.welcome')
66
+ * @param params - Optional interpolation params
67
+ */
68
+ export function t(key, params) {
69
+ const trans = translations.get();
70
+ const value = getNestedValue(trans, key);
71
+
72
+ if (typeof value !== 'string') {
73
+ if (import.meta.env.DEV) {
74
+ console.warn('[ez-i18n] Missing translation:', key);
75
+ }
76
+ return key;
77
+ }
78
+
79
+ return interpolate(value, params);
80
+ }
81
+ `;
82
+ }
83
+ if (id === RESOLVED_PREFIX + VIRTUAL_TRANSLATIONS) {
84
+ const loaderEntries = Object.entries(resolved.translations).map(([locale, path]) => ` ${JSON.stringify(locale)}: () => import(${JSON.stringify(path)})`).join(",\n");
85
+ return `
86
+ /**
87
+ * Translation loaders for ez-i18n
88
+ * Auto-generated from config
89
+ */
90
+ export const translationLoaders = {
91
+ ${loaderEntries}
92
+ };
93
+
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
+ export async function loadTranslations(locale) {
100
+ const loader = translationLoaders[locale];
101
+ if (!loader) {
102
+ if (import.meta.env.DEV) {
103
+ console.warn('[ez-i18n] No translations configured for locale:', locale);
104
+ }
105
+ return {};
106
+ }
107
+
108
+ try {
109
+ const mod = await loader();
110
+ return mod.default ?? mod;
111
+ } catch (error) {
112
+ if (import.meta.env.DEV) {
113
+ console.error('[ez-i18n] Failed to load translations for locale:', locale, error);
114
+ }
115
+ return {};
116
+ }
117
+ }
118
+ `;
119
+ }
120
+ return null;
121
+ }
122
+ };
123
+ }
124
+
125
+ // src/index.ts
126
+ function ezI18n(config) {
127
+ const resolved = resolveConfig(config);
128
+ return {
129
+ name: "ez-i18n",
130
+ hooks: {
131
+ "astro:config:setup": ({
132
+ updateConfig,
133
+ addMiddleware,
134
+ injectScript
135
+ }) => {
136
+ updateConfig({
137
+ vite: {
138
+ plugins: [vitePlugin(config)]
139
+ }
140
+ });
141
+ addMiddleware({
142
+ entrypoint: "@zachhandley/ez-i18n/middleware",
143
+ order: "pre"
144
+ });
145
+ const hydrationScript = `
146
+ (function() {
147
+ try {
148
+ var cookieName = ${JSON.stringify(resolved.cookieName)};
149
+ var stored = localStorage.getItem(cookieName);
150
+ var cookieMatch = document.cookie.match(new RegExp(cookieName + '=([^;]+)'));
151
+ var cookie = cookieMatch ? cookieMatch[1] : null;
152
+ if (cookie && stored !== cookie) {
153
+ localStorage.setItem(cookieName, cookie);
154
+ }
155
+ } catch (e) {}
156
+ })();
157
+ `;
158
+ injectScript("head-inline", hydrationScript);
159
+ },
160
+ "astro:config:done": ({
161
+ injectTypes
162
+ }) => {
163
+ injectTypes({
164
+ filename: "virtual.d.ts",
165
+ content: `declare module 'ez-i18n:config' {
166
+ /** List of all supported locale codes */
167
+ export const locales: readonly string[];
168
+ /** Default locale when no preference is detected */
169
+ export const defaultLocale: string;
170
+ /** Cookie name used to store locale preference */
171
+ export const cookieName: string;
172
+ }
173
+
174
+ declare module 'ez-i18n:runtime' {
175
+ import type { ReadableAtom } from 'nanostores';
176
+ /** Reactive store containing the current locale */
177
+ export const locale: ReadableAtom<string>;
178
+ /**
179
+ * Translate a key to the current locale
180
+ * @param key - Dot-notation key (e.g., 'common.welcome')
181
+ * @param params - Optional interpolation params for {placeholder} syntax
182
+ */
183
+ export function t(key: string, params?: Record<string, string | number>): string;
184
+ /**
185
+ * Set the current locale and persist to cookie/localStorage
186
+ * @param locale - Locale code to switch to
187
+ * @param cookieName - Optional custom cookie name
188
+ */
189
+ export function setLocale(locale: string, cookieName?: string): Promise<void>;
190
+ /**
191
+ * Initialize the locale store with translations
192
+ * @param locale - Initial locale code
193
+ * @param translations - Optional initial translations object
194
+ */
195
+ export function initLocale(locale: string, translations?: Record<string, unknown>): void;
196
+ }
197
+
198
+ declare module 'ez-i18n:translations' {
199
+ /** Load translations for a specific locale */
200
+ export function loadTranslations(locale: string): Promise<Record<string, unknown>>;
201
+ /** Get the translation loader map from config */
202
+ export const translationLoaders: Record<string, () => Promise<{ default: Record<string, unknown> }>>;
203
+ }
204
+ `
205
+ });
206
+ }
207
+ }
208
+ };
209
+ }
210
+ export {
211
+ ezI18n as default
212
+ };
@@ -0,0 +1,37 @@
1
+ // src/middleware.ts
2
+ import { defineMiddleware } from "astro:middleware";
3
+ var onRequest = defineMiddleware(async ({ cookies, request, locals }, next) => {
4
+ const { locales, defaultLocale, cookieName } = await import("ez-i18n:config");
5
+ const url = new URL(request.url);
6
+ const langParam = url.searchParams.get("lang");
7
+ const cookieValue = cookies.get(cookieName)?.value;
8
+ const acceptLang = request.headers.get("accept-language");
9
+ const browserLang = acceptLang?.split(",")[0]?.split("-")[0];
10
+ let locale = defaultLocale;
11
+ if (langParam && locales.includes(langParam)) {
12
+ locale = langParam;
13
+ } else if (cookieValue && locales.includes(cookieValue)) {
14
+ locale = cookieValue;
15
+ } else if (browserLang && locales.includes(browserLang)) {
16
+ locale = browserLang;
17
+ }
18
+ locals.locale = locale;
19
+ try {
20
+ const { loadTranslations } = await import("ez-i18n:translations");
21
+ locals.translations = await loadTranslations(locale);
22
+ } catch {
23
+ locals.translations = {};
24
+ }
25
+ if (langParam && langParam !== cookieValue && locales.includes(langParam)) {
26
+ cookies.set(cookieName, locale, {
27
+ path: "/",
28
+ maxAge: 60 * 60 * 24 * 365,
29
+ // 1 year
30
+ sameSite: "lax"
31
+ });
32
+ }
33
+ return next();
34
+ });
35
+ export {
36
+ onRequest
37
+ };
@@ -0,0 +1,51 @@
1
+ import * as nanostores from 'nanostores';
2
+
3
+ /**
4
+ * Client-side locale preference (persisted to localStorage)
5
+ */
6
+ declare const localePreference: nanostores.WritableAtom<string>;
7
+ /**
8
+ * Effective locale - uses server locale if set, otherwise client preference
9
+ */
10
+ declare const effectiveLocale: nanostores.ReadableAtom<string>;
11
+ /**
12
+ * Current translations object (reactive)
13
+ */
14
+ declare const translations: nanostores.PreinitializedWritableAtom<Record<string, unknown>> & object;
15
+ /**
16
+ * Whether locale is currently being changed
17
+ */
18
+ declare const localeLoading: nanostores.PreinitializedWritableAtom<boolean> & object;
19
+ /**
20
+ * Initialize locale from server-provided value
21
+ * Called during hydration to sync server and client state
22
+ */
23
+ declare function initLocale(locale: string, trans?: Record<string, unknown>): void;
24
+ /**
25
+ * Set the translations object
26
+ */
27
+ declare function setTranslations(trans: Record<string, unknown>): void;
28
+ /** Type for translation loader function */
29
+ type TranslationLoader = () => Promise<{
30
+ default?: Record<string, unknown>;
31
+ } | Record<string, unknown>>;
32
+ /**
33
+ * Change locale and update cookie
34
+ * Optionally loads new translations dynamically
35
+ * @param locale - New locale code
36
+ * @param options - Options object or cookie name for backwards compatibility
37
+ */
38
+ declare function setLocale(locale: string, options?: string | {
39
+ cookieName?: string;
40
+ loadTranslations?: TranslationLoader;
41
+ }): Promise<void>;
42
+ /**
43
+ * Get current locale value (non-reactive)
44
+ */
45
+ declare function getLocale(): string;
46
+ /**
47
+ * Get current translations (non-reactive)
48
+ */
49
+ declare function getTranslations(): Record<string, unknown>;
50
+
51
+ export { type TranslationLoader, effectiveLocale, getLocale, getTranslations, initLocale, localeLoading, localePreference, setLocale, setTranslations, translations };
@@ -0,0 +1,68 @@
1
+ // src/runtime/store.ts
2
+ import { atom, computed } from "nanostores";
3
+ import { persistentAtom } from "@nanostores/persistent";
4
+ var serverLocale = atom(null);
5
+ var localePreference = persistentAtom("ez-locale", "en", {
6
+ encode: (value) => value,
7
+ decode: (value) => value
8
+ });
9
+ var effectiveLocale = computed(
10
+ [serverLocale, localePreference],
11
+ (server, client) => server ?? client
12
+ );
13
+ var translations = atom({});
14
+ var localeLoading = atom(false);
15
+ function initLocale(locale, trans) {
16
+ serverLocale.set(locale);
17
+ localePreference.set(locale);
18
+ if (trans) {
19
+ translations.set(trans);
20
+ }
21
+ }
22
+ function setTranslations(trans) {
23
+ translations.set(trans);
24
+ }
25
+ async function setLocale(locale, options = {}) {
26
+ const opts = typeof options === "string" ? { cookieName: options } : options;
27
+ const { cookieName = "ez-locale", loadTranslations } = opts;
28
+ localeLoading.set(true);
29
+ try {
30
+ if (loadTranslations) {
31
+ const mod = await loadTranslations();
32
+ const trans = "default" in mod ? mod.default : mod;
33
+ translations.set(trans);
34
+ }
35
+ localePreference.set(locale);
36
+ serverLocale.set(locale);
37
+ if (typeof document !== "undefined") {
38
+ document.cookie = `${cookieName}=${locale}; path=/; max-age=31536000; samesite=lax`;
39
+ }
40
+ if (typeof document !== "undefined") {
41
+ document.dispatchEvent(
42
+ new CustomEvent("ez-i18n:locale-changed", {
43
+ detail: { locale },
44
+ bubbles: true
45
+ })
46
+ );
47
+ }
48
+ } finally {
49
+ localeLoading.set(false);
50
+ }
51
+ }
52
+ function getLocale() {
53
+ return effectiveLocale.get();
54
+ }
55
+ function getTranslations() {
56
+ return translations.get();
57
+ }
58
+ export {
59
+ effectiveLocale,
60
+ getLocale,
61
+ getTranslations,
62
+ initLocale,
63
+ localeLoading,
64
+ localePreference,
65
+ setLocale,
66
+ setTranslations,
67
+ translations
68
+ };
@@ -0,0 +1,52 @@
1
+ import * as vue from 'vue';
2
+ import { Plugin } from 'vue';
3
+ import { setLocale } from './index.js';
4
+ import { T as TranslateFunction } from '../types-DwCG8sp8.js';
5
+ import 'nanostores';
6
+
7
+ /**
8
+ * Vue plugin that provides global $t(), $locale, and $setLocale
9
+ *
10
+ * @example
11
+ * // In _vueEntrypoint.ts or main.ts
12
+ * import { ezI18nVue } from 'ez-i18n/vue';
13
+ *
14
+ * export default (app) => {
15
+ * app.use(ezI18nVue);
16
+ * };
17
+ *
18
+ * @example
19
+ * // In Vue components
20
+ * <template>
21
+ * <h1>{{ $t('welcome.title') }}</h1>
22
+ * <p>{{ $t('welcome.message', { name: userName }) }}</p>
23
+ * <button @click="$setLocale('es')">Español</button>
24
+ * </template>
25
+ */
26
+ declare const ezI18nVue: Plugin;
27
+ /**
28
+ * Composable for using i18n in Vue components with Composition API
29
+ *
30
+ * @example
31
+ * <script setup>
32
+ * import { useI18n } from 'ez-i18n/vue';
33
+ *
34
+ * const { t, locale, setLocale } = useI18n();
35
+ * const greeting = t('welcome.greeting');
36
+ * </script>
37
+ */
38
+ declare function useI18n(): {
39
+ t: TranslateFunction;
40
+ locale: Readonly<vue.Ref<string, string>>;
41
+ setLocale: typeof setLocale;
42
+ };
43
+ declare module 'vue' {
44
+ interface ComponentCustomProperties {
45
+ $t: TranslateFunction;
46
+ /** Current locale (reactive ref from nanostore) */
47
+ $locale: Readonly<vue.Ref<string>>;
48
+ $setLocale: typeof setLocale;
49
+ }
50
+ }
51
+
52
+ export { ezI18nVue as default, ezI18nVue, useI18n };