@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,137 @@
1
+ import type { App, Plugin, ComputedRef } from 'vue';
2
+ import { computed } from 'vue';
3
+ import { useStore } from '@nanostores/vue';
4
+ import { effectiveLocale, translations, setLocale } from './store';
5
+ import type { TranslateFunction } from '../types';
6
+
7
+ /**
8
+ * Get nested value from object using dot notation
9
+ */
10
+ function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
11
+ const keys = path.split('.');
12
+ let value: unknown = obj;
13
+
14
+ for (const key of keys) {
15
+ if (value == null || typeof value !== 'object') {
16
+ return undefined;
17
+ }
18
+ value = (value as Record<string, unknown>)[key];
19
+ }
20
+
21
+ return value;
22
+ }
23
+
24
+ /**
25
+ * Interpolate params into string
26
+ */
27
+ function interpolate(
28
+ str: string,
29
+ params?: Record<string, string | number>
30
+ ): string {
31
+ if (!params) return str;
32
+ return str.replace(/\{(\w+)\}/g, (match, key) => {
33
+ return key in params ? String(params[key]) : match;
34
+ });
35
+ }
36
+
37
+ /**
38
+ * Create a translation function bound to a translations object
39
+ */
40
+ function createTranslateFunction(
41
+ translationsRef: ComputedRef<Record<string, unknown>>
42
+ ): TranslateFunction {
43
+ return (key: string, params?: Record<string, string | number>): string => {
44
+ const trans = translationsRef.value;
45
+ const value = getNestedValue(trans, key);
46
+
47
+ if (typeof value !== 'string') {
48
+ if (import.meta.env?.DEV) {
49
+ console.warn('[ez-i18n] Missing translation:', key);
50
+ }
51
+ return key;
52
+ }
53
+
54
+ return interpolate(value, params);
55
+ };
56
+ }
57
+
58
+ /**
59
+ * Vue plugin that provides global $t(), $locale, and $setLocale
60
+ *
61
+ * @example
62
+ * // In _vueEntrypoint.ts or main.ts
63
+ * import { ezI18nVue } from 'ez-i18n/vue';
64
+ *
65
+ * export default (app) => {
66
+ * app.use(ezI18nVue);
67
+ * };
68
+ *
69
+ * @example
70
+ * // In Vue components
71
+ * <template>
72
+ * <h1>{{ $t('welcome.title') }}</h1>
73
+ * <p>{{ $t('welcome.message', { name: userName }) }}</p>
74
+ * <button @click="$setLocale('es')">Español</button>
75
+ * </template>
76
+ */
77
+ export const ezI18nVue: Plugin = {
78
+ install(app: App) {
79
+ // Get reactive store values
80
+ const locale = useStore(effectiveLocale);
81
+ const trans = useStore(translations);
82
+
83
+ // Create reactive computed for translations
84
+ const transComputed = computed(() => trans.value);
85
+
86
+ // Create translate function
87
+ const t = createTranslateFunction(transComputed);
88
+
89
+ // Add global properties
90
+ app.config.globalProperties.$t = t;
91
+ app.config.globalProperties.$locale = locale;
92
+ app.config.globalProperties.$setLocale = setLocale;
93
+
94
+ // Also provide for composition API usage
95
+ app.provide('ez-i18n', {
96
+ t,
97
+ locale,
98
+ setLocale,
99
+ });
100
+ },
101
+ };
102
+
103
+ /**
104
+ * Composable for using i18n in Vue components with Composition API
105
+ *
106
+ * @example
107
+ * <script setup>
108
+ * import { useI18n } from 'ez-i18n/vue';
109
+ *
110
+ * const { t, locale, setLocale } = useI18n();
111
+ * const greeting = t('welcome.greeting');
112
+ * </script>
113
+ */
114
+ export function useI18n() {
115
+ const locale = useStore(effectiveLocale);
116
+ const trans = useStore(translations);
117
+ const transComputed = computed(() => trans.value);
118
+ const t = createTranslateFunction(transComputed);
119
+
120
+ return {
121
+ t,
122
+ locale,
123
+ setLocale,
124
+ };
125
+ }
126
+
127
+ // Type augmentation for Vue global properties
128
+ declare module 'vue' {
129
+ interface ComponentCustomProperties {
130
+ $t: TranslateFunction;
131
+ /** Current locale (reactive ref from nanostore) */
132
+ $locale: Readonly<import('vue').Ref<string>>;
133
+ $setLocale: typeof setLocale;
134
+ }
135
+ }
136
+
137
+ export default ezI18nVue;
package/src/types.ts ADDED
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Configuration for ez-i18n Astro integration
3
+ */
4
+ export interface EzI18nConfig {
5
+ /**
6
+ * List of supported locale codes (e.g., ['en', 'es', 'fr'])
7
+ */
8
+ locales: string[];
9
+
10
+ /**
11
+ * Default locale to use when no preference is detected
12
+ */
13
+ defaultLocale: string;
14
+
15
+ /**
16
+ * Cookie name for storing locale preference
17
+ * @default 'ez-locale'
18
+ */
19
+ cookieName?: string;
20
+
21
+ /**
22
+ * Translation file paths for each locale.
23
+ * Paths are relative to your project root.
24
+ * Dynamic imports will be generated for code splitting.
25
+ * @example
26
+ * translations: {
27
+ * en: './src/i18n/en.json',
28
+ * es: './src/i18n/es.json',
29
+ * }
30
+ */
31
+ translations?: Record<string, string>;
32
+ }
33
+
34
+ /**
35
+ * Resolved config with defaults applied
36
+ */
37
+ export interface ResolvedEzI18nConfig {
38
+ locales: string[];
39
+ defaultLocale: string;
40
+ cookieName: string;
41
+ translations: Record<string, string>;
42
+ }
43
+
44
+ /**
45
+ * Translation function type
46
+ */
47
+ export type TranslateFunction = (key: string, params?: Record<string, string | number>) => string;
48
+
49
+ /**
50
+ * Augment Astro's locals type
51
+ */
52
+ declare global {
53
+ namespace App {
54
+ interface Locals {
55
+ /** Current locale code */
56
+ locale: string;
57
+ /** Loaded translations for the current locale */
58
+ translations: Record<string, unknown>;
59
+ }
60
+ }
61
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Type declarations for ez-i18n virtual modules
3
+ *
4
+ * Note: These are also injected via injectTypes() for consumer projects.
5
+ * This file provides types during package development.
6
+ */
7
+
8
+ declare module 'ez-i18n:config' {
9
+ /** List of all supported locale codes */
10
+ export const locales: readonly string[];
11
+ /** Default locale when no preference is detected */
12
+ export const defaultLocale: string;
13
+ /** Cookie name used to store locale preference */
14
+ export const cookieName: string;
15
+ }
16
+
17
+ declare module 'ez-i18n:runtime' {
18
+ import type { ReadableAtom } from 'nanostores';
19
+
20
+ /** Reactive store containing the current locale */
21
+ export const locale: ReadableAtom<string>;
22
+
23
+ /**
24
+ * Translate a key to the current locale
25
+ * @param key - Dot-notation key (e.g., 'common.welcome')
26
+ * @param params - Optional interpolation params for {placeholder} syntax
27
+ */
28
+ export function t(key: string, params?: Record<string, string | number>): string;
29
+
30
+ /**
31
+ * Set the current locale and persist to cookie/localStorage
32
+ * @param locale - Locale code to switch to
33
+ * @param options - Cookie name string or options object
34
+ */
35
+ export function setLocale(
36
+ locale: string,
37
+ options?: string | {
38
+ cookieName?: string;
39
+ loadTranslations?: () => Promise<{ default?: Record<string, unknown> } | Record<string, unknown>>;
40
+ }
41
+ ): Promise<void>;
42
+
43
+ /**
44
+ * Initialize the locale store with translations
45
+ * @param locale - Initial locale code
46
+ * @param translations - Optional initial translations object
47
+ */
48
+ export function initLocale(locale: string, translations?: Record<string, unknown>): void;
49
+ }
50
+
51
+ declare module 'ez-i18n:translations' {
52
+ /** Map of locale codes to their translation loaders */
53
+ export const translationLoaders: Record<
54
+ string,
55
+ () => Promise<{ default?: Record<string, unknown> } | Record<string, unknown>>
56
+ >;
57
+
58
+ /**
59
+ * Load translations for a specific locale
60
+ * @param locale - Locale code to load translations for
61
+ * @returns Translations object or empty object if not found
62
+ */
63
+ export function loadTranslations(locale: string): Promise<Record<string, unknown>>;
64
+ }
@@ -0,0 +1,146 @@
1
+ import type { Plugin } from 'vite';
2
+ import type { EzI18nConfig, ResolvedEzI18nConfig } from './types';
3
+
4
+ const VIRTUAL_CONFIG = 'ez-i18n:config';
5
+ const VIRTUAL_RUNTIME = 'ez-i18n:runtime';
6
+ const VIRTUAL_TRANSLATIONS = 'ez-i18n:translations';
7
+ const RESOLVED_PREFIX = '\0';
8
+
9
+ /**
10
+ * Resolve config with defaults
11
+ */
12
+ export function resolveConfig(config: EzI18nConfig): ResolvedEzI18nConfig {
13
+ return {
14
+ locales: config.locales,
15
+ defaultLocale: config.defaultLocale,
16
+ cookieName: config.cookieName ?? 'ez-locale',
17
+ translations: config.translations ?? {},
18
+ };
19
+ }
20
+
21
+ /**
22
+ * Vite plugin that provides virtual modules for ez-i18n
23
+ */
24
+ export function vitePlugin(config: EzI18nConfig): Plugin {
25
+ const resolved = resolveConfig(config);
26
+
27
+ return {
28
+ name: 'ez-i18n-vite',
29
+ enforce: 'pre',
30
+
31
+ resolveId(id) {
32
+ if (id === VIRTUAL_CONFIG || id === VIRTUAL_RUNTIME || id === VIRTUAL_TRANSLATIONS) {
33
+ return RESOLVED_PREFIX + id;
34
+ }
35
+ return null;
36
+ },
37
+
38
+ load(id) {
39
+ // ez-i18n:config - Static config values
40
+ if (id === RESOLVED_PREFIX + VIRTUAL_CONFIG) {
41
+ return `
42
+ export const locales = ${JSON.stringify(resolved.locales)};
43
+ export const defaultLocale = ${JSON.stringify(resolved.defaultLocale)};
44
+ export const cookieName = ${JSON.stringify(resolved.cookieName)};
45
+ `;
46
+ }
47
+
48
+ // ez-i18n:runtime - Runtime exports for Astro files
49
+ if (id === RESOLVED_PREFIX + VIRTUAL_RUNTIME) {
50
+ return `
51
+ import { effectiveLocale, translations, setLocale, initLocale } from '@zachhandley/ez-i18n/runtime';
52
+
53
+ export { setLocale, initLocale };
54
+ export { effectiveLocale as locale };
55
+
56
+ /**
57
+ * Get nested value from object using dot notation
58
+ */
59
+ function getNestedValue(obj, path) {
60
+ const keys = path.split('.');
61
+ let value = obj;
62
+ for (const key of keys) {
63
+ if (value == null || typeof value !== 'object') return undefined;
64
+ value = value[key];
65
+ }
66
+ return value;
67
+ }
68
+
69
+ /**
70
+ * Interpolate params into string
71
+ */
72
+ function interpolate(str, params) {
73
+ if (!params) return str;
74
+ return str.replace(/\\{(\\w+)\\}/g, (match, key) => {
75
+ return key in params ? String(params[key]) : match;
76
+ });
77
+ }
78
+
79
+ /**
80
+ * Translate a key to the current locale
81
+ * @param key - Dot-notation key (e.g., 'common.welcome')
82
+ * @param params - Optional interpolation params
83
+ */
84
+ export function t(key, params) {
85
+ const trans = translations.get();
86
+ const value = getNestedValue(trans, key);
87
+
88
+ if (typeof value !== 'string') {
89
+ if (import.meta.env.DEV) {
90
+ console.warn('[ez-i18n] Missing translation:', key);
91
+ }
92
+ return key;
93
+ }
94
+
95
+ return interpolate(value, params);
96
+ }
97
+ `;
98
+ }
99
+
100
+ // ez-i18n:translations - Translation loaders
101
+ if (id === RESOLVED_PREFIX + VIRTUAL_TRANSLATIONS) {
102
+ // Generate dynamic import statements for each locale
103
+ const loaderEntries = Object.entries(resolved.translations)
104
+ .map(([locale, path]) => ` ${JSON.stringify(locale)}: () => import(${JSON.stringify(path)})`)
105
+ .join(',\n');
106
+
107
+ return `
108
+ /**
109
+ * Translation loaders for ez-i18n
110
+ * Auto-generated from config
111
+ */
112
+ export const translationLoaders = {
113
+ ${loaderEntries}
114
+ };
115
+
116
+ /**
117
+ * Load translations for a specific locale
118
+ * @param locale - Locale code to load translations for
119
+ * @returns Translations object or empty object if not found
120
+ */
121
+ export async function loadTranslations(locale) {
122
+ const loader = translationLoaders[locale];
123
+ if (!loader) {
124
+ if (import.meta.env.DEV) {
125
+ console.warn('[ez-i18n] No translations configured for locale:', locale);
126
+ }
127
+ return {};
128
+ }
129
+
130
+ try {
131
+ const mod = await loader();
132
+ return mod.default ?? mod;
133
+ } catch (error) {
134
+ if (import.meta.env.DEV) {
135
+ console.error('[ez-i18n] Failed to load translations for locale:', locale, error);
136
+ }
137
+ return {};
138
+ }
139
+ }
140
+ `;
141
+ }
142
+
143
+ return null;
144
+ },
145
+ };
146
+ }