@gulibs/react-vintl
Version:
Type-safe i18n library for React with Vite plugin and automatic type inference
345 lines (344 loc) • 14.9 kB
JavaScript
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 };