UNPKG

@vyxos/astro-i18next

Version:

I18next integration for Astro with dynamic namespace loading.

832 lines (789 loc) 27.4 kB
'use strict'; var fs = require('fs'); var pathe = require('pathe'); var module$1 = require('module'); var path = require('path'); var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null; function _interopNamespace(e) { if (e && e.__esModule) return e; var n = Object.create(null); if (e) { Object.keys(e).forEach(function (k) { if (k !== 'default') { var d = Object.getOwnPropertyDescriptor(e, k); Object.defineProperty(n, k, d.get ? d : { enumerable: true, get: function () { return e[k]; } }); } }); } n.default = e; return Object.freeze(n); } var fs__namespace = /*#__PURE__*/_interopNamespace(fs); // src/config.ts function applyInternalDefaults(options) { return { translationsDir: options.translationsDir ?? defaultOptions.translationsDir, generatedTypes: { dirPath: options?.generatedTypes?.dirPath ?? defaultOptions.generatedTypes.dirPath, fileName: options?.generatedTypes?.fileName ?? defaultOptions.generatedTypes.fileName } }; } var defaultOptions = { generatedTypes: { dirPath: "types", fileName: "i18next-resources" }, translationsDir: "i18n" }; // src/constants.ts var INTEGRATION_NAME = "@vyxos/astro-i18next"; var isBrowser = typeof window !== "undefined"; var nodeRequire = isBrowser ? null : module$1.createRequire((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('index.cjs', document.baseURI).href))); var colorTimestamp = (value) => { if (isBrowser || !nodeRequire) return value; try { const { dim } = nodeRequire("kleur/colors"); return dim(value); } catch { return value; } }; var colorIntegration = (value) => { if (isBrowser || !nodeRequire) return value; try { const { bold, green } = nodeRequire("kleur/colors"); return bold(green(value)); } catch { return value; } }; var colorWarn = (value) => { if (isBrowser || !nodeRequire) return value; try { const { yellow } = nodeRequire("kleur/colors"); return yellow(value); } catch { return value; } }; var colorError = (value) => { if (isBrowser || !nodeRequire) return value; try { const { red } = nodeRequire("kleur/colors"); return red(value); } catch { return value; } }; var colorWarnPrefix = (value) => { if (isBrowser || !nodeRequire) return value; try { const { bold, yellow } = nodeRequire("kleur/colors"); return bold(yellow(value)); } catch { return value; } }; var colorErrorPrefix = (value) => { if (isBrowser || !nodeRequire) return value; try { const { red } = nodeRequire("kleur/colors"); return red(value); } catch { return value; } }; // src/logger/index.ts function log(message, label = INTEGRATION_NAME) { const timestamp = (/* @__PURE__ */ new Date()).toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" }); console.log( `${colorTimestamp(timestamp)} ${colorIntegration(`[${label}]`)} ${message}` ); } function logError(error, label = INTEGRATION_NAME) { const timestamp = (/* @__PURE__ */ new Date()).toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" }); console.error( `${colorTimestamp(timestamp)} ${colorIntegration(`[${label}]`)} ${colorErrorPrefix(`[ERROR]`)} ${colorError(error)}` ); } function logWarn(message, label = INTEGRATION_NAME) { const timestamp = (/* @__PURE__ */ new Date()).toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" }); console.warn( `${colorTimestamp(timestamp)} ${colorIntegration(`[${label}]`)} ${colorWarnPrefix(`[WARN]`)} ${colorWarn(message)}` ); } // src/loader/translation-loader.ts function loadTranslation(filePath) { if (!fs.existsSync(filePath)) { logWarn(`Translation file not found: ${filePath}`); return {}; } try { const content = fs.readFileSync(filePath, "utf-8"); const parsed = JSON.parse(content); if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { logError( `Invalid translation file format: ${filePath} Expected JSON object, got ${typeof parsed}` ); return {}; } return parsed; } catch (error) { logError( `Failed to parse translation file: ${filePath}. Original error: ${error instanceof Error ? error.message : String(error)}` ); return {}; } } function loadAllTranslations(srcDir, internalOptions, i18nextOptions) { const allTranslations = {}; const localeFilesData = getAllFilePaths( srcDir, internalOptions, i18nextOptions ); for (const data of localeFilesData) { if (allTranslations[data.locale] === void 0) { allTranslations[data.locale] = {}; } allTranslations[data.locale][data.namespace] = loadTranslation(data.path); } return allTranslations; } function scanDirectoryRecursively(currentPath, localeBasePath, locale, filePaths) { try { const entries = fs.readdirSync(currentPath); for (const entry of entries) { const entryPath = pathe.resolve(currentPath, entry); const stats = fs.statSync(entryPath); if (stats.isDirectory()) { scanDirectoryRecursively(entryPath, localeBasePath, locale, filePaths); } else if (entry.endsWith(".json")) { const relativePath = entryPath.substring(localeBasePath.length + 1); const pathParts = relativePath.split(/[/\\]/); pathParts[pathParts.length - 1] = pathParts[pathParts.length - 1].replace(".json", ""); const namespace = pathParts.join("."); filePaths.push({ path: entryPath, locale, namespace }); } } } catch (_error) { logWarn(`Failed to scan directory: ${currentPath}`); } } function getAllFilePaths(srcDir, internalOptions, i18nextOptions) { const filePaths = []; if (i18nextOptions.supportedLngs === false || i18nextOptions.supportedLngs === void 0) { const translationDirPath = pathe.resolve(srcDir, internalOptions.translationsDir); if (!fs.existsSync(translationDirPath)) { return filePaths; } try { const localeEntries = fs.readdirSync(translationDirPath); for (const localeEntry of localeEntries) { const localeDir = pathe.resolve(translationDirPath, localeEntry); if (fs.statSync(localeDir).isDirectory()) { const locale = localeEntry; scanDirectoryRecursively(localeDir, localeDir, locale, filePaths); } } } catch (_error) { logError(`Failed to scan translation directory: ${translationDirPath}`); return filePaths; } return filePaths; } if (Array.isArray(i18nextOptions.supportedLngs)) { let namespaces = []; if (i18nextOptions.ns === void 0) { const translationDirPath = pathe.resolve( srcDir, internalOptions.translationsDir ); const discoveredNamespaces = /* @__PURE__ */ new Set(); for (const locale of i18nextOptions.supportedLngs) { const localeDir = pathe.resolve(translationDirPath, locale); if (fs.existsSync(localeDir) && fs.statSync(localeDir).isDirectory()) { const tempFilePaths = []; scanDirectoryRecursively(localeDir, localeDir, locale, tempFilePaths); tempFilePaths.forEach( ({ namespace }) => discoveredNamespaces.add(namespace) ); } } namespaces = Array.from(discoveredNamespaces); if (namespaces.length === 0) { namespaces = ["translation"]; } } else if (typeof i18nextOptions.ns === "string") { namespaces = [i18nextOptions.ns]; } else if (Array.isArray(i18nextOptions.ns)) { namespaces = i18nextOptions.ns; } for (const locale of i18nextOptions.supportedLngs) { for (const namespace of namespaces) { const filePath = getFilePath( locale, namespace, srcDir, internalOptions.translationsDir ); filePaths.push({ path: filePath, locale, namespace }); } } } return filePaths; } function getFilePath(locale, namespace, srcDir, translationDirectoryPath) { const namespacePath = namespace.replace(/\./g, "/"); return pathe.resolve( srcDir, translationDirectoryPath, `${locale}/${namespacePath}.json` ); } function hasJsonFilesRecursively(dirPath) { try { const entries = fs.readdirSync(dirPath); for (const entry of entries) { const entryPath = pathe.resolve(dirPath, entry); const stats = fs.statSync(entryPath); if (stats.isDirectory()) { if (hasJsonFilesRecursively(entryPath)) { return true; } } else if (entry.endsWith(".json")) { return true; } } return false; } catch { return false; } } function discoverAvailableLanguages(srcDir, translationsDir) { const translationDirPath = pathe.resolve(srcDir, translationsDir); if (!fs.existsSync(translationDirPath)) { logWarn(`Translation directory not found: ${translationDirPath}`); return []; } try { const localeEntries = fs.readdirSync(translationDirPath); const availableLocales = []; for (const localeEntry of localeEntries) { const localeDir = pathe.resolve(translationDirPath, localeEntry); if (fs.statSync(localeDir).isDirectory()) { if (hasJsonFilesRecursively(localeDir)) { availableLocales.push(localeEntry); } } } return availableLocales; } catch (_error) { logError(`Failed to scan translation directory: ${translationDirPath}`); return []; } } // src/scripts/client.ts function generateClientScript(baseConfig, internalOptions, i18nextOptions) { return ` import i18next from "i18next"; import LanguageDetector from "i18next-browser-languagedetector"; // CRITICAL: Read server state synchronously (react-i18next SSR pattern) const initialI18nStore = typeof window !== 'undefined' ? window.__initialI18nStore__ : null; const initialLanguage = typeof window !== 'undefined' ? window.__initialLanguage__ : null; if (initialI18nStore && initialLanguage) { // SYNCHRONOUS initialization with exact server resources i18next.init({ ...${JSON.stringify(baseConfig)}, lng: initialLanguage, resources: initialI18nStore, initImmediate: false, // CRITICAL: Synchronous initialization fallbackLng: false, integrationOptions: ${JSON.stringify({ ...internalOptions, ...i18nextOptions })} }).then(() => { setupTranslationWrapper(); setupDynamicLoading(); }).catch(err => console.error('[ASTRO-I18N] SSR hydration initialization failed:', err)); } else { // Fallback: Dynamic loading for non-SSR or missing server data const dynamicBackend = { type: 'backend', init: function() {}, read: async function(language, namespace, callback) { try { const { loadTranslation } = await import('virtual:i18n-loader'); const data = await loadTranslation(language, namespace); callback(null, data); } catch (err) { console.warn(\`Failed to load \${language}/\${namespace}:\`, err); callback(null, {}); } } }; i18next .use(LanguageDetector) .use(dynamicBackend) .init({ ...${JSON.stringify(baseConfig)}, fallbackLng: false, initImmediate: true, detection: { order: ["htmlTag", "path"], lookupFromPathIndex: 0, caches: [] }, load: 'currentOnly', partialBundledLanguages: true, integrationOptions: ${JSON.stringify({ ...internalOptions, ...i18nextOptions })} }).then(() => { setupTranslationWrapper(); setupDynamicLoading(); }).catch(err => console.error('Dynamic initialization failed:', err)); } // Setup translation wrapper with dev warnings function setupTranslationWrapper() { const originalT = i18next.t; i18next.t = function (...args) { const key = args[0]; const lastArg = args[args.length - 1]; const options = typeof lastArg === 'object' && lastArg !== null ? lastArg : undefined; let namespace; if (typeof key === 'string' && key.includes(':')) { namespace = key.split(':')[0]; } else if (options && 'ns' in options && typeof options.ns === 'string') { namespace = options.ns; } if (import.meta.env.DEV && namespace) { const currentLanguage = i18next.language; if (!i18next.hasResourceBundle(currentLanguage, namespace)) { console.warn( \`Warning: Translation key "\${String(key)}" is being accessed \` + \`for namespace "\${namespace}" in locale "\${currentLanguage}", \` + \`but this namespace is not currently loaded. Ensure '\${namespace}' is loaded \` + \`using 'loadNamespacesForRoute' or 'useLoadNamespaces'.\` ); } } return originalT.apply(this, args); }; } // Setup dynamic loading functions for SPA navigation function setupDynamicLoading() { window.__i18nLoadNamespaces = async function(namespaces) { const currentLang = i18next.language${baseConfig.lng ? ` || '${baseConfig.lng}'` : ""}; const promises = namespaces.map(ns => new Promise((resolve) => { if (i18next.hasResourceBundle(currentLang, ns)) { resolve(null); } else { i18next.loadNamespaces(ns, resolve); } }) ); await Promise.all(promises); }; } `; } // src/scripts/server.ts function generateServerScript(baseConfig, allTranslations, internalOptions, i18nextOptions) { return ` import i18next from "i18next"; const resources = ${JSON.stringify(allTranslations)}; const usedNamespaces = new Set(); i18next.init({ ...${JSON.stringify(baseConfig)}, resources, initImmediate: true, integrationOptions: ${JSON.stringify({ ...internalOptions, ...i18nextOptions })} }).then(() => { // Track namespace usage during SSR const originalT = i18next.t; i18next.t = function(...args) { const key = args[0]; const lastArg = args[args.length - 1]; const options = typeof lastArg === 'object' && lastArg !== null ? lastArg : undefined; let namespace; if (typeof key === 'string' && key.includes(':')) { namespace = key.split(':')[0]; } else if (options && 'ns' in options && typeof options.ns === 'string') { namespace = options.ns; } else { namespace = '${baseConfig.defaultNS || "translation"}'; } if (namespace) { usedNamespaces.add(namespace); } return originalT.apply(this, args); }; // CRITICAL: Serialize exact server state for client hydration if (typeof window !== 'undefined') { const currentLang = i18next.language; // Extract ONLY the resources that were actually used during SSR const usedResources = {}; usedNamespaces.forEach(ns => { if (resources[currentLang] && resources[currentLang][ns]) { if (!usedResources[currentLang]) usedResources[currentLang] = {}; usedResources[currentLang][ns] = resources[currentLang][ns]; } }); // Serialize for synchronous client initialization (react-i18next SSR pattern) window.__initialI18nStore__ = usedResources; window.__initialLanguage__ = currentLang; } }).catch(err => console.error('[${INTEGRATION_NAME}] Server initialization failed:', err)); `; } // src/scripts/ssg.ts function generateSSGSerializationScript(baseConfig, allTranslations, _internalOptions) { const supportedLanguages = baseConfig.supportedLngs || Object.keys(allTranslations); const defaultNamespace = baseConfig.defaultNS || "translation"; const namespaces = baseConfig.ns || [defaultNamespace]; return ` // CRITICAL: Detect language from current URL/HTML for SSG pages (function() { const detectSSGLanguage = function() { // Try path-based detection first const pathMatch = window.location.pathname.match(/^\\/([a-z]{2}(?:-[A-Z]{2})?)/); if (pathMatch) { const detectedLang = pathMatch[1]; const supportedLangs = ${JSON.stringify(supportedLanguages)}; if (supportedLangs.includes(detectedLang)) { return detectedLang; } } // Try HTML lang attribute const htmlLang = document.documentElement.lang; if (htmlLang) { const supportedLangs = ${JSON.stringify(supportedLanguages)}; if (supportedLangs.includes(htmlLang)) { return htmlLang; } } // Fallback to default return "${baseConfig.lng || supportedLanguages[0]}"; }; const currentLanguage = detectSSGLanguage(); const allTranslations = ${JSON.stringify(allTranslations)}; const expectedNamespaces = ${JSON.stringify(namespaces)}; // Extract resources for the detected language and expected namespaces const ssgResources = {}; if (allTranslations[currentLanguage]) { ssgResources[currentLanguage] = {}; expectedNamespaces.forEach(function(ns) { if (allTranslations[currentLanguage][ns]) { ssgResources[currentLanguage][ns] = allTranslations[currentLanguage][ns]; } }); } // Serialize for synchronous client access window.__initialI18nStore__ = ssgResources; window.__initialLanguage__ = currentLanguage; })(); `; } // node_modules/.pnpm/i18next-resources-for-ts@1.6.0/node_modules/i18next-resources-for-ts/dist/esm/mergeResources.js function mergeResources(namespaces) { return namespaces.reduce((prev, cur) => { prev[cur.name] = cur.resources; return prev; }, {}); } // node_modules/.pnpm/i18next-resources-for-ts@1.6.0/node_modules/i18next-resources-for-ts/dist/esm/mergeResourcesAsInterface.js function mergeResourcesAsInterface(namespaces) { const resources = mergeResources(namespaces); let interfaceFileContent = "interface Resources "; interfaceFileContent += JSON.stringify(resources, null, 2); interfaceFileContent += "\n\nexport default Resources;\n"; return interfaceFileContent; } function toNamespaceArray(translationMap, lng) { const localeForTypes = !lng || lng === "cimode" ? Object.keys(translationMap)[0] : lng; return Object.entries(translationMap[localeForTypes]).map( ([namespaceKey, resources]) => ({ name: namespaceKey, resources }) ); } function generateTypescriptDefinitions(namespaces, outputDirPath, internalOptions, i18nextOptions) { try { const INTERFACE_OUTPUT_FILE = path.join( pathe.resolve(outputDirPath, internalOptions.generatedTypes.dirPath), `${internalOptions.generatedTypes.fileName}.d.ts` ); const typeDefinitionFile = mergeResourcesAsInterface( toNamespaceArray(namespaces, i18nextOptions.lng) ); const final = ` import "i18next"; declare module "i18next" { interface CustomTypeOptions { defaultNS: "${i18nextOptions.defaultNS === false ? "false" : i18nextOptions.defaultNS || "translation"}"; resources: Resources; } } ${typeDefinitionFile} `; const namespacesLength = Object.keys(Object.values(namespaces)[0]).length; const RELATIVE_OUTPUT_PATH = pathe.relative(process.cwd(), INTERFACE_OUTPUT_FILE); fs__namespace.writeFileSync(INTERFACE_OUTPUT_FILE, final, { encoding: "utf-8", flag: "w" }); log( `Created interface file for ${namespacesLength !== void 0 ? namespacesLength : 0} namespaces: ${RELATIVE_OUTPUT_PATH}` ); } catch (error) { log(` Failed to create interface resources file: ${error}`); } } // src/validation.ts var I18nConfigError = class extends Error { constructor(message, field) { super(message); this.field = field; this.name = "I18nConfigError"; } }; function validateOptions(options) { if (!options) { throw new I18nConfigError("Integration options are required"); } } // src/vite-plugin.ts function createI18nVitePlugin(srcDir, internalOptions, i18nextOptions) { return { name: "i18n-virtual-modules", resolveId(id) { if (id === "virtual:i18n-loader") return id; const match = id.match(/^virtual:i18n-translation:(.+)\/(.+)$/); if (match) return id; const virtualMatch = id.match(/^\.?\/virtual-i18n-(.+?)__(.+)\.js$/); if (virtualMatch) { const locale = virtualMatch[1]; const namespace = virtualMatch[2]; return `virtual:i18n-translation:${locale}/${namespace}`; } return null; }, load(id) { if (id === "virtual:i18n-loader") { return generateDynamicTranslationLoader( srcDir, internalOptions, i18nextOptions ); } const match = id.match(/^virtual:i18n-translation:(.+)\/(.+)$/); if (match) { const [, locale, namespace] = match; const translation = loadTranslation( getFilePath( locale, namespace, srcDir, internalOptions.translationsDir ) ); return `export default ${JSON.stringify(translation)};`; } return null; } }; } function generateDynamicTranslationLoader(srcDir, internalOptions, i18nextOptions) { const importMap = []; const caseStatements = []; let locales = []; let namespaces = []; if (i18nextOptions.supportedLngs === false || i18nextOptions.supportedLngs === void 0) { locales = discoverAvailableLanguages( srcDir, internalOptions.translationsDir ); const allFilePaths = getAllFilePaths( srcDir, internalOptions, i18nextOptions ); const discoveredNamespaces = /* @__PURE__ */ new Set(); allFilePaths.forEach( ({ namespace }) => discoveredNamespaces.add(namespace) ); namespaces = Array.from(discoveredNamespaces); if (namespaces.length === 0) { if (i18nextOptions.ns === void 0) { namespaces = ["translation"]; } else if (typeof i18nextOptions.ns === "string") { namespaces = [i18nextOptions.ns]; } else if (Array.isArray(i18nextOptions.ns)) { namespaces = i18nextOptions.ns; } } } else if (Array.isArray(i18nextOptions.supportedLngs)) { locales = i18nextOptions.supportedLngs; if (i18nextOptions.ns === void 0) { const allFilePaths = getAllFilePaths( srcDir, internalOptions, i18nextOptions ); const discoveredNamespaces = /* @__PURE__ */ new Set(); allFilePaths.forEach( ({ namespace }) => discoveredNamespaces.add(namespace) ); namespaces = Array.from(discoveredNamespaces); if (namespaces.length === 0) { namespaces = ["translation"]; } } else if (typeof i18nextOptions.ns === "string") { namespaces = [i18nextOptions.ns]; } else if (Array.isArray(i18nextOptions.ns)) { namespaces = i18nextOptions.ns; } } locales.forEach((locale) => { namespaces.forEach((namespace) => { const importVar = `${locale}_${namespace}`.replace(/[^a-zA-Z0-9_]/g, "_"); importMap.push( `const ${importVar} = () => import('./virtual-i18n-${locale}__${namespace}.js');` ); caseStatements.push( ` case '${locale}/${namespace}': return (await ${importVar}()).default || {};` ); }); }); return ` ${importMap.join("\n")} export async function loadTranslation(locale, namespace) { try { const key = \`\${locale}/\${namespace}\`; switch (key) { ${caseStatements.join("\n")} default: console.warn(\`[${INTEGRATION_NAME}] Unknown translation: \${locale}/\${namespace}\`); return {}; } } catch (err) { console.warn(\`[${INTEGRATION_NAME}] Failed to load translation \${locale}/\${namespace}:\`, err); return {}; } } // Helper to preload specific namespaces export async function preloadNamespaces(locale, namespaces) { const promises = namespaces.map(ns => loadTranslation(locale, ns)); return Promise.all(promises); } // Available locales and namespaces for validation export const availableLocales = ${JSON.stringify(locales)}; export const availableNamespaces = ${JSON.stringify(namespaces)}; `; } // src/integration.ts function i18nIntegration(options) { return { name: INTEGRATION_NAME, hooks: { "astro:config:setup": async ({ config, injectScript, addWatchFile, updateConfig, addMiddleware }) => { try { validateOptions(options); const internalOptions = applyInternalDefaults(options); const allTranslations = loadAllTranslations( config.srcDir.pathname, internalOptions, options.i18NextOptions ); updateConfig({ vite: { plugins: [ createI18nVitePlugin( config.srcDir.pathname, internalOptions, options.i18NextOptions ) ] } }); injectScript( "page-ssr", generateServerScript( options.i18NextOptions, allTranslations, internalOptions, options.i18NextOptions ) ); injectScript( "head-inline", generateSSGSerializationScript( options.i18NextOptions, allTranslations, internalOptions ) ); injectScript( "before-hydration", generateClientScript( options.i18NextOptions, internalOptions, options.i18NextOptions ) ); generateTypescriptDefinitions( allTranslations, config.srcDir.pathname, internalOptions, options.i18NextOptions ); addMiddleware({ entrypoint: `${INTEGRATION_NAME}/middleware`, order: "post" }); const translationsData = getAllFilePaths( config.srcDir.pathname, internalOptions, options.i18NextOptions ); translationsData.forEach(({ path }) => addWatchFile(path)); } catch (error) { if (error instanceof Error) { throw new Error( `[${INTEGRATION_NAME}] Configuration error: ${error.message}` ); } throw error; } } } }; } exports.i18nIntegration = i18nIntegration; //# sourceMappingURL=index.cjs.map //# sourceMappingURL=index.cjs.map