@vyxos/astro-i18next
Version:
I18next integration for Astro with dynamic namespace loading.
810 lines (770 loc) • 26.5 kB
JavaScript
import * as fs from 'fs';
import { existsSync, readdirSync, statSync, readFileSync } from 'fs';
import { resolve, relative } from 'pathe';
import { createRequire } from 'module';
import { join } from 'path';
// 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 : createRequire(import.meta.url);
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 (!existsSync(filePath)) {
logWarn(`Translation file not found: ${filePath}`);
return {};
}
try {
const content = 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 = readdirSync(currentPath);
for (const entry of entries) {
const entryPath = resolve(currentPath, entry);
const stats = 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 = resolve(srcDir, internalOptions.translationsDir);
if (!existsSync(translationDirPath)) {
return filePaths;
}
try {
const localeEntries = readdirSync(translationDirPath);
for (const localeEntry of localeEntries) {
const localeDir = resolve(translationDirPath, localeEntry);
if (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 = resolve(
srcDir,
internalOptions.translationsDir
);
const discoveredNamespaces = /* @__PURE__ */ new Set();
for (const locale of i18nextOptions.supportedLngs) {
const localeDir = resolve(translationDirPath, locale);
if (existsSync(localeDir) && 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 resolve(
srcDir,
translationDirectoryPath,
`${locale}/${namespacePath}.json`
);
}
function hasJsonFilesRecursively(dirPath) {
try {
const entries = readdirSync(dirPath);
for (const entry of entries) {
const entryPath = resolve(dirPath, entry);
const stats = 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 = resolve(srcDir, translationsDir);
if (!existsSync(translationDirPath)) {
logWarn(`Translation directory not found: ${translationDirPath}`);
return [];
}
try {
const localeEntries = readdirSync(translationDirPath);
const availableLocales = [];
for (const localeEntry of localeEntries) {
const localeDir = resolve(translationDirPath, localeEntry);
if (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 = join(
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 = relative(process.cwd(), INTERFACE_OUTPUT_FILE);
fs.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;
}
}
}
};
}
export { i18nIntegration };
//# sourceMappingURL=index.js.map
//# sourceMappingURL=index.js.map