UNPKG

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
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