UNPKG

@gulibs/react-vintl

Version:

Type-safe i18n library for React with Vite plugin and automatic type inference

307 lines (306 loc) 12.8 kB
import path from "path"; import fs from "fs/promises"; import fg from "fast-glob"; import { existsSync } from "fs"; var VIRTUAL_MODULE_ID = "virtual:@gulibs/react-vintl/generated-locales", MODULE_IDS = ["@gulibs/react-vintl-locales"], DEFAULT_OPTIONS = { basePath: "src/locales", extensions: [ ".json", ".ts", ".js" ], localePattern: "directory", hmr: !0, deep: !0, exclude: [], include: [], debug: !1 }, I18nContext = class { constructor(e = {}) { this.cache = /* @__PURE__ */ new Map(), this.isGenerating = !1, this.generationPromise = null, this.options = { ...DEFAULT_OPTIONS, ...e }; } setupServer(e) { this.server = e, this.options.hmr && this.setupWatcher(e.watcher); } setupWatcher(n) { let r = path.resolve(process.cwd(), this.options.basePath); n.on("add", async (e) => { e.startsWith(r) && (this.logDebug(`Added locale file: ${e}`), await this.invalidateCache()); }), n.on("change", async (e) => { e.startsWith(r) && (this.logDebug(`Changed locale file: ${e}`), await this.invalidateCache()); }), n.on("unlink", async (e) => { e.startsWith(r) && (this.logDebug(`Removed locale file: ${e}`), await this.invalidateCache()); }); } async invalidateCache() { this.cache.clear(); try { if (!this.options.hmr) { this.logDebug("HMR disabled, skipping type update on file change."); return; } let e = await this.parseLocales(process.cwd()); await this.updateDeclarationFile(e); } catch (e) { this.logError(`Failed to update types on file change: ${e instanceof Error ? e.message : String(e)}`); } if (this.server) { let e = this.server.moduleGraph.getModuleById(`${VIRTUAL_MODULE_ID}?id=${MODULE_IDS[0]}`); e && (this.server.reloadModule(e), this.logDebug("Hot reloaded locale resources")); } } logDebug(e) { this.options.debug && console.log(`[react-vintl] ${e}`); } logError(e) { console.error(`[react-vintl] ${e}`); } async initialize() { try { if (this.options.hmr) { let e = await this.parseLocales(process.cwd()); await this.updateDeclarationFile(e); } } catch (e) { this.logError(`Failed to initialize: ${e instanceof Error ? e.message : String(e)}`); } } parseRequest(e) { let [n, r] = e.split("?", 2), i = new URLSearchParams(r), a = i.get("id"); return { moduleId: n, query: i, pageId: a }; } async parseFileContent(r) { try { let i = path.extname(r); switch (i) { case ".json": { let e = await fs.readFile(r, "utf-8"); return JSON.parse(e); } case ".js": { let e = await import(`file://${r}?t=${Date.now()}`); return e.default || e; } case ".ts": { let e = (await fs.readFile(r, "utf-8")).replace(/:\s*[^=,;\{\}\[\]]+/g, "").replace(/export\s+type\s+[^;]+;/g, "").replace(/import\s+type\s+[^;]+;/g, ""), i = r.replace(".ts", ".temp.mjs"); await fs.writeFile(i, e); try { let e = await import(`file://${i}?t=${Date.now()}`), r = e.default || e; return await fs.unlink(i).catch(() => {}), r; } catch (e) { throw await fs.unlink(i).catch(() => {}), e; } } default: throw Error(`Unsupported file extension: ${i}`); } } catch (n) { let i = n instanceof Error ? n.message : String(n); throw Error(`Failed to parse file: ${path.basename(r)} - ${i}`); } } async scanLocaleFiles(n) { let i = this.options.extensions.map((n) => path.posix.join("**", `*${n}`).replace(/\\/g, "/")); try { let a = await fg(i, { cwd: n, absolute: !0, onlyFiles: !0, ignore: [ "**/node_modules/**", "**/dist/**", "**/.git/**", ...this.options.exclude ] }); return this.options.include && this.options.include.length > 0 ? a.filter((r) => { let i = path.relative(n, r); return this.options.include.some((e) => i.includes(e)); }) : a; } catch { return this.logDebug(`Failed to scan directory: ${n}`), []; } } extractLocaleFromPath(n, r) { let i = n.replace(/\\/g, "/"), a = r.replace(/\\/g, "/"), o = i.replace(a + "/", ""); if (this.options.localePattern === "directory") { let e = o.split("/"); return e.length > 0 ? e[0] : null; } else { let n = path.basename(o, path.extname(o)).match(/\.([a-z]{2}(-[A-Z]{2})?)$/); return n ? n[1] : null; } } extractNamespaceFromPath(n, r, i) { let a = n.replace(/\\/g, "/"), o = r.replace(/\\/g, "/"), s = a.replace(o + "/", ""); if (this.options.localePattern === "directory") { let n = s.replace(`${i}/`, ""); return n.replace(path.extname(n), "").replace(/\//g, "."); } else return path.basename(s, path.extname(s)).replace(/\.[a-z]{2}(-[A-Z]{2})?$/, ""); } async parseLocales(r) { let i = path.resolve(r, this.options.basePath); try { await fs.access(i); } catch { return this.logDebug(`Locales directory not found: ${i}`), {}; } let a = await this.scanLocaleFiles(i); this.logDebug(`Found ${a.length} locale files`); let o = {}; for (let n of a) try { let r = this.extractLocaleFromPath(n, i); if (!r) { this.logDebug(`Could not extract locale code from: ${n}`); continue; } let a = this.extractNamespaceFromPath(n, i, r), s = await this.parseFileContent(n); o[r] || (o[r] = {}); let c = a || "default"; o[r][c] ? o[r][c] = this.deepMerge(o[r][c], s) : o[r][c] = s, this.logDebug(`✓ Parsed: ${r}/${c} <- ${path.basename(n)}`); } catch { let r = path.relative(i, n); this.logDebug(`✗ Failed to parse locale file: ${r}`); } return o; } deepMerge(e, n) { if (typeof e != "object" || !e || Array.isArray(e)) return n; if (typeof n != "object" || !n || Array.isArray(n)) return e; let r = { ...e }; for (let i in n) if (Object.prototype.hasOwnProperty.call(n, i)) { let a = n[i], o = e[i]; typeof a == "object" && a && !Array.isArray(a) && typeof o == "object" && o && !Array.isArray(o) ? r[i] = this.deepMerge(o, a) : a !== void 0 && (r[i] = a); } return r; } collectAllResourceKeys(e) { let n = /* @__PURE__ */ new Set(); return Object.values(e).forEach((e) => { Object.entries(e).forEach(([e, r]) => { e === "default" ? this.collectKeysFromObject(r, "", n) : this.collectKeysFromObject(r, e, n); }); }), Array.from(n).sort(); } collectKeysFromObject(e, n, r) { typeof e != "object" || !e || Array.isArray(e) || Object.keys(e).forEach((i) => { let a = n ? `${n}.${i}` : i, o = e[i]; typeof o == "object" && o && !Array.isArray(o) ? this.collectKeysFromObject(o, a, r) : r.add(a); }); } async updateDeclarationFile(r) { if (!this.options.hmr) { this.logDebug("HMR disabled, skipping declaration file update."); return; } let a = this.collectAllResourceKeys(r), o = path.resolve(process.cwd(), "node_modules/@gulibs/react-vintl/react-vintl-locales.d.ts"), s = path.resolve(process.cwd(), "react-vintl-locales.d.ts"), c = existsSync(o) ? o : s; try { let e; try { e = await fs.readFile(c, "utf-8"); } catch { this.logDebug("Declaration file not found, creating default template"), e = this.createDefaultDeclarationContent(); } let r = a.length > 0 ? a.map((e) => ` | '${e}'`).join("\n") : " | string", i = /* @__PURE__ */ new Set(); a.forEach((e) => { let n = e.indexOf("."); n > 0 && i.add(e.substring(0, n)); }); let o = i.size > 0 ? Array.from(i).sort().map((e) => ` | '${e}'`).join("\n") : " | string"; e = e.replace(/export type I18nKeys =[\s\S]*?;/, `export type I18nKeys =\n${r};`), e = e.replace(/export type I18nNamespaces =[\s\S]*?;/, `export type I18nNamespaces =\n${o};`), await fs.writeFile(c, e, "utf-8"), this.logDebug(`Updated declaration file with ${a.length} keys and ${i.size} namespaces`); } catch (e) { this.logError(`Failed to update declaration file: ${e instanceof Error ? e.message : String(e)}`); } } createDefaultDeclarationContent() { return "declare module '@gulibs/react-vintl-locales' {\n /**\n * 翻译资源对象(运行时从虚拟模块导入)\n */\n export const resources: Record<string, Record<string, any>>;\n\n /**\n * 支持的语言列表\n */\n export const supportedLocales: readonly string[];\n\n /**\n * 插件配置信息\n */\n export const config: {\n readonly supportedLocales: readonly string[];\n readonly basePath: string;\n readonly localePattern: 'directory' | 'filename';\n };\n\n /**\n * 所有翻译键的数组(用于运行时验证)\n */\n export const keys: readonly string[];\n\n /**\n * 翻译资源的类型(自动从虚拟模块推导)\n */\n export type I18nResources = typeof resources;\n\n /**\n * 支持的语言类型\n */\n export type I18nLocales = typeof supportedLocales[number];\n\n /**\n * 翻译键的联合类型(由插件自动生成)\n * 提供完美的 IDE 自动补全支持\n * * ⚠️ 此类型在开发时由插件自动更新,请勿手动修改\n */\n export type I18nKeys =\n | string;\n\n /**\n * 命名空间的联合类型(由插件自动生成)\n * 提供完美的 IDE 自动补全支持\n * * ⚠️ 此类型在开发时由插件自动更新,请勿手动修改\n */\n export type I18nNamespaces =\n | string;\n\n export default resources;\n}\n\ndeclare module '@gulibs/react-vintl/react-vintl-locales' {\n export * from '@gulibs/react-vintl-locales';\n}\n"; } generateTypeScriptExports(e) { let n = ""; n += "// Auto-generated by @gulibs/react-vintl - Do not edit manually!\n", n += `// Generated at: ${(/* @__PURE__ */ new Date()).toISOString()}\n\n`, n += "const resources = ", n += this.stringifyResources(e, 0), n += ";\n\n"; let r = Object.keys(e), i = this.collectAllResourceKeys(e); return n += "/**\n * 翻译资源对象\n */\n", n += "export { resources };\n", n += "export default resources;\n\n", n += "/**\n * 支持的语言列表\n */\n", n += `export const supportedLocales = ${JSON.stringify(r)};\n\n`, n += "/**\n * 插件配置信息\n */\n", n += "export const config = {\n", n += ` supportedLocales: ${JSON.stringify(r)},\n`, n += ` basePath: ${JSON.stringify(this.options.basePath)},\n`, n += ` localePattern: ${JSON.stringify(this.options.localePattern)}\n`, n += "};\n\n", n += "/**\n * 所有翻译键的数组(用于运行时验证)\n */\n", n += `export const keys = ${JSON.stringify(i)};\n\n`, n; } stringifyResources(e, n = 0) { if (e === null) return "null"; if (e === void 0) return "undefined"; if (typeof e == "string") return JSON.stringify(e); if (typeof e == "number" || typeof e == "boolean") return String(e); if (Array.isArray(e)) { let r = " ".repeat(n + 2); return `[\n${e.map((e) => `${r}${this.stringifyResources(e, n + 2)}`).join(",\n")}\n${" ".repeat(n)}]`; } if (typeof e == "object" && e) { let r = " ".repeat(n + 2), i = Object.entries(e); return i.length === 0 ? "{}" : `{\n${i.map(([e, i]) => { let a = this.needsQuotes(e) ? JSON.stringify(e) : e; return `${r}${a}: ${this.stringifyResources(i, n + 2)}`; }).join(",\n")}\n${" ".repeat(n)}}`; } return String(e); } needsQuotes(e) { return !/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(e); } async generateLocales() { if (this.isGenerating && this.generationPromise) { this.logDebug("Waiting for existing locale generation..."); let e = await this.generationPromise; if (e) return e; } let e = "locales", n = this.cache.get(e); if (n && Date.now() - n.timestamp < 5e3) return n.content; this.isGenerating = !0, this.generationPromise = (async () => { try { let n = await this.parseLocales(process.cwd()); await this.updateDeclarationFile(n); let r = this.generateTypeScriptExports(n); return this.cache.set(e, { content: r, timestamp: Date.now(), localeCount: Object.keys(n).length }), this.logDebug(`Generated ${Object.keys(n).length} locales with TypeScript types`), r; } catch (e) { console.error(`Failed to generate locales: ${e instanceof Error ? e.message : String(e)}`); return; } finally { this.isGenerating = !1, this.generationPromise = null; } })(); let r = await this.generationPromise; if (r === void 0) throw Error("Failed to generate locales."); return r; } dispose() { this.cache.clear(), this.server = void 0; } }; function createReactVintlPlugin(e = {}) { let n = new I18nContext(e); return [{ name: "@gulibs/react-vintl", async configResolved(e) { e.command === "serve" && await n.initialize(); }, configureServer(e) { n.setupServer(e); }, resolveId(e) { if (MODULE_IDS.includes(e)) return `${VIRTUAL_MODULE_ID}?id=${e}`; }, async load(e) { let { moduleId: r, pageId: i } = n.parseRequest(e); if (r === VIRTUAL_MODULE_ID && i && MODULE_IDS.includes(i)) return await n.generateLocales(); }, buildEnd() { n.dispose(); } }]; } export { createReactVintlPlugin };