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