UNPKG

@gulibs/react-vintl

Version:

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

345 lines (344 loc) 14.9 kB
import { n as logger } from "./logger-p3Rg7WdR.js"; 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", "@gulibs/react-vintl/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.rootPath = process.cwd(), this.isBuildMode = !1, this.options = { ...DEFAULT_OPTIONS, ...e }; } setBuildMode(e) { this.isBuildMode = e; } setRootPath(e) { this.rootPath = e; } setupServer(e) { this.server = e, this.options.hmr && this.setupWatcher(e.watcher); } setupWatcher(e) { let n = path.resolve(this.rootPath, this.options.basePath); e.on("add", async (e) => { e.startsWith(n) && (this.logDebug(`Added locale file: ${e}`), await this.invalidateCache()); }), e.on("change", async (e) => { e.startsWith(n) && (this.logDebug(`Changed locale file: ${e}`), await this.invalidateCache()); }), e.on("unlink", async (e) => { e.startsWith(n) && (this.logDebug(`Removed locale file: ${e}`), await this.invalidateCache()); }); } async invalidateCache() { this.cache.clear(), this.isGenerating = !1, this.generationPromise = null; try { if (!this.options.hmr) { this.logDebug("HMR disabled, skipping type update on file change."); return; } let e = await this.parseLocales(); await this.updateDeclarationFile(e), this.logDebug("Cache invalidated, will regenerate on next request"); } catch (e) { this.logError(`Failed to update types on file change: ${e instanceof Error ? e.message : String(e)}`); } if (this.server) for (let e of MODULE_IDS) { let t = this.server.moduleGraph.getModuleById(`${VIRTUAL_MODULE_ID}?id=${e}`); t && (this.server.reloadModule(t), this.logDebug(`Hot reloaded locale resources for ${e}`)); } } logDebug(t) { this.options.debug && logger.debug(t); } logError(t) { logger.error(t); } async initialize() { try { if (this.options.hmr) { let e = await this.parseLocales(); await this.updateDeclarationFile(e); } } catch (e) { this.logError(`Failed to initialize: ${e instanceof Error ? e.message : String(e)}`); } } parseRequest(e) { let [t, n] = e.split("?", 2), r = new URLSearchParams(n), i = r.get("id"); return { moduleId: t, query: r, pageId: i }; } async parseFileContent(e) { try { let r = path.extname(e); switch (r) { case ".json": { let t = await fs.readFile(e, "utf-8"); return JSON.parse(t); } case ".js": { let t = await import(`file://${e}?t=${Date.now()}`); return t.default || t; } case ".ts": { let t = (await fs.readFile(e, "utf-8")).replace(/:\s*[^=,;\{\}\[\]]+/g, "").replace(/export\s+type\s+[^;]+;/g, "").replace(/import\s+type\s+[^;]+;/g, ""), r = e.replace(".ts", ".temp.mjs"); await fs.writeFile(r, t); try { let e = await import(`file://${r}?t=${Date.now()}`), t = e.default || e; return await fs.unlink(r).catch(() => {}), t; } catch (e) { throw await fs.unlink(r).catch(() => {}), e; } } default: throw Error(`Unsupported file extension: ${r}`); } } catch (n) { let r = n instanceof Error ? n.message : String(n); throw Error(`Failed to parse file: ${path.basename(e)} - ${r}`); } } async scanLocaleFiles(e) { let n = this.options.extensions.map((e) => path.posix.join("**", `*${e}`).replace(/\\/g, "/")); try { let i = await fg(n, { cwd: e, absolute: !0, onlyFiles: !0, ignore: [ "**/node_modules/**", "**/dist/**", "**/.git/**", ...this.options.exclude ] }); return this.options.include && this.options.include.length > 0 ? i.filter((n) => { let r = path.relative(e, n); return this.options.include.some((e) => r.includes(e)); }) : i; } catch { return this.logDebug(`Failed to scan directory: ${e}`), []; } } extractLocaleFromPath(e, n) { let r = e.replace(/\\/g, "/"), i = n.replace(/\\/g, "/"), a = r.replace(i + "/", ""); if (this.options.localePattern === "directory") { let e = a.split("/"); return e.length > 0 ? e[0] : null; } else { let e = path.basename(a, path.extname(a)).match(/\.([a-z]{2}(-[A-Z]{2})?)$/); return e ? e[1] : null; } } extractNamespaceFromPath(e, n, r) { let i = e.replace(/\\/g, "/"), a = n.replace(/\\/g, "/"), o = i.replace(a + "/", ""); if (this.options.localePattern === "directory") { let e = o.replace(`${r}/`, ""); return e.replace(path.extname(e), "").replace(/\//g, "."); } else return path.basename(o, path.extname(o)).replace(/\.[a-z]{2}(-[A-Z]{2})?$/, ""); } async parseLocales(e) { let r = e || this.rootPath, 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 e of a) try { let n = this.extractLocaleFromPath(e, i); if (!n) { this.logDebug(`Could not extract locale code from: ${e}`); continue; } let r = this.extractNamespaceFromPath(e, i, n), a = await this.parseFileContent(e); o[n] || (o[n] = {}); let s = r || "default"; o[n][s] ? o[n][s] = this.deepMerge(o[n][s], a) : o[n][s] = a, this.logDebug(`✓ Parsed: ${n}/${s} <- ${path.basename(e)}`); } catch { let n = path.relative(i, e); this.logDebug(`✗ Failed to parse locale file: ${n}`); } return o; } deepMerge(e, t) { if (typeof e != "object" || !e || Array.isArray(e)) return t; if (typeof t != "object" || !t || Array.isArray(t)) return e; let n = { ...e }; for (let r in t) if (Object.prototype.hasOwnProperty.call(t, r)) { let i = t[r], a = e[r]; typeof i == "object" && i && !Array.isArray(i) && typeof a == "object" && a && !Array.isArray(a) ? n[r] = this.deepMerge(a, i) : i !== void 0 && (n[r] = i); } return n; } collectAllResourceKeys(e) { let t = /* @__PURE__ */ new Set(); return Object.values(e).forEach((e) => { Object.entries(e).forEach(([e, n]) => { e === "default" ? this.collectKeysFromObject(n, "", t) : this.collectKeysFromObject(n, e, t); }); }), Array.from(t).sort(); } collectKeysFromObject(e, t, n) { typeof e != "object" || !e || Array.isArray(e) || Object.keys(e).forEach((r) => { let i = t ? `${t}.${r}` : r, a = e[r]; typeof a == "object" && a && !Array.isArray(a) ? this.collectKeysFromObject(a, i, n) : n.add(i); }); } async updateDeclarationFile(e, r = !1) { if (!this.options.hmr && !this.isBuildMode) { this.logDebug("HMR disabled and not in build mode, skipping declaration file update."); return; } let a = this.collectAllResourceKeys(e), o = path.resolve(this.rootPath, "node_modules/@gulibs/react-vintl/react-vintl-locales.d.ts"), s = path.resolve(this.rootPath, "react-vintl-locales.d.ts"), c = existsSync(o) ? o : s, l = a.length > 0 ? a.map((e) => ` | '${e}'`).join("\n") : " | string", u = /* @__PURE__ */ new Set(); a.forEach((e) => { let t = e.indexOf("."); t > 0 && u.add(e.substring(0, t)); }); let d = u.size > 0 ? Array.from(u).sort().map((e) => ` | '${e}'`).join("\n") : " | string", f; try { f = await fs.readFile(c, "utf-8"); } catch { this.logDebug("Declaration file not found, creating default template"), f = this.createDefaultDeclarationContent(); } let p = f; if (p = p.replace(/export type I18nKeys =[\s\S]*?;/, `export type I18nKeys =\n${l};`), p = p.replace(/export type I18nNamespaces =[\s\S]*?;/, `export type I18nNamespaces =\n${d};`), !r && p === f) { this.logDebug("Declaration file content unchanged, skipping write"); return; } let m = null; for (let e = 1; e <= 3; e++) try { await fs.writeFile(c, p, "utf-8"); try { let e = /* @__PURE__ */ new Date(); await fs.utimes(c, e, e), this.logDebug("Touched declaration file to trigger TypeScript server reload"); } catch (e) { this.logDebug(`Failed to touch declaration file: ${e instanceof Error ? e.message : String(e)}`); } this.logDebug(`Updated declaration file with ${a.length} keys and ${u.size} namespaces (attempt ${e})`); return; } catch (t) { m = t instanceof Error ? t : Error(String(t)), e < 3 && (this.logDebug(`Failed to update declaration file (attempt ${e}/3), retrying in 100ms...`), await new Promise((e) => setTimeout(e, 100))); } this.logError(`Failed to update declaration file after 3 attempts: ${m?.message || "Unknown error"}`); } 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 t = ""; t += "// Auto-generated by @gulibs/react-vintl - Do not edit manually!\n", t += `// Generated at: ${(/* @__PURE__ */ new Date()).toISOString()}\n\n`, t += "const resources = ", t += this.stringifyResources(e, 0), t += ";\n\n"; let n = Object.keys(e), r = this.collectAllResourceKeys(e); return t += "/**\n * 翻译资源对象\n */\n", t += "export { resources };\n", t += "export default resources;\n\n", t += "/**\n * 支持的语言列表\n */\n", t += `export const supportedLocales = ${JSON.stringify(n)};\n\n`, t += "/**\n * 插件配置信息\n */\n", t += "export const config = {\n", t += ` supportedLocales: ${JSON.stringify(n)},\n`, t += ` basePath: ${JSON.stringify(this.options.basePath)},\n`, t += ` localePattern: ${JSON.stringify(this.options.localePattern)}\n`, t += "};\n\n", t += "/**\n * 所有翻译键的数组(用于运行时验证)\n */\n", t += `export const keys = ${JSON.stringify(r)};\n\n`, t; } stringifyResources(e, t = 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 n = " ".repeat(t + 2); return `[\n${e.map((e) => `${n}${this.stringifyResources(e, t + 2)}`).join(",\n")}\n${" ".repeat(t)}]`; } if (typeof e == "object" && e) { let n = " ".repeat(t + 2), r = Object.entries(e); return r.length === 0 ? "{}" : `{\n${r.map(([e, r]) => { let i = this.needsQuotes(e) ? JSON.stringify(e) : e; return `${n}${i}: ${this.stringifyResources(r, t + 2)}`; }).join(",\n")}\n${" ".repeat(t)}}`; } return String(e); } needsQuotes(e) { return !/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(e); } async generateLocales(e = !1) { if (this.isGenerating && this.generationPromise && !e) { this.logDebug("Waiting for existing locale generation..."); let e = await this.generationPromise; if (e) return e; } let t = "locales", n = this.cache.get(t); if (e) this.cache.delete(t), this.isGenerating = !1, this.generationPromise = null; else { let e = this.options.hmr ? 1e3 : 5e3; if (n && Date.now() - n.timestamp < e) return n.content; } this.isGenerating = !0, this.generationPromise = (async () => { try { let e = await this.parseLocales(); try { await this.updateDeclarationFile(e, this.isBuildMode); } catch (e) { if (this.isBuildMode) this.logError(`⚠️ Build-time type file update failed. Type checking may fail. Error: ${e instanceof Error ? e.message : String(e)}`), this.logError("💡 Tip: Try restarting the TypeScript server or check file permissions."); else throw e; } let n = this.generateTypeScriptExports(e); return this.cache.set(t, { content: n, timestamp: Date.now(), localeCount: Object.keys(e).length }), this.logDebug(`Generated ${Object.keys(e).length} locales with TypeScript types`), n; } catch (e) { this.logError(`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 t = new I18nContext(e); return [{ name: "@gulibs/react-vintl", enforce: "pre", async configResolved(e) { t.setRootPath(e.root || process.cwd()), t.setBuildMode(e.command === "build"), e.command === "serve" && await t.initialize(); }, configureServer(e) { t.setupServer(e); }, resolveId(e) { if (MODULE_IDS.includes(e)) return `${VIRTUAL_MODULE_ID}?id=${e}`; if (e.includes("react-vintl-locales") && (e.includes("dist/") || e.includes("node_modules"))) { let t = MODULE_IDS.find((t) => { let n = t.replace("@gulibs/", ""); return e.includes(n) || e.includes("react-vintl-locales"); }); if (t) return `${VIRTUAL_MODULE_ID}?id=${t}`; } if (e.endsWith("react-vintl-locales") || e.endsWith("react-vintl-locales.js")) return `${VIRTUAL_MODULE_ID}?id=${MODULE_IDS[0]}`; }, async load(e) { let { moduleId: n, pageId: r } = t.parseRequest(e); if (n === VIRTUAL_MODULE_ID && r && MODULE_IDS.includes(r)) return await t.generateLocales(); }, async buildEnd() { t.dispose(); } }]; } export { createReactVintlPlugin };