@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.
@@ -0,0 +1,110 @@
1
+ // src/runtime/vue-plugin.ts
2
+ import { computed as computed2 } from "vue";
3
+ import { useStore } from "@nanostores/vue";
4
+
5
+ // src/runtime/store.ts
6
+ import { atom, computed } from "nanostores";
7
+ import { persistentAtom } from "@nanostores/persistent";
8
+ var serverLocale = atom(null);
9
+ var localePreference = persistentAtom("ez-locale", "en", {
10
+ encode: (value) => value,
11
+ decode: (value) => value
12
+ });
13
+ var effectiveLocale = computed(
14
+ [serverLocale, localePreference],
15
+ (server, client) => server ?? client
16
+ );
17
+ var translations = atom({});
18
+ var localeLoading = atom(false);
19
+ async function setLocale(locale, options = {}) {
20
+ const opts = typeof options === "string" ? { cookieName: options } : options;
21
+ const { cookieName = "ez-locale", loadTranslations } = opts;
22
+ localeLoading.set(true);
23
+ try {
24
+ if (loadTranslations) {
25
+ const mod = await loadTranslations();
26
+ const trans = "default" in mod ? mod.default : mod;
27
+ translations.set(trans);
28
+ }
29
+ localePreference.set(locale);
30
+ serverLocale.set(locale);
31
+ if (typeof document !== "undefined") {
32
+ document.cookie = `${cookieName}=${locale}; path=/; max-age=31536000; samesite=lax`;
33
+ }
34
+ if (typeof document !== "undefined") {
35
+ document.dispatchEvent(
36
+ new CustomEvent("ez-i18n:locale-changed", {
37
+ detail: { locale },
38
+ bubbles: true
39
+ })
40
+ );
41
+ }
42
+ } finally {
43
+ localeLoading.set(false);
44
+ }
45
+ }
46
+
47
+ // src/runtime/vue-plugin.ts
48
+ function getNestedValue(obj, path) {
49
+ const keys = path.split(".");
50
+ let value = obj;
51
+ for (const key of keys) {
52
+ if (value == null || typeof value !== "object") {
53
+ return void 0;
54
+ }
55
+ value = value[key];
56
+ }
57
+ return value;
58
+ }
59
+ function interpolate(str, params) {
60
+ if (!params) return str;
61
+ return str.replace(/\{(\w+)\}/g, (match, key) => {
62
+ return key in params ? String(params[key]) : match;
63
+ });
64
+ }
65
+ function createTranslateFunction(translationsRef) {
66
+ return (key, params) => {
67
+ const trans = translationsRef.value;
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
+ }
78
+ var ezI18nVue = {
79
+ install(app) {
80
+ const locale = useStore(effectiveLocale);
81
+ const trans = useStore(translations);
82
+ const transComputed = computed2(() => trans.value);
83
+ const t = createTranslateFunction(transComputed);
84
+ app.config.globalProperties.$t = t;
85
+ app.config.globalProperties.$locale = locale;
86
+ app.config.globalProperties.$setLocale = setLocale;
87
+ app.provide("ez-i18n", {
88
+ t,
89
+ locale,
90
+ setLocale
91
+ });
92
+ }
93
+ };
94
+ function useI18n() {
95
+ const locale = useStore(effectiveLocale);
96
+ const trans = useStore(translations);
97
+ const transComputed = computed2(() => trans.value);
98
+ const t = createTranslateFunction(transComputed);
99
+ return {
100
+ t,
101
+ locale,
102
+ setLocale
103
+ };
104
+ }
105
+ var vue_plugin_default = ezI18nVue;
106
+ export {
107
+ vue_plugin_default as default,
108
+ ezI18nVue,
109
+ useI18n
110
+ };
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Configuration for ez-i18n Astro integration
3
+ */
4
+ interface EzI18nConfig {
5
+ /**
6
+ * List of supported locale codes (e.g., ['en', 'es', 'fr'])
7
+ */
8
+ locales: string[];
9
+ /**
10
+ * Default locale to use when no preference is detected
11
+ */
12
+ defaultLocale: string;
13
+ /**
14
+ * Cookie name for storing locale preference
15
+ * @default 'ez-locale'
16
+ */
17
+ cookieName?: string;
18
+ /**
19
+ * Translation file paths for each locale.
20
+ * Paths are relative to your project root.
21
+ * Dynamic imports will be generated for code splitting.
22
+ * @example
23
+ * translations: {
24
+ * en: './src/i18n/en.json',
25
+ * es: './src/i18n/es.json',
26
+ * }
27
+ */
28
+ translations?: Record<string, string>;
29
+ }
30
+ /**
31
+ * Translation function type
32
+ */
33
+ type TranslateFunction = (key: string, params?: Record<string, string | number>) => string;
34
+ /**
35
+ * Augment Astro's locals type
36
+ */
37
+ declare global {
38
+ namespace App {
39
+ interface Locals {
40
+ /** Current locale code */
41
+ locale: string;
42
+ /** Loaded translations for the current locale */
43
+ translations: Record<string, unknown>;
44
+ }
45
+ }
46
+ }
47
+
48
+ export type { EzI18nConfig as E, TranslateFunction as T };
package/package.json ADDED
@@ -0,0 +1,89 @@
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
+ }
@@ -0,0 +1,62 @@
1
+ ---
2
+ /**
3
+ * EzI18nHead - Astro component for i18n hydration setup
4
+ *
5
+ * Place this in your layout's <head> to properly initialize
6
+ * the i18n runtime with server-side loaded translations.
7
+ *
8
+ * @example
9
+ * ---
10
+ * import { EzI18nHead } from '@zachhandley/ez-i18n/astro';
11
+ * const { locale, translations } = Astro.locals;
12
+ * ---
13
+ * <html lang={locale}>
14
+ * <head>
15
+ * <EzI18nHead locale={locale} translations={translations} />
16
+ * </head>
17
+ * <body><slot /></body>
18
+ * </html>
19
+ */
20
+ interface Props {
21
+ /** Current locale code from Astro.locals.locale */
22
+ locale: string;
23
+ /** Translations object from Astro.locals.translations */
24
+ translations: Record<string, unknown>;
25
+ }
26
+
27
+ const { locale, translations } = Astro.props;
28
+
29
+ // Serialize translations for inline script
30
+ const serializedTranslations = JSON.stringify(translations);
31
+ ---
32
+
33
+ <script
34
+ is:inline
35
+ data-ez-i18n-locale={locale}
36
+ data-ez-i18n-translations={serializedTranslations}
37
+ >
38
+ // Initialize ez-i18n runtime with server-provided values
39
+ (function() {
40
+ const script = document.currentScript;
41
+ if (!script) return;
42
+
43
+ const locale = script.dataset.ezI18nLocale;
44
+ const translations = JSON.parse(script.dataset.ezI18nTranslations || '{}');
45
+
46
+ // Store for runtime initialization
47
+ window.__EZ_I18N_INIT__ = { locale, translations };
48
+ })();
49
+ </script>
50
+
51
+ <script>
52
+ // Import and initialize the runtime stores
53
+ import { initLocale, setTranslations } from '@zachhandley/ez-i18n/runtime';
54
+
55
+ // Get initialization data from inline script
56
+ const initData = (window as any).__EZ_I18N_INIT__;
57
+ if (initData) {
58
+ initLocale(initData.locale, initData.translations);
59
+ setTranslations(initData.translations);
60
+ delete (window as any).__EZ_I18N_INIT__;
61
+ }
62
+ </script>
package/src/index.ts ADDED
@@ -0,0 +1,122 @@
1
+ import type { AstroIntegration, HookParameters } from 'astro';
2
+ import type { EzI18nConfig } from './types';
3
+ import { vitePlugin, resolveConfig } from './vite-plugin';
4
+
5
+ export type { EzI18nConfig, TranslateFunction } from './types';
6
+
7
+ /**
8
+ * ez-i18n Astro integration
9
+ *
10
+ * Provides cookie-based i18n without URL prefixes.
11
+ *
12
+ * @example
13
+ * // astro.config.ts
14
+ * import ezI18n from '@zachhandley/ez-i18n';
15
+ *
16
+ * export default defineConfig({
17
+ * integrations: [
18
+ * ezI18n({
19
+ * locales: ['en', 'es', 'fr'],
20
+ * defaultLocale: 'en',
21
+ * translations: {
22
+ * en: './src/i18n/en.json',
23
+ * es: './src/i18n/es.json',
24
+ * },
25
+ * }),
26
+ * ],
27
+ * });
28
+ */
29
+ export default function ezI18n(config: EzI18nConfig): AstroIntegration {
30
+ const resolved = resolveConfig(config);
31
+
32
+ return {
33
+ name: 'ez-i18n',
34
+ hooks: {
35
+ 'astro:config:setup': ({
36
+ updateConfig,
37
+ addMiddleware,
38
+ injectScript,
39
+ }: HookParameters<'astro:config:setup'>) => {
40
+ // Add Vite plugin for virtual modules
41
+ updateConfig({
42
+ vite: {
43
+ plugins: [vitePlugin(config)],
44
+ },
45
+ });
46
+
47
+ // Add locale detection middleware
48
+ addMiddleware({
49
+ entrypoint: '@zachhandley/ez-i18n/middleware',
50
+ order: 'pre',
51
+ });
52
+
53
+ // Inject hydration script to sync localStorage with cookie
54
+ // This prevents hydration mismatch when server and client disagree
55
+ const hydrationScript = `
56
+ (function() {
57
+ try {
58
+ var cookieName = ${JSON.stringify(resolved.cookieName)};
59
+ var stored = localStorage.getItem(cookieName);
60
+ var cookieMatch = document.cookie.match(new RegExp(cookieName + '=([^;]+)'));
61
+ var cookie = cookieMatch ? cookieMatch[1] : null;
62
+ if (cookie && stored !== cookie) {
63
+ localStorage.setItem(cookieName, cookie);
64
+ }
65
+ } catch (e) {}
66
+ })();
67
+ `;
68
+ injectScript('head-inline', hydrationScript);
69
+ },
70
+
71
+ 'astro:config:done': ({
72
+ injectTypes,
73
+ }: HookParameters<'astro:config:done'>) => {
74
+ // Inject type declarations for virtual modules
75
+ injectTypes({
76
+ filename: 'virtual.d.ts',
77
+ content: `\
78
+ declare module 'ez-i18n:config' {
79
+ /** List of all supported locale codes */
80
+ export const locales: readonly string[];
81
+ /** Default locale when no preference is detected */
82
+ export const defaultLocale: string;
83
+ /** Cookie name used to store locale preference */
84
+ export const cookieName: string;
85
+ }
86
+
87
+ declare module 'ez-i18n:runtime' {
88
+ import type { ReadableAtom } from 'nanostores';
89
+ /** Reactive store containing the current locale */
90
+ export const locale: ReadableAtom<string>;
91
+ /**
92
+ * Translate a key to the current locale
93
+ * @param key - Dot-notation key (e.g., 'common.welcome')
94
+ * @param params - Optional interpolation params for {placeholder} syntax
95
+ */
96
+ export function t(key: string, params?: Record<string, string | number>): string;
97
+ /**
98
+ * Set the current locale and persist to cookie/localStorage
99
+ * @param locale - Locale code to switch to
100
+ * @param cookieName - Optional custom cookie name
101
+ */
102
+ export function setLocale(locale: string, cookieName?: string): Promise<void>;
103
+ /**
104
+ * Initialize the locale store with translations
105
+ * @param locale - Initial locale code
106
+ * @param translations - Optional initial translations object
107
+ */
108
+ export function initLocale(locale: string, translations?: Record<string, unknown>): void;
109
+ }
110
+
111
+ declare module 'ez-i18n:translations' {
112
+ /** Load translations for a specific locale */
113
+ export function loadTranslations(locale: string): Promise<Record<string, unknown>>;
114
+ /** Get the translation loader map from config */
115
+ export const translationLoaders: Record<string, () => Promise<{ default: Record<string, unknown> }>>;
116
+ }
117
+ `,
118
+ });
119
+ },
120
+ },
121
+ };
122
+ }
@@ -0,0 +1,61 @@
1
+ import { defineMiddleware } from 'astro:middleware';
2
+
3
+ /**
4
+ * Locale detection middleware for ez-i18n
5
+ *
6
+ * Detection priority:
7
+ * 1. ?lang query parameter (allows explicit switching)
8
+ * 2. Cookie value
9
+ * 3. Accept-Language header
10
+ * 4. Default locale
11
+ */
12
+ export const onRequest = defineMiddleware(async ({ cookies, request, locals }, next) => {
13
+ // Import config from virtual module (provided by vite-plugin)
14
+ const { locales, defaultLocale, cookieName } = await import('ez-i18n:config');
15
+
16
+ const url = new URL(request.url);
17
+
18
+ // Priority 1: Query parameter
19
+ const langParam = url.searchParams.get('lang');
20
+
21
+ // Priority 2: Cookie
22
+ const cookieValue = cookies.get(cookieName)?.value;
23
+
24
+ // Priority 3: Accept-Language header
25
+ const acceptLang = request.headers.get('accept-language');
26
+ const browserLang = acceptLang?.split(',')[0]?.split('-')[0];
27
+
28
+ // Determine locale with priority
29
+ let locale = defaultLocale;
30
+
31
+ if (langParam && locales.includes(langParam)) {
32
+ locale = langParam;
33
+ } else if (cookieValue && locales.includes(cookieValue)) {
34
+ locale = cookieValue;
35
+ } else if (browserLang && locales.includes(browserLang)) {
36
+ locale = browserLang;
37
+ }
38
+
39
+ // Set locale on locals for use in pages
40
+ locals.locale = locale;
41
+
42
+ // Load translations for the current locale
43
+ try {
44
+ const { loadTranslations } = await import('ez-i18n:translations');
45
+ locals.translations = await loadTranslations(locale);
46
+ } catch {
47
+ // Fallback to empty translations if loader not configured
48
+ locals.translations = {};
49
+ }
50
+
51
+ // Update cookie if changed via query param
52
+ if (langParam && langParam !== cookieValue && locales.includes(langParam)) {
53
+ cookies.set(cookieName, locale, {
54
+ path: '/',
55
+ maxAge: 60 * 60 * 24 * 365, // 1 year
56
+ sameSite: 'lax',
57
+ });
58
+ }
59
+
60
+ return next();
61
+ });
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Runtime exports for ez-i18n
3
+ *
4
+ * This module is imported by the ez-i18n:runtime virtual module
5
+ * and can also be used directly in Vue components
6
+ */
7
+ export {
8
+ effectiveLocale,
9
+ translations,
10
+ localePreference,
11
+ localeLoading,
12
+ initLocale,
13
+ setLocale,
14
+ setTranslations,
15
+ getLocale,
16
+ getTranslations,
17
+ } from './store';
18
+
19
+ export type { TranslationLoader } from './store';
@@ -0,0 +1,122 @@
1
+ import { atom, computed } from 'nanostores';
2
+ import { persistentAtom } from '@nanostores/persistent';
3
+
4
+ /**
5
+ * Server-provided locale (set during SSR/hydration)
6
+ * Takes precedence over client preference to prevent hydration mismatch
7
+ */
8
+ const serverLocale = atom<string | null>(null);
9
+
10
+ /**
11
+ * Client-side locale preference (persisted to localStorage)
12
+ */
13
+ export const localePreference = persistentAtom<string>('ez-locale', 'en', {
14
+ encode: (value) => value,
15
+ decode: (value) => value,
16
+ });
17
+
18
+ /**
19
+ * Effective locale - uses server locale if set, otherwise client preference
20
+ */
21
+ export const effectiveLocale = computed(
22
+ [serverLocale, localePreference],
23
+ (server, client) => server ?? client
24
+ );
25
+
26
+ /**
27
+ * Current translations object (reactive)
28
+ */
29
+ export const translations = atom<Record<string, unknown>>({});
30
+
31
+ /**
32
+ * Whether locale is currently being changed
33
+ */
34
+ export const localeLoading = atom<boolean>(false);
35
+
36
+ /**
37
+ * Initialize locale from server-provided value
38
+ * Called during hydration to sync server and client state
39
+ */
40
+ export function initLocale(locale: string, trans?: Record<string, unknown>): void {
41
+ serverLocale.set(locale);
42
+ localePreference.set(locale);
43
+ if (trans) {
44
+ translations.set(trans);
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Set the translations object
50
+ */
51
+ export function setTranslations(trans: Record<string, unknown>): void {
52
+ translations.set(trans);
53
+ }
54
+
55
+ /** Type for translation loader function */
56
+ export type TranslationLoader = () => Promise<{ default?: Record<string, unknown> } | Record<string, unknown>>;
57
+
58
+ /**
59
+ * Change locale and update cookie
60
+ * Optionally loads new translations dynamically
61
+ * @param locale - New locale code
62
+ * @param options - Options object or cookie name for backwards compatibility
63
+ */
64
+ export async function setLocale(
65
+ locale: string,
66
+ options: string | {
67
+ cookieName?: string;
68
+ loadTranslations?: TranslationLoader;
69
+ } = {}
70
+ ): Promise<void> {
71
+ // Handle backwards compatibility with string cookieName
72
+ const opts = typeof options === 'string'
73
+ ? { cookieName: options }
74
+ : options;
75
+ const { cookieName = 'ez-locale', loadTranslations } = opts;
76
+
77
+ localeLoading.set(true);
78
+
79
+ try {
80
+ // Load new translations if loader provided
81
+ if (loadTranslations) {
82
+ const mod = await loadTranslations();
83
+ const trans = 'default' in mod ? mod.default : mod;
84
+ translations.set(trans as Record<string, unknown>);
85
+ }
86
+
87
+ // Update stores
88
+ localePreference.set(locale);
89
+ serverLocale.set(locale);
90
+
91
+ // Update cookie
92
+ if (typeof document !== 'undefined') {
93
+ document.cookie = `${cookieName}=${locale}; path=/; max-age=31536000; samesite=lax`;
94
+ }
95
+
96
+ // Dispatch event for components that need to react
97
+ if (typeof document !== 'undefined') {
98
+ document.dispatchEvent(
99
+ new CustomEvent('ez-i18n:locale-changed', {
100
+ detail: { locale },
101
+ bubbles: true,
102
+ })
103
+ );
104
+ }
105
+ } finally {
106
+ localeLoading.set(false);
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Get current locale value (non-reactive)
112
+ */
113
+ export function getLocale(): string {
114
+ return effectiveLocale.get();
115
+ }
116
+
117
+ /**
118
+ * Get current translations (non-reactive)
119
+ */
120
+ export function getTranslations(): Record<string, unknown> {
121
+ return translations.get();
122
+ }