@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 +211 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.js +212 -0
- package/dist/middleware.js +37 -0
- package/dist/runtime/index.d.ts +51 -0
- package/dist/runtime/index.js +68 -0
- package/dist/runtime/vue-plugin.d.ts +52 -0
- package/dist/runtime/vue-plugin.js +110 -0
- package/dist/types-DwCG8sp8.d.ts +48 -0
- package/package.json +89 -0
- package/src/components/EzI18nHead.astro +62 -0
- package/src/index.ts +122 -0
- package/src/middleware.ts +61 -0
- package/src/runtime/index.ts +19 -0
- package/src/runtime/store.ts +122 -0
- package/src/runtime/vue-plugin.ts +137 -0
- package/src/types.ts +61 -0
- package/src/virtual.d.ts +64 -0
- package/src/vite-plugin.ts +146 -0
|
@@ -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
|
+
}
|