@zachhandley/ez-i18n 0.1.1 → 0.1.3
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 +125 -6
- package/dist/index.d.ts +2 -2
- package/dist/index.js +385 -24
- package/dist/runtime/react-plugin.d.ts +29 -0
- package/dist/runtime/react-plugin.js +85 -0
- package/dist/runtime/vue-plugin.d.ts +1 -1
- package/dist/types-CHyDGt_C.d.ts +86 -0
- package/dist/utils/index.d.ts +59 -0
- package/dist/utils/index.js +190 -0
- package/package.json +112 -89
- package/src/components/EzI18nHead.astro +1 -1
- package/src/runtime/react-plugin.ts +78 -0
- package/src/types.ts +57 -12
- package/src/utils/index.ts +13 -0
- package/src/utils/translations.ts +311 -0
- package/src/vite-plugin.ts +329 -29
- package/dist/types-DwCG8sp8.d.ts +0 -48
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { useStore } from '@nanostores/react';
|
|
2
|
+
import { effectiveLocale, translations, setLocale } from './store';
|
|
3
|
+
import type { TranslateFunction } from '../types';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Get nested value from object using dot notation
|
|
7
|
+
*/
|
|
8
|
+
function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
|
|
9
|
+
const keys = path.split('.');
|
|
10
|
+
let value: unknown = obj;
|
|
11
|
+
|
|
12
|
+
for (const key of keys) {
|
|
13
|
+
if (value == null || typeof value !== 'object') {
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
value = (value as Record<string, unknown>)[key];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Interpolate params into string
|
|
24
|
+
*/
|
|
25
|
+
function interpolate(
|
|
26
|
+
str: string,
|
|
27
|
+
params?: Record<string, string | number>
|
|
28
|
+
): string {
|
|
29
|
+
if (!params) return str;
|
|
30
|
+
return str.replace(/\{(\w+)\}/g, (match, key) => {
|
|
31
|
+
return key in params ? String(params[key]) : match;
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* React hook for i18n
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* import { useI18n } from '@zachhandley/ez-i18n/react';
|
|
40
|
+
*
|
|
41
|
+
* function MyComponent() {
|
|
42
|
+
* const { t, locale, setLocale } = useI18n();
|
|
43
|
+
*
|
|
44
|
+
* return (
|
|
45
|
+
* <div>
|
|
46
|
+
* <h1>{t('common.welcome')}</h1>
|
|
47
|
+
* <p>{t('greeting', { name: 'World' })}</p>
|
|
48
|
+
* <button onClick={() => setLocale('es')}>Español</button>
|
|
49
|
+
* </div>
|
|
50
|
+
* );
|
|
51
|
+
* }
|
|
52
|
+
*/
|
|
53
|
+
export function useI18n() {
|
|
54
|
+
const locale = useStore(effectiveLocale);
|
|
55
|
+
const trans = useStore(translations);
|
|
56
|
+
|
|
57
|
+
const t: TranslateFunction = (
|
|
58
|
+
key: string,
|
|
59
|
+
params?: Record<string, string | number>
|
|
60
|
+
): string => {
|
|
61
|
+
const value = getNestedValue(trans, key);
|
|
62
|
+
|
|
63
|
+
if (typeof value !== 'string') {
|
|
64
|
+
if (import.meta.env?.DEV) {
|
|
65
|
+
console.warn('[ez-i18n] Missing translation:', key);
|
|
66
|
+
}
|
|
67
|
+
return key;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return interpolate(value, params);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
t,
|
|
75
|
+
locale,
|
|
76
|
+
setLocale,
|
|
77
|
+
};
|
|
78
|
+
}
|
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
|
|
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,52 @@ export interface EzI18nConfig {
|
|
|
19
38
|
cookieName?: string;
|
|
20
39
|
|
|
21
40
|
/**
|
|
22
|
-
* Translation file paths
|
|
41
|
+
* Translation file paths configuration.
|
|
23
42
|
* Paths are relative to your project root.
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* es
|
|
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?:
|
|
60
|
+
translations?: TranslationsConfig;
|
|
32
61
|
}
|
|
33
62
|
|
|
34
63
|
/**
|
|
35
|
-
* Resolved config with defaults applied
|
|
64
|
+
* Resolved config with defaults applied.
|
|
65
|
+
* After resolution:
|
|
66
|
+
* - locales is always populated (from config or auto-discovered)
|
|
67
|
+
* - translations is normalized to arrays of absolute file paths
|
|
36
68
|
*/
|
|
37
69
|
export interface ResolvedEzI18nConfig {
|
|
38
70
|
locales: string[];
|
|
39
71
|
defaultLocale: string;
|
|
40
72
|
cookieName: string;
|
|
41
|
-
|
|
73
|
+
/** Normalized: locale → array of resolved absolute file paths */
|
|
74
|
+
translations: Record<string, string[]>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Cache file structure (.ez-i18n.json)
|
|
79
|
+
* Used to speed up subsequent builds by caching discovered translations
|
|
80
|
+
*/
|
|
81
|
+
export interface TranslationCache {
|
|
82
|
+
version: number;
|
|
83
|
+
/** Discovered locale → file paths mapping */
|
|
84
|
+
discovered: Record<string, string[]>;
|
|
85
|
+
/** ISO timestamp of last scan */
|
|
86
|
+
lastScan: string;
|
|
42
87
|
}
|
|
43
88
|
|
|
44
89
|
/**
|
|
@@ -0,0 +1,311 @@
|
|
|
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
|
+
*/
|
|
307
|
+
export function toGlobPattern(baseDir: string, projectRoot: string): string {
|
|
308
|
+
const relativePath = path.relative(projectRoot, baseDir).replace(/\\/g, '/');
|
|
309
|
+
const normalized = relativePath.startsWith('.') ? relativePath : './' + relativePath;
|
|
310
|
+
return `${normalized}/**/*.json`;
|
|
311
|
+
}
|