@zachhandley/ez-i18n 0.1.2 → 0.1.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.
@@ -0,0 +1,85 @@
1
+ import { L as LocaleTranslationPath, a as TranslationsConfig, b as TranslationCache } from '../types-Cd9e7Lkc.js';
2
+
3
+ type PathType = 'file' | 'folder' | 'glob' | 'array';
4
+ /**
5
+ * Detect the type of translation path
6
+ */
7
+ declare function detectPathType(input: string | string[]): PathType;
8
+ /**
9
+ * Resolve a single translation path to an array of absolute file paths.
10
+ * Results are sorted alphabetically for predictable merge order.
11
+ */
12
+ declare function resolveTranslationPaths(input: LocaleTranslationPath, projectRoot: string): Promise<string[]>;
13
+ /**
14
+ * Auto-discover translations from a base directory.
15
+ * Scans for locale folders (e.g., en/, es/, fr/) and their JSON files.
16
+ * Returns both discovered locales and their file mappings.
17
+ */
18
+ declare function autoDiscoverTranslations(baseDir: string, projectRoot: string, configuredLocales?: string[]): Promise<{
19
+ locales: string[];
20
+ translations: Record<string, string[]>;
21
+ }>;
22
+ /**
23
+ * Resolve the full translations config to normalized form.
24
+ * Handles string (base dir), object (per-locale), or undefined (auto-discover).
25
+ */
26
+ declare function resolveTranslationsConfig(config: TranslationsConfig | undefined, projectRoot: string, configuredLocales?: string[]): Promise<{
27
+ locales: string[];
28
+ translations: Record<string, string[]>;
29
+ }>;
30
+ /**
31
+ * Deep merge translation objects.
32
+ * - Objects are recursively merged
33
+ * - Arrays are REPLACED (not concatenated)
34
+ * - Primitives are overwritten by later values
35
+ * - Prototype pollution safe
36
+ */
37
+ declare function deepMerge<T extends Record<string, unknown>>(target: T, ...sources: T[]): T;
38
+ /**
39
+ * Load cached translation discovery results
40
+ */
41
+ declare function loadCache(projectRoot: string): TranslationCache | null;
42
+ /**
43
+ * Save translation discovery results to cache
44
+ */
45
+ declare function saveCache(projectRoot: string, discovered: Record<string, string[]>): void;
46
+ /**
47
+ * Check if cache is still valid (files haven't changed)
48
+ */
49
+ declare function isCacheValid(cache: TranslationCache, projectRoot: string): boolean;
50
+ /**
51
+ * Convert an absolute path to a relative import path for Vite
52
+ */
53
+ declare function toRelativeImport(absolutePath: string, projectRoot: string): string;
54
+ /**
55
+ * Generate a glob pattern for import.meta.glob from a base directory
56
+ */
57
+ declare function toGlobPattern(baseDir: string, projectRoot: string): string;
58
+ /**
59
+ * Get namespace from file path relative to locale base directory.
60
+ *
61
+ * Examples:
62
+ * - filePath: /project/public/i18n/en/auth/login.json, localeDir: /project/public/i18n/en
63
+ * → namespace: 'auth.login'
64
+ * - filePath: /project/public/i18n/en/common.json, localeDir: /project/public/i18n/en
65
+ * → namespace: 'common'
66
+ * - filePath: /project/public/i18n/en/settings/index.json, localeDir: /project/public/i18n/en
67
+ * → namespace: 'settings' (index is stripped)
68
+ */
69
+ declare function getNamespaceFromPath(filePath: string, localeDir: string): string;
70
+ /**
71
+ * Wrap a translation object with its namespace.
72
+ *
73
+ * Example:
74
+ * - namespace: 'auth.login'
75
+ * - content: { title: 'Welcome', subtitle: 'Login here' }
76
+ * - result: { auth: { login: { title: 'Welcome', subtitle: 'Login here' } } }
77
+ */
78
+ declare function wrapWithNamespace(namespace: string, content: Record<string, unknown>): Record<string, unknown>;
79
+ /**
80
+ * Generate code that wraps imported content with namespace at runtime.
81
+ * Used in virtual module generation.
82
+ */
83
+ declare function generateNamespaceWrapperCode(): string;
84
+
85
+ export { type PathType, autoDiscoverTranslations, deepMerge, detectPathType, generateNamespaceWrapperCode, getNamespaceFromPath, isCacheValid, loadCache, resolveTranslationPaths, resolveTranslationsConfig, saveCache, toGlobPattern, toRelativeImport, wrapWithNamespace };
@@ -0,0 +1,220 @@
1
+ // src/utils/translations.ts
2
+ import { glob } from "tinyglobby";
3
+ import * as path from "path";
4
+ import * as fs from "fs";
5
+ var CACHE_FILE = ".ez-i18n.json";
6
+ var CACHE_VERSION = 1;
7
+ var DEFAULT_I18N_DIR = "./public/i18n";
8
+ function detectPathType(input) {
9
+ if (Array.isArray(input)) return "array";
10
+ if (input.includes("*")) return "glob";
11
+ if (input.endsWith("/") || input.endsWith(path.sep)) return "folder";
12
+ return "file";
13
+ }
14
+ function isDirectory(filePath) {
15
+ try {
16
+ return fs.statSync(filePath).isDirectory();
17
+ } catch {
18
+ return false;
19
+ }
20
+ }
21
+ async function resolveTranslationPaths(input, projectRoot) {
22
+ const type = detectPathType(input);
23
+ let files = [];
24
+ switch (type) {
25
+ case "array":
26
+ for (const entry of input) {
27
+ const resolved = await resolveTranslationPaths(entry, projectRoot);
28
+ files.push(...resolved);
29
+ }
30
+ break;
31
+ case "glob":
32
+ files = await glob(input, {
33
+ cwd: projectRoot,
34
+ absolute: true
35
+ });
36
+ break;
37
+ case "folder": {
38
+ const folderPath = path.resolve(projectRoot, input.replace(/\/$/, ""));
39
+ files = await glob("**/*.json", {
40
+ cwd: folderPath,
41
+ absolute: true
42
+ });
43
+ break;
44
+ }
45
+ case "file":
46
+ default: {
47
+ const filePath = path.resolve(projectRoot, input);
48
+ if (isDirectory(filePath)) {
49
+ files = await glob("**/*.json", {
50
+ cwd: filePath,
51
+ absolute: true
52
+ });
53
+ } else {
54
+ files = [filePath];
55
+ }
56
+ break;
57
+ }
58
+ }
59
+ return [...new Set(files)].sort((a, b) => a.localeCompare(b));
60
+ }
61
+ async function autoDiscoverTranslations(baseDir, projectRoot, configuredLocales) {
62
+ const absoluteBaseDir = path.resolve(projectRoot, baseDir.replace(/\/$/, ""));
63
+ if (!isDirectory(absoluteBaseDir)) {
64
+ console.warn(`[ez-i18n] Translation directory not found: ${absoluteBaseDir}`);
65
+ return { locales: configuredLocales || [], translations: {} };
66
+ }
67
+ const translations = {};
68
+ const discoveredLocales = [];
69
+ const entries = fs.readdirSync(absoluteBaseDir, { withFileTypes: true });
70
+ for (const entry of entries) {
71
+ if (entry.isDirectory()) {
72
+ const locale = entry.name;
73
+ if (configuredLocales && configuredLocales.length > 0) {
74
+ if (!configuredLocales.includes(locale)) continue;
75
+ }
76
+ const localePath = path.join(absoluteBaseDir, locale);
77
+ const files = await glob("**/*.json", {
78
+ cwd: localePath,
79
+ absolute: true
80
+ });
81
+ if (files.length > 0) {
82
+ discoveredLocales.push(locale);
83
+ translations[locale] = files.sort((a, b) => a.localeCompare(b));
84
+ }
85
+ } else if (entry.isFile() && entry.name.endsWith(".json")) {
86
+ const locale = path.basename(entry.name, ".json");
87
+ if (configuredLocales && configuredLocales.length > 0) {
88
+ if (!configuredLocales.includes(locale)) continue;
89
+ }
90
+ const filePath = path.join(absoluteBaseDir, entry.name);
91
+ if (!translations[locale]) {
92
+ discoveredLocales.push(locale);
93
+ translations[locale] = [];
94
+ }
95
+ translations[locale].push(filePath);
96
+ }
97
+ }
98
+ const sortedLocales = [...new Set(discoveredLocales)].sort();
99
+ return { locales: sortedLocales, translations };
100
+ }
101
+ async function resolveTranslationsConfig(config, projectRoot, configuredLocales) {
102
+ if (!config) {
103
+ return autoDiscoverTranslations(DEFAULT_I18N_DIR, projectRoot, configuredLocales);
104
+ }
105
+ if (typeof config === "string") {
106
+ return autoDiscoverTranslations(config, projectRoot, configuredLocales);
107
+ }
108
+ const translations = {};
109
+ const locales = Object.keys(config);
110
+ for (const [locale, localePath] of Object.entries(config)) {
111
+ translations[locale] = await resolveTranslationPaths(localePath, projectRoot);
112
+ }
113
+ return { locales, translations };
114
+ }
115
+ function deepMerge(target, ...sources) {
116
+ const FORBIDDEN_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
117
+ const result = { ...target };
118
+ for (const source of sources) {
119
+ if (!source || typeof source !== "object") continue;
120
+ for (const key of Object.keys(source)) {
121
+ if (FORBIDDEN_KEYS.has(key)) continue;
122
+ const targetVal = result[key];
123
+ const sourceVal = source[key];
124
+ if (sourceVal !== null && typeof sourceVal === "object" && !Array.isArray(sourceVal) && targetVal !== null && typeof targetVal === "object" && !Array.isArray(targetVal)) {
125
+ result[key] = deepMerge(
126
+ targetVal,
127
+ sourceVal
128
+ );
129
+ } else {
130
+ result[key] = sourceVal;
131
+ }
132
+ }
133
+ }
134
+ return result;
135
+ }
136
+ function loadCache(projectRoot) {
137
+ const cachePath = path.join(projectRoot, CACHE_FILE);
138
+ try {
139
+ if (!fs.existsSync(cachePath)) return null;
140
+ const content = fs.readFileSync(cachePath, "utf-8");
141
+ const cache = JSON.parse(content);
142
+ if (cache.version !== CACHE_VERSION) return null;
143
+ return cache;
144
+ } catch {
145
+ return null;
146
+ }
147
+ }
148
+ function saveCache(projectRoot, discovered) {
149
+ const cachePath = path.join(projectRoot, CACHE_FILE);
150
+ const cache = {
151
+ version: CACHE_VERSION,
152
+ discovered,
153
+ lastScan: (/* @__PURE__ */ new Date()).toISOString()
154
+ };
155
+ try {
156
+ fs.writeFileSync(cachePath, JSON.stringify(cache, null, 2));
157
+ } catch (error) {
158
+ console.warn("[ez-i18n] Failed to write cache file:", error);
159
+ }
160
+ }
161
+ function isCacheValid(cache, projectRoot) {
162
+ for (const files of Object.values(cache.discovered)) {
163
+ for (const file of files) {
164
+ if (!fs.existsSync(file)) return false;
165
+ }
166
+ }
167
+ return true;
168
+ }
169
+ function toRelativeImport(absolutePath, projectRoot) {
170
+ const relativePath = path.relative(projectRoot, absolutePath);
171
+ const normalized = relativePath.replace(/\\/g, "/");
172
+ return normalized.startsWith(".") ? normalized : "./" + normalized;
173
+ }
174
+ function toGlobPattern(baseDir, projectRoot) {
175
+ const relativePath = path.relative(projectRoot, baseDir).replace(/\\/g, "/");
176
+ const normalized = relativePath.startsWith(".") ? relativePath : "./" + relativePath;
177
+ return `${normalized}/**/*.json`;
178
+ }
179
+ function getNamespaceFromPath(filePath, localeDir) {
180
+ const relative2 = path.relative(localeDir, filePath);
181
+ const withoutExt = relative2.replace(/\.json$/i, "");
182
+ const namespace = withoutExt.replace(/[\\/]/g, ".");
183
+ return namespace.replace(/\.index$/, "");
184
+ }
185
+ function wrapWithNamespace(namespace, content) {
186
+ if (!namespace) return content;
187
+ const parts = namespace.split(".");
188
+ let result = content;
189
+ for (let i = parts.length - 1; i >= 0; i--) {
190
+ result = { [parts[i]]: result };
191
+ }
192
+ return result;
193
+ }
194
+ function generateNamespaceWrapperCode() {
195
+ return `
196
+ function __wrapWithNamespace(namespace, content) {
197
+ if (!namespace) return content;
198
+ const parts = namespace.split('.');
199
+ let result = content;
200
+ for (let i = parts.length - 1; i >= 0; i--) {
201
+ result = { [parts[i]]: result };
202
+ }
203
+ return result;
204
+ }`;
205
+ }
206
+ export {
207
+ autoDiscoverTranslations,
208
+ deepMerge,
209
+ detectPathType,
210
+ generateNamespaceWrapperCode,
211
+ getNamespaceFromPath,
212
+ isCacheValid,
213
+ loadCache,
214
+ resolveTranslationPaths,
215
+ resolveTranslationsConfig,
216
+ saveCache,
217
+ toGlobPattern,
218
+ toRelativeImport,
219
+ wrapWithNamespace
220
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zachhandley/ez-i18n",
3
- "version": "0.1.2",
3
+ "version": "0.1.5",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -30,6 +30,10 @@
30
30
  "./react": {
31
31
  "types": "./dist/runtime/react-plugin.d.ts",
32
32
  "import": "./dist/runtime/react-plugin.js"
33
+ },
34
+ "./utils": {
35
+ "types": "./dist/utils/index.d.ts",
36
+ "import": "./dist/utils/index.js"
33
37
  }
34
38
  },
35
39
  "files": [
@@ -64,6 +68,9 @@
64
68
  "bugs": {
65
69
  "url": "https://github.com/zachhandley/ez-i18n/issues"
66
70
  },
71
+ "dependencies": {
72
+ "tinyglobby": "^0.2.15"
73
+ },
67
74
  "peerDependencies": {
68
75
  "@nanostores/persistent": "^0.10.0",
69
76
  "@nanostores/react": "^0.7.0 || ^0.8.0 || ^1.0.0",
package/src/types.ts CHANGED
@@ -1,14 +1,33 @@
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
+
1
17
  /**
2
18
  * Configuration for ez-i18n Astro integration
3
19
  */
4
20
  export interface EzI18nConfig {
5
21
  /**
6
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.
7
25
  */
8
- locales: string[];
26
+ locales?: string[];
9
27
 
10
28
  /**
11
- * Default locale to use when no preference is detected
29
+ * Default locale to use when no preference is detected.
30
+ * Required - this tells us what to fall back to.
12
31
  */
13
32
  defaultLocale: string;
14
33
 
@@ -19,26 +38,69 @@ export interface EzI18nConfig {
19
38
  cookieName?: string;
20
39
 
21
40
  /**
22
- * Translation file paths for each locale.
41
+ * Translation file paths configuration.
23
42
  * 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
- * }
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/
30
59
  */
31
- translations?: Record<string, string>;
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;
32
74
  }
33
75
 
34
76
  /**
35
- * Resolved config with defaults applied
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
36
81
  */
37
82
  export interface ResolvedEzI18nConfig {
38
83
  locales: string[];
39
84
  defaultLocale: string;
40
85
  cookieName: string;
41
- translations: Record<string, 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;
42
104
  }
43
105
 
44
106
  /**
@@ -0,0 +1,16 @@
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';