@zachhandley/ez-i18n 0.3.3 → 0.3.5

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.
@@ -1,122 +0,0 @@
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
- }
package/src/types.ts DELETED
@@ -1,125 +0,0 @@
1
- /**
2
- * Translation path for a single locale:
3
- * - Single file: `./src/i18n/en.json`
4
- * - Folder: `./src/i18n/en/` (auto-discover all JSONs inside)
5
- * - Glob: `./src/i18n/en/**.json` (recursive)
6
- * - Array: `['./common.json', './auth.json']`
7
- */
8
- export type LocaleTranslationPath = string | string[];
9
-
10
- /**
11
- * Translation config can be:
12
- * - A base directory string (auto-discovers locale folders): './public/i18n/'
13
- * - Per-locale mapping: { en: './src/i18n/en/', es: './src/i18n/es.json' }
14
- */
15
- export type TranslationsConfig = string | Record<string, LocaleTranslationPath>;
16
-
17
- /**
18
- * Configuration for ez-i18n Astro integration
19
- */
20
- export interface EzI18nConfig {
21
- /**
22
- * List of supported locale codes (e.g., ['en', 'es', 'fr'])
23
- * Optional if using directory-based auto-discovery - locales will be
24
- * detected from folder names in the translations directory.
25
- */
26
- locales?: string[];
27
-
28
- /**
29
- * Default locale to use when no preference is detected.
30
- * Required - this tells us what to fall back to.
31
- */
32
- defaultLocale: string;
33
-
34
- /**
35
- * Cookie name for storing locale preference
36
- * @default 'ez-locale'
37
- */
38
- cookieName?: string;
39
-
40
- /**
41
- * Translation file paths configuration.
42
- * Paths are relative to your project root.
43
- *
44
- * Can be:
45
- * - A base directory (auto-discovers locale folders):
46
- * translations: './public/i18n/'
47
- * → Scans for en/, es/, fr/ folders and their JSON files
48
- * → Auto-populates `locales` from discovered folders
49
- *
50
- * - Per-locale mapping with flexible path types:
51
- * translations: {
52
- * en: './src/i18n/en.json', // single file
53
- * es: './src/i18n/es/', // folder (all JSONs)
54
- * fr: './src/i18n/fr/**.json', // glob pattern
55
- * de: ['./common.json', './auth.json'] // array of files
56
- * }
57
- *
58
- * If not specified, auto-discovers from ./public/i18n/
59
- */
60
- translations?: TranslationsConfig;
61
-
62
- /**
63
- * Derive namespace from file path relative to locale folder.
64
- *
65
- * When enabled:
66
- * - `en/auth/login.json` with `{ "title": "..." }` → `$t('auth.login.title')`
67
- * - `en/common.json` with `{ "actions": {...} }` → `$t('common.actions.save')`
68
- *
69
- * The file path (minus locale folder and .json extension) becomes the key prefix.
70
- *
71
- * @default true when using folder-based translations config
72
- */
73
- pathBasedNamespacing?: boolean;
74
- }
75
-
76
- /**
77
- * Resolved config with defaults applied.
78
- * After resolution:
79
- * - locales is always populated (from config or auto-discovered)
80
- * - translations is normalized to arrays of absolute file paths
81
- */
82
- export interface ResolvedEzI18nConfig {
83
- locales: string[];
84
- defaultLocale: string;
85
- cookieName: string;
86
- /** Normalized: locale → array of resolved absolute file paths */
87
- translations: Record<string, string[]>;
88
- /** Whether to derive namespace from file path */
89
- pathBasedNamespacing: boolean;
90
- /** Base directory for each locale (used for namespace calculation) */
91
- localeBaseDirs: Record<string, string>;
92
- }
93
-
94
- /**
95
- * Cache file structure (.ez-i18n.json)
96
- * Used to speed up subsequent builds by caching discovered translations
97
- */
98
- export interface TranslationCache {
99
- version: number;
100
- /** Discovered locale → file paths mapping */
101
- discovered: Record<string, string[]>;
102
- /** ISO timestamp of last scan */
103
- lastScan: string;
104
- }
105
-
106
- /**
107
- * Translation function type
108
- */
109
- export type TranslateFunction = (key: string, params?: Record<string, string | number>) => string;
110
-
111
- /**
112
- * Augment Astro's locals type
113
- */
114
- declare global {
115
- namespace App {
116
- interface Locals {
117
- /** Current locale code */
118
- locale: string;
119
- /** Loaded translations for the current locale */
120
- translations: Record<string, unknown>;
121
- /** Server-side translation function */
122
- t: TranslateFunction;
123
- }
124
- }
125
- }
@@ -1,16 +0,0 @@
1
- export {
2
- detectPathType,
3
- resolveTranslationPaths,
4
- autoDiscoverTranslations,
5
- resolveTranslationsConfig,
6
- deepMerge,
7
- loadCache,
8
- saveCache,
9
- isCacheValid,
10
- toRelativeImport,
11
- toGlobPattern,
12
- getNamespaceFromPath,
13
- wrapWithNamespace,
14
- generateNamespaceWrapperCode,
15
- type PathType,
16
- } from './translations';
@@ -1,418 +0,0 @@
1
- import { glob } from 'tinyglobby';
2
- import * as path from 'node:path';
3
- import * as fs from 'node:fs';
4
- import type { LocaleTranslationPath, TranslationsConfig, TranslationCache } from '../types';
5
-
6
- const CACHE_FILE = '.ez-i18n.json';
7
- const CACHE_VERSION = 1;
8
- const DEFAULT_I18N_DIR = './public/i18n';
9
-
10
- export type PathType = 'file' | 'folder' | 'glob' | 'array';
11
-
12
- /**
13
- * Detect the type of translation path
14
- */
15
- export function detectPathType(input: string | string[]): PathType {
16
- if (Array.isArray(input)) return 'array';
17
- if (input.includes('*')) return 'glob';
18
- if (input.endsWith('/') || input.endsWith(path.sep)) return 'folder';
19
- return 'file';
20
- }
21
-
22
- /**
23
- * Check if a path is a directory (handles missing trailing slash)
24
- */
25
- function isDirectory(filePath: string): boolean {
26
- try {
27
- return fs.statSync(filePath).isDirectory();
28
- } catch {
29
- return false;
30
- }
31
- }
32
-
33
- /**
34
- * Resolve a single translation path to an array of absolute file paths.
35
- * Results are sorted alphabetically for predictable merge order.
36
- */
37
- export async function resolveTranslationPaths(
38
- input: LocaleTranslationPath,
39
- projectRoot: string
40
- ): Promise<string[]> {
41
- const type = detectPathType(input);
42
- let files: string[] = [];
43
-
44
- switch (type) {
45
- case 'array':
46
- // Each entry could itself be a glob, folder, or file
47
- for (const entry of input as string[]) {
48
- const resolved = await resolveTranslationPaths(entry, projectRoot);
49
- files.push(...resolved);
50
- }
51
- break;
52
-
53
- case 'glob':
54
- files = await glob(input as string, {
55
- cwd: projectRoot,
56
- absolute: true,
57
- });
58
- break;
59
-
60
- case 'folder': {
61
- const folderPath = path.resolve(projectRoot, (input as string).replace(/\/$/, ''));
62
- files = await glob('**/*.json', {
63
- cwd: folderPath,
64
- absolute: true,
65
- });
66
- break;
67
- }
68
-
69
- case 'file':
70
- default: {
71
- const filePath = path.resolve(projectRoot, input as string);
72
- // Check if it's actually a directory (user omitted trailing slash)
73
- if (isDirectory(filePath)) {
74
- files = await glob('**/*.json', {
75
- cwd: filePath,
76
- absolute: true,
77
- });
78
- } else {
79
- files = [filePath];
80
- }
81
- break;
82
- }
83
- }
84
-
85
- // Sort alphabetically for predictable merge order
86
- return [...new Set(files)].sort((a, b) => a.localeCompare(b));
87
- }
88
-
89
- /**
90
- * Auto-discover translations from a base directory.
91
- * Scans for locale folders (e.g., en/, es/, fr/) and their JSON files.
92
- * Returns both discovered locales and their file mappings.
93
- */
94
- export async function autoDiscoverTranslations(
95
- baseDir: string,
96
- projectRoot: string,
97
- configuredLocales?: string[]
98
- ): Promise<{ locales: string[]; translations: Record<string, string[]> }> {
99
- const absoluteBaseDir = path.resolve(projectRoot, baseDir.replace(/\/$/, ''));
100
-
101
- if (!isDirectory(absoluteBaseDir)) {
102
- console.warn(`[ez-i18n] Translation directory not found: ${absoluteBaseDir}`);
103
- return { locales: configuredLocales || [], translations: {} };
104
- }
105
-
106
- const translations: Record<string, string[]> = {};
107
- const discoveredLocales: string[] = [];
108
-
109
- // Read directory entries
110
- const entries = fs.readdirSync(absoluteBaseDir, { withFileTypes: true });
111
-
112
- for (const entry of entries) {
113
- if (entry.isDirectory()) {
114
- // This is a locale folder (e.g., en/, es/)
115
- const locale = entry.name;
116
-
117
- // If locales were configured, only include matching ones
118
- if (configuredLocales && configuredLocales.length > 0) {
119
- if (!configuredLocales.includes(locale)) continue;
120
- }
121
-
122
- const localePath = path.join(absoluteBaseDir, locale);
123
- const files = await glob('**/*.json', {
124
- cwd: localePath,
125
- absolute: true,
126
- });
127
-
128
- if (files.length > 0) {
129
- discoveredLocales.push(locale);
130
- translations[locale] = files.sort((a, b) => a.localeCompare(b));
131
- }
132
- } else if (entry.isFile() && entry.name.endsWith('.json')) {
133
- // Root-level JSON files (e.g., en.json, es.json)
134
- // Extract locale from filename
135
- const locale = path.basename(entry.name, '.json');
136
-
137
- // If locales were configured, only include matching ones
138
- if (configuredLocales && configuredLocales.length > 0) {
139
- if (!configuredLocales.includes(locale)) continue;
140
- }
141
-
142
- const filePath = path.join(absoluteBaseDir, entry.name);
143
-
144
- if (!translations[locale]) {
145
- discoveredLocales.push(locale);
146
- translations[locale] = [];
147
- }
148
- translations[locale].push(filePath);
149
- }
150
- }
151
-
152
- // Sort locales for consistency
153
- const sortedLocales = [...new Set(discoveredLocales)].sort();
154
-
155
- return { locales: sortedLocales, translations };
156
- }
157
-
158
- /**
159
- * Resolve the full translations config to normalized form.
160
- * Handles string (base dir), object (per-locale), or undefined (auto-discover).
161
- */
162
- export async function resolveTranslationsConfig(
163
- config: TranslationsConfig | undefined,
164
- projectRoot: string,
165
- configuredLocales?: string[]
166
- ): Promise<{ locales: string[]; translations: Record<string, string[]> }> {
167
- // No config - auto-discover from default location
168
- if (!config) {
169
- return autoDiscoverTranslations(DEFAULT_I18N_DIR, projectRoot, configuredLocales);
170
- }
171
-
172
- // String - treat as base directory for auto-discovery
173
- if (typeof config === 'string') {
174
- return autoDiscoverTranslations(config, projectRoot, configuredLocales);
175
- }
176
-
177
- // Object - per-locale mapping
178
- const translations: Record<string, string[]> = {};
179
- const locales = Object.keys(config);
180
-
181
- for (const [locale, localePath] of Object.entries(config)) {
182
- translations[locale] = await resolveTranslationPaths(localePath, projectRoot);
183
- }
184
-
185
- return { locales, translations };
186
- }
187
-
188
- /**
189
- * Deep merge translation objects.
190
- * - Objects are recursively merged
191
- * - Arrays are REPLACED (not concatenated)
192
- * - Primitives are overwritten by later values
193
- * - Prototype pollution safe
194
- */
195
- export function deepMerge<T extends Record<string, unknown>>(
196
- target: T,
197
- ...sources: T[]
198
- ): T {
199
- const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
200
- const result = { ...target };
201
-
202
- for (const source of sources) {
203
- if (!source || typeof source !== 'object') continue;
204
-
205
- for (const key of Object.keys(source)) {
206
- if (FORBIDDEN_KEYS.has(key)) continue;
207
-
208
- const targetVal = result[key as keyof T];
209
- const sourceVal = source[key as keyof T];
210
-
211
- if (
212
- sourceVal !== null &&
213
- typeof sourceVal === 'object' &&
214
- !Array.isArray(sourceVal) &&
215
- targetVal !== null &&
216
- typeof targetVal === 'object' &&
217
- !Array.isArray(targetVal)
218
- ) {
219
- // Both are plain objects - recurse
220
- (result as Record<string, unknown>)[key] = deepMerge(
221
- targetVal as Record<string, unknown>,
222
- sourceVal as Record<string, unknown>
223
- );
224
- } else {
225
- // Arrays replace, primitives overwrite
226
- (result as Record<string, unknown>)[key] = sourceVal;
227
- }
228
- }
229
- }
230
-
231
- return result;
232
- }
233
-
234
- /**
235
- * Load cached translation discovery results
236
- */
237
- export function loadCache(projectRoot: string): TranslationCache | null {
238
- const cachePath = path.join(projectRoot, CACHE_FILE);
239
-
240
- try {
241
- if (!fs.existsSync(cachePath)) return null;
242
-
243
- const content = fs.readFileSync(cachePath, 'utf-8');
244
- const cache = JSON.parse(content) as TranslationCache;
245
-
246
- // Validate cache version
247
- if (cache.version !== CACHE_VERSION) return null;
248
-
249
- return cache;
250
- } catch {
251
- return null;
252
- }
253
- }
254
-
255
- /**
256
- * Save translation discovery results to cache
257
- */
258
- export function saveCache(
259
- projectRoot: string,
260
- discovered: Record<string, string[]>
261
- ): void {
262
- const cachePath = path.join(projectRoot, CACHE_FILE);
263
-
264
- const cache: TranslationCache = {
265
- version: CACHE_VERSION,
266
- discovered,
267
- lastScan: new Date().toISOString(),
268
- };
269
-
270
- try {
271
- fs.writeFileSync(cachePath, JSON.stringify(cache, null, 2));
272
- } catch (error) {
273
- console.warn('[ez-i18n] Failed to write cache file:', error);
274
- }
275
- }
276
-
277
- /**
278
- * Check if cache is still valid (files haven't changed)
279
- */
280
- export function isCacheValid(
281
- cache: TranslationCache,
282
- projectRoot: string
283
- ): boolean {
284
- // Check if all cached files still exist
285
- for (const files of Object.values(cache.discovered)) {
286
- for (const file of files) {
287
- if (!fs.existsSync(file)) return false;
288
- }
289
- }
290
-
291
- return true;
292
- }
293
-
294
- /**
295
- * Convert an absolute path to a relative import path for Vite
296
- */
297
- export function toRelativeImport(absolutePath: string, projectRoot: string): string {
298
- const relativePath = path.relative(projectRoot, absolutePath);
299
- // Ensure it starts with ./ and uses forward slashes
300
- const normalized = relativePath.replace(/\\/g, '/');
301
- return normalized.startsWith('.') ? normalized : './' + normalized;
302
- }
303
-
304
- /**
305
- * Generate a glob pattern for import.meta.glob from a base directory.
306
- * In virtual modules, globs must start with '/' (project root relative).
307
- * Returns null if the path is in public/ (can't use import.meta.glob for public files).
308
- */
309
- export function toGlobPattern(baseDir: string, projectRoot: string): string | null {
310
- const relativePath = path.relative(projectRoot, baseDir).replace(/\\/g, '/');
311
- // Can't use import.meta.glob for public directory files
312
- if (relativePath.startsWith('public/') || relativePath === 'public') {
313
- return null;
314
- }
315
- // Virtual modules require globs to start with '/' (project root relative)
316
- return `/${relativePath}/**/*.json`;
317
- }
318
-
319
- /**
320
- * Check if an absolute path is inside the public directory
321
- */
322
- export function isInPublicDir(filePath: string, projectRoot: string): boolean {
323
- const relativePath = path.relative(projectRoot, filePath).replace(/\\/g, '/');
324
- return relativePath.startsWith('public/') || relativePath === 'public';
325
- }
326
-
327
- /**
328
- * Convert a public directory path to its served URL.
329
- * public/i18n/en/common.json → /i18n/en/common.json
330
- */
331
- export function toPublicUrl(filePath: string, projectRoot: string): string {
332
- const relativePath = path.relative(projectRoot, filePath).replace(/\\/g, '/');
333
- // Remove 'public/' prefix - files in public/ are served at root
334
- if (relativePath.startsWith('public/')) {
335
- return '/' + relativePath.slice('public/'.length);
336
- }
337
- return '/' + relativePath;
338
- }
339
-
340
- /**
341
- * Get the locale base directory for namespace calculation.
342
- * For public paths, strips the 'public/' prefix.
343
- */
344
- export function getLocaleBaseDirForNamespace(localeBaseDir: string, projectRoot: string): string {
345
- const relativePath = path.relative(projectRoot, localeBaseDir).replace(/\\/g, '/');
346
- // For namespace calculation, we want the path without 'public/' prefix
347
- if (relativePath.startsWith('public/')) {
348
- return relativePath.slice('public/'.length);
349
- }
350
- return relativePath;
351
- }
352
-
353
- /**
354
- * Get namespace from file path relative to locale base directory.
355
- *
356
- * Examples:
357
- * - filePath: /project/public/i18n/en/auth/login.json, localeDir: /project/public/i18n/en
358
- * → namespace: 'auth.login'
359
- * - filePath: /project/public/i18n/en/common.json, localeDir: /project/public/i18n/en
360
- * → namespace: 'common'
361
- * - filePath: /project/public/i18n/en/settings/index.json, localeDir: /project/public/i18n/en
362
- * → namespace: 'settings' (index is stripped)
363
- */
364
- export function getNamespaceFromPath(filePath: string, localeDir: string): string {
365
- // Get relative path from locale directory
366
- const relative = path.relative(localeDir, filePath);
367
-
368
- // Remove .json extension
369
- const withoutExt = relative.replace(/\.json$/i, '');
370
-
371
- // Convert path separators to dots
372
- const namespace = withoutExt.replace(/[\\/]/g, '.');
373
-
374
- // Remove trailing .index (index.json files represent the folder itself)
375
- return namespace.replace(/\.index$/, '');
376
- }
377
-
378
- /**
379
- * Wrap a translation object with its namespace.
380
- *
381
- * Example:
382
- * - namespace: 'auth.login'
383
- * - content: { title: 'Welcome', subtitle: 'Login here' }
384
- * - result: { auth: { login: { title: 'Welcome', subtitle: 'Login here' } } }
385
- */
386
- export function wrapWithNamespace(
387
- namespace: string,
388
- content: Record<string, unknown>
389
- ): Record<string, unknown> {
390
- if (!namespace) return content;
391
-
392
- const parts = namespace.split('.');
393
- let result: Record<string, unknown> = content;
394
-
395
- // Build from inside out
396
- for (let i = parts.length - 1; i >= 0; i--) {
397
- result = { [parts[i]]: result };
398
- }
399
-
400
- return result;
401
- }
402
-
403
- /**
404
- * Generate code that wraps imported content with namespace at runtime.
405
- * Used in virtual module generation.
406
- */
407
- export function generateNamespaceWrapperCode(): string {
408
- return `
409
- function __wrapWithNamespace(namespace, content) {
410
- if (!namespace) return content;
411
- const parts = namespace.split('.');
412
- let result = content;
413
- for (let i = parts.length - 1; i >= 0; i--) {
414
- result = { [parts[i]]: result };
415
- }
416
- return result;
417
- }`;
418
- }