vite-intlayer
Version:
A Vite plugin for seamless internationalization (i18n), providing locale detection, redirection, and environment-based configuration
519 lines (517 loc) • 18.7 kB
JavaScript
import { existsSync } from "node:fs";
import { mkdir, readFile } from "node:fs/promises";
import { createRequire } from "node:module";
import { join, relative } from "node:path";
import { intlayerExtractBabelPlugin } from "@intlayer/babel";
import { buildDictionary, buildFilesList, prepareIntlayer, writeContentDeclaration } from "@intlayer/chokidar";
import { ANSIColors, colorize, colorizeKey, colorizePath, getAppLogger, getConfiguration } from "@intlayer/config";
//#region src/IntlayerCompilerPlugin.ts
/**
* Create an IntlayerCompiler - A Vite-compatible compiler plugin for Intlayer
*
* This autonomous compiler handles:
* - Configuration loading and management
* - Hot Module Replacement (HMR) for content changes
* - File transformation with content extraction
* - Dictionary persistence and building
*
* @example
* ```ts
* // vite.config.ts
* import { defineConfig } from 'vite';
* import { intlayerCompiler } from 'vite-intlayer';
*
* export default defineConfig({
* plugins: [intlayerCompiler()],
* });
* ```
*/
const intlayerCompiler = (options) => {
let config;
let logger;
let projectRoot = "";
let filesList = [];
let babel = null;
let pendingDictionaryWrite = null;
const recentlyProcessedFiles = /* @__PURE__ */ new Map();
const recentDictionaryContent = /* @__PURE__ */ new Map();
const DEBOUNCE_MS = 500;
const configOptions = options?.configOptions;
const customCompilerConfig = options?.compilerConfig;
/**
* Check if a file was recently processed (within debounce window)
* and should be skipped to prevent infinite loops
*/
const wasRecentlyProcessed = (filePath) => {
const lastProcessed = recentlyProcessedFiles.get(filePath);
if (!lastProcessed) return false;
return Date.now() - lastProcessed < DEBOUNCE_MS;
};
/**
* Mark a file as recently processed
*/
const markAsProcessed = (filePath) => {
recentlyProcessedFiles.set(filePath, Date.now());
const now = Date.now();
for (const [path, timestamp] of recentlyProcessedFiles.entries()) if (now - timestamp > DEBOUNCE_MS * 2) recentlyProcessedFiles.delete(path);
};
/**
* Create a simple hash of content for comparison
* Used to detect if dictionary content has actually changed
*/
const hashContent = (content) => {
return JSON.stringify(Object.keys(content).sort().map((k) => [k, content[k]]));
};
/**
* Check if dictionary content has changed since last write
*/
const hasDictionaryContentChanged = (dictionaryKey, content) => {
const newHash = hashContent(content);
if (recentDictionaryContent.get(dictionaryKey) === newHash) return false;
recentDictionaryContent.set(dictionaryKey, newHash);
return true;
};
/**
* Get compiler config from intlayer config or custom options
*/
const getCompilerConfig = () => {
const rawConfig = config;
return {
enabled: customCompilerConfig?.enabled ?? rawConfig.compiler?.enabled ?? true,
transformPattern: customCompilerConfig?.transformPattern ?? rawConfig.compiler?.transformPattern ?? config.build.traversePattern,
excludePattern: [
...customCompilerConfig?.excludePattern ?? [],
"**/node_modules/**",
...config.content.fileExtensions.map((pattern) => `*${pattern}`)
],
outputDir: customCompilerConfig?.outputDir ?? rawConfig.compiler?.outputDir ?? "compiler"
};
};
/**
* Get the output directory path for compiler dictionaries
*/
const getOutputDir = () => {
const { baseDir } = config.content;
return join(baseDir, getCompilerConfig().outputDir);
};
/**
* Get the file path for a dictionary
*/
const getDictionaryFilePath = (dictionaryKey) => {
return join(getOutputDir(), `${dictionaryKey}.content.json`);
};
/**
* Read an existing dictionary file if it exists
*/
const readExistingDictionary = async (dictionaryKey) => {
const filePath = getDictionaryFilePath(dictionaryKey);
if (!existsSync(filePath)) return null;
try {
const content = await readFile(filePath, "utf-8");
return JSON.parse(content);
} catch {
return null;
}
};
/**
* Merge extracted content with existing dictionary for multilingual format.
* - Keys in extracted but not in existing: added with default locale only
* - Keys in both: preserve existing translations, update default locale value
* - Keys in existing but not in extracted: removed (no longer in source)
*/
const mergeWithExistingMultilingualDictionary = (extractedContent, existingDictionary, defaultLocale) => {
const mergedContent = {};
const existingContent = existingDictionary?.content;
for (const [key, value] of Object.entries(extractedContent)) {
const existingEntry = existingContent?.[key];
if (existingEntry && existingEntry.nodeType === "translation" && existingEntry.translation) {
const oldValue = existingEntry.translation[defaultLocale];
const isUpdated = oldValue !== value;
mergedContent[key] = {
nodeType: "translation",
translation: {
...existingEntry.translation,
[defaultLocale]: value
}
};
if (isUpdated) logger(`${colorize("Compiler:", ANSIColors.GREY_DARK)} Updated "${key}" [${defaultLocale}]: "${oldValue?.slice(0, 30)}..." → "${value.slice(0, 30)}..."`, {
level: "info",
isVerbose: true
});
} else {
mergedContent[key] = {
nodeType: "translation",
translation: { [defaultLocale]: value }
};
logger(`${colorize("Compiler:", ANSIColors.GREY_DARK)} Added new key "${key}"`, {
level: "info",
isVerbose: true
});
}
}
if (existingContent) {
const removedKeys = Object.keys(existingContent).filter((key) => !(key in extractedContent));
for (const key of removedKeys) logger(`${colorize("Compiler:", ANSIColors.GREY_DARK)} Removed key "${key}" (no longer in source)`, {
level: "info",
isVerbose: true
});
}
return mergedContent;
};
/**
* Merge extracted content with existing dictionary for per-locale format.
* - Keys in extracted but not in existing: added
* - Keys in both: update value
* - Keys in existing but not in extracted: removed (no longer in source)
*/
const mergeWithExistingPerLocaleDictionary = (extractedContent, existingDictionary, defaultLocale) => {
const mergedContent = {};
const existingContent = existingDictionary?.content;
for (const [key, value] of Object.entries(extractedContent)) {
const existingValue = existingContent?.[key];
if (existingValue && typeof existingValue === "string") {
const isUpdated = existingValue !== value;
mergedContent[key] = value;
if (isUpdated) logger(`${colorize("Compiler:", ANSIColors.GREY_DARK)} Updated "${key}" [${defaultLocale}]: "${existingValue?.slice(0, 30)}..." → "${value.slice(0, 30)}..."`, {
level: "info",
isVerbose: true
});
} else {
mergedContent[key] = value;
logger(`${colorize("Compiler:", ANSIColors.GREY_DARK)} Added new key "${key}"`, {
level: "info",
isVerbose: true
});
}
}
if (existingContent) {
const removedKeys = Object.keys(existingContent).filter((key) => !(key in extractedContent));
for (const key of removedKeys) logger(`${colorize("Compiler:", ANSIColors.GREY_DARK)} Removed key "${key}" (no longer in source)`, {
level: "info",
isVerbose: true
});
}
return mergedContent;
};
/**
* Build the list of files to transform based on configuration patterns
*/
const buildFilesListFn = async () => {
const { baseDir, fileExtensions } = config.content;
const compilerConfig = getCompilerConfig();
const excludePatterns = Array.isArray(compilerConfig.excludePattern) ? compilerConfig.excludePattern : [compilerConfig.excludePattern];
filesList = buildFilesList({
transformPattern: compilerConfig.transformPattern,
excludePattern: [
...excludePatterns,
"**/node_modules/**",
...fileExtensions.map((pattern) => `**/*${pattern}`)
],
baseDir
});
};
/**
* Initialize the compiler with the given mode
*/
const init = async (_compilerMode) => {
config = getConfiguration(configOptions);
logger = getAppLogger(config);
try {
babel = createRequire(import.meta.url)("@babel/core");
} catch {
logger("Failed to load @babel/core. Transformation will be disabled.", { level: "warn" });
}
await buildFilesListFn();
};
/**
* Vite hook: config
* Called before Vite config is resolved - perfect time to prepare dictionaries
*/
const configHook = async (_config, env) => {
config = getConfiguration(configOptions);
logger = getAppLogger(config);
const isDevCommand = env.command === "serve" && env.mode === "development";
const isBuildCommand = env.command === "build";
if (isDevCommand || isBuildCommand) await prepareIntlayer(config, {
clean: isBuildCommand,
cacheTimeoutMs: isBuildCommand ? 1e3 * 30 : 1e3 * 60 * 60
});
};
/**
* Vite hook: configResolved
* Called when Vite config is resolved
*/
const configResolved = async (viteConfig) => {
const compilerMode = viteConfig.env?.DEV ? "dev" : "build";
projectRoot = viteConfig.root;
await init(compilerMode);
};
/**
* Build start hook - no longer needs to prepare dictionaries
* The compiler is now autonomous and extracts content inline
*/
const buildStart = async () => {
logger("Intlayer compiler initialized", { level: "info" });
};
/**
* Build end hook - wait for any pending dictionary writes
*/
const buildEnd = async () => {
if (pendingDictionaryWrite) await pendingDictionaryWrite;
};
/**
* Configure the dev server
*/
const configureServer = async () => {};
/**
* Vite hook: handleHotUpdate
* Handles HMR for content files - invalidates cache and triggers re-transform
*/
const handleHotUpdate = async (ctx) => {
const { file, server, modules } = ctx;
if (filesList.some((f) => f === file)) {
if (wasRecentlyProcessed(file)) {
logger(`${colorize("Compiler:", ANSIColors.GREY_DARK)} Skipping re-transform of ${colorizePath(relative(projectRoot, file))} (recently processed)`, {
level: "info",
isVerbose: true
});
return;
}
markAsProcessed(file);
for (const mod of modules) server.moduleGraph.invalidateModule(mod);
try {
await transformHandler(await readFile(file, "utf-8"), file);
} catch (error) {
logger(`${colorize("Compiler:", ANSIColors.GREY_DARK)} Failed to re-transform ${file}: ${error}`, { level: "error" });
}
server.ws.send({ type: "full-reload" });
return [];
}
};
/**
* Write and build a single dictionary immediately
* This is called during transform to ensure dictionaries are always up-to-date.
*
* The merge strategy:
* - New keys are added with the default locale only
* - Existing keys preserve their translations, with default locale updated
* - Keys no longer in source are removed
*
* Dictionary format:
* - Per-locale: When config.dictionary.locale is set, content is simple strings with locale property
* - Multilingual: When not set, content is wrapped in translation nodes without locale property
*/
const writeAndBuildDictionary = async (result) => {
const { dictionaryKey, content } = result;
if (!hasDictionaryContentChanged(dictionaryKey, content)) {
logger(`${colorize("Compiler:", ANSIColors.GREY_DARK)} Skipping dictionary ${colorizeKey(dictionaryKey)} (content unchanged)`, {
level: "info",
isVerbose: true
});
return;
}
const outputDir = getOutputDir();
const { defaultLocale } = config.internationalization;
const isPerLocaleFile = Boolean(config?.dictionary?.locale);
await mkdir(outputDir, { recursive: true });
const existingDictionary = await readExistingDictionary(dictionaryKey);
const relativeFilePath = join(relative(config.content.baseDir, outputDir), `${dictionaryKey}.content.json`);
let mergedDictionary;
if (isPerLocaleFile) {
const mergedContent = mergeWithExistingPerLocaleDictionary(content, existingDictionary, defaultLocale);
mergedDictionary = {
...existingDictionary && {
$schema: existingDictionary.$schema,
id: existingDictionary.id,
title: existingDictionary.title,
description: existingDictionary.description,
tags: existingDictionary.tags,
fill: existingDictionary.fill,
filled: existingDictionary.filled,
priority: existingDictionary.priority,
version: existingDictionary.version
},
key: dictionaryKey,
content: mergedContent,
locale: defaultLocale,
filePath: relativeFilePath
};
} else {
const mergedContent = mergeWithExistingMultilingualDictionary(content, existingDictionary, defaultLocale);
mergedDictionary = {
...existingDictionary && {
$schema: existingDictionary.$schema,
id: existingDictionary.id,
title: existingDictionary.title,
description: existingDictionary.description,
tags: existingDictionary.tags,
fill: existingDictionary.fill,
filled: existingDictionary.filled,
priority: existingDictionary.priority,
version: existingDictionary.version
},
key: dictionaryKey,
content: mergedContent,
filePath: relativeFilePath
};
}
try {
const writeResult = await writeContentDeclaration(mergedDictionary, config, { newDictionariesPath: relative(config.content.baseDir, outputDir) });
logger(`${colorize("Compiler:", ANSIColors.GREY_DARK)} ${writeResult.status === "created" ? "Created" : writeResult.status === "updated" ? "Updated" : "Processed"} content declaration: ${colorizePath(relative(projectRoot, writeResult.path))}`, { level: "info" });
const dictionaryToBuild = {
...mergedDictionary,
filePath: relative(config.content.baseDir, writeResult.path)
};
logger(`${colorize("Compiler:", ANSIColors.GREY_DARK)} Building dictionary ${colorizeKey(dictionaryKey)}`, { level: "info" });
await buildDictionary([dictionaryToBuild], config);
logger(`${colorize("Compiler:", ANSIColors.GREY_DARK)} Dictionary ${colorizeKey(dictionaryKey)} built successfully`, { level: "info" });
} catch (error) {
logger(`${colorize("Compiler:", ANSIColors.GREY_DARK)} Failed to write/build dictionary for ${colorizeKey(dictionaryKey)}: ${error}`, { level: "error" });
}
};
/**
* Callback for when content is extracted from a file
* Immediately writes and builds the dictionary
*/
const handleExtractedContent = (result) => {
const contentKeys = Object.keys(result.content);
logger(`${colorize("Compiler:", ANSIColors.GREY_DARK)} Extracted ${contentKeys.length} content keys from ${colorizePath(relative(projectRoot, result.filePath))}`, { level: "info" });
pendingDictionaryWrite = (pendingDictionaryWrite ?? Promise.resolve()).then(() => writeAndBuildDictionary(result)).catch((error) => {
logger(`${colorize("Compiler:", ANSIColors.GREY_DARK)} Error in dictionary write chain: ${error}`, { level: "error" });
});
};
/**
* Detect the package name to import useIntlayer from based on file extension
*/
const detectPackageName = (filename) => {
if (filename.endsWith(".vue")) return "vue-intlayer";
if (filename.endsWith(".svelte")) return "svelte-intlayer";
if (filename.endsWith(".tsx") || filename.endsWith(".jsx")) return "react-intlayer";
return "intlayer";
};
/**
* Transform a Vue file using the Vue extraction plugin
*/
const transformVue = async (code, filename, defaultLocale) => {
const { intlayerVueExtract } = await import("@intlayer/vue-compiler");
return intlayerVueExtract(code, filename, {
defaultLocale,
filesList,
packageName: "vue-intlayer",
onExtract: handleExtractedContent
});
};
/**
* Transform a Svelte file using the Svelte extraction plugin
*/
const transformSvelte = async (code, filename, defaultLocale) => {
const { intlayerSvelteExtract } = await import("@intlayer/svelte-compiler");
return await intlayerSvelteExtract(code, filename, {
defaultLocale,
filesList,
packageName: "svelte-intlayer",
onExtract: handleExtractedContent
});
};
/**
* Transform a JSX/TSX file using the Babel extraction plugin
*/
const transformJsx = (code, filename, defaultLocale) => {
if (!babel) return;
const packageName = detectPackageName(filename);
const result = babel.transformSync(code, {
filename,
plugins: [[intlayerExtractBabelPlugin, {
defaultLocale,
filesList,
packageName,
onExtract: handleExtractedContent
}]],
parserOpts: {
sourceType: "module",
allowImportExportEverywhere: true,
plugins: [
"typescript",
"jsx",
"decorators-legacy",
"classProperties",
"objectRestSpread",
"asyncGenerators",
"functionBind",
"exportDefaultFrom",
"exportNamespaceFrom",
"dynamicImport",
"nullishCoalescingOperator",
"optionalChaining"
]
}
});
if (result?.code) return {
code: result.code,
map: result.map,
extracted: true
};
};
/**
* Transform a file using the appropriate extraction plugin based on file type
*/
const transformHandler = async (code, id, _options) => {
if (!getCompilerConfig().enabled) return;
if (id.includes("?")) return;
const { defaultLocale } = config.internationalization;
const filename = id;
if (!filesList.includes(filename)) return;
const isVue = filename.endsWith(".vue");
const isSvelte = filename.endsWith(".svelte");
if (!isVue && !isSvelte) {
try {
const result = transformJsx(code, filename, defaultLocale);
if (pendingDictionaryWrite) await pendingDictionaryWrite;
if (result?.code) return {
code: result.code,
map: result.map
};
} catch (error) {
logger(`Failed to transform ${colorizePath(relative(projectRoot, filename))}: ${error}`, { level: "error" });
}
return;
}
logger(`${colorize("Compiler:", ANSIColors.GREY_DARK)} Transforming ${colorizePath(relative(projectRoot, filename))}`, { level: "info" });
try {
let result;
if (isVue) result = await transformVue(code, filename, defaultLocale);
else if (isSvelte) result = await transformSvelte(code, filename, defaultLocale);
if (pendingDictionaryWrite) await pendingDictionaryWrite;
if (result?.code) return {
code: result.code,
map: result.map
};
} catch (error) {
logger(`Failed to transform ${relative(projectRoot, filename)}: ${error}`, { level: "error" });
}
};
/**
* Apply hook for determining when plugin should be active
*/
const apply = (_config, _env) => {
return getCompilerConfig().enabled;
};
return {
name: "vite-intlayer-compiler",
enforce: "pre",
config: configHook,
configResolved,
buildStart,
buildEnd,
configureServer,
handleHotUpdate,
transform: transformHandler,
apply: (_viteConfig, env) => {
if (!config) config = getConfiguration(configOptions);
return apply(_viteConfig, env);
}
};
};
//#endregion
export { intlayerCompiler };
//# sourceMappingURL=IntlayerCompilerPlugin.mjs.map