nuxt-i18n-micro
Version:
Nuxt I18n Micro is a lightweight, high-performance internationalization module for Nuxt, designed to handle multi-language support with minimal overhead, fast build times, and efficient runtime performance.
1,194 lines (1,181 loc) • 55.1 kB
JavaScript
import path, { resolve, join } from 'node:path';
import * as fs from 'node:fs';
import fs__default, { existsSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs';
import { useNuxt, defineNuxtModule, useLogger, createResolver, addTemplate, addImportsDir, addPlugin, addServerHandler, addComponentsDir, addTypeTemplate, addPrerenderRoutes } from '@nuxt/kit';
import { isPrefixAndDefaultStrategy, isPrefixStrategy, isNoPrefixStrategy, isPrefixExceptDefaultStrategy, defaultPlural, withPrefixStrategy } from '@i18n-micro/core';
import { fileURLToPath } from 'node:url';
import { onDevToolsInitialized, extendServerRpc } from '@nuxt/devtools-kit';
import { isInternalPath } from '../dist/runtime/utils/path-utils.js';
import { globby } from 'globby';
const DEVTOOLS_UI_PORT = 3030;
const DEVTOOLS_UI_ROUTE = "/__nuxt-i18n-micro";
const distDir = resolve(fileURLToPath(import.meta.url), "..");
resolve(distDir, "client");
function setupDevToolsUI(options, resolve2) {
const nuxt = useNuxt();
const clientPath = resolve2("./client");
const devtoolsUiDistPath = resolve2("./packages/devtools-ui/dist");
const clientDirExists = fs.existsSync(clientPath) || fs.existsSync(devtoolsUiDistPath);
const ROUTE_PATH = `${nuxt.options.app.baseURL || "/"}/__nuxt-i18n-micro`.replace(/\/+/g, "/");
const ROUTE_CLIENT = `${ROUTE_PATH}/client`;
if (clientDirExists) {
nuxt.hook("vite:extendConfig", (config) => {
config.server = config.server || {};
config.server.proxy = config.server.proxy || {};
const proxyConfig = {
target: `http://localhost:${DEVTOOLS_UI_PORT}`,
changeOrigin: true,
ws: true,
// Enable WebSocket proxying for HMR
// Don't rewrite - keep the full path since client server expects /__nuxt-i18n-micro/client/*
rewrite: (path2) => path2
};
config.server.proxy[`${ROUTE_CLIENT}`] = proxyConfig;
config.server.proxy[`${ROUTE_CLIENT}/`] = proxyConfig;
config.server.proxy[`${ROUTE_CLIENT}/*`] = proxyConfig;
});
} else {
nuxt.hook("vite:extendConfig", (config) => {
config.server = config.server || {};
config.server.proxy = config.server.proxy || {};
config.server.proxy[DEVTOOLS_UI_ROUTE] = {
target: `http://localhost:${DEVTOOLS_UI_PORT}${DEVTOOLS_UI_ROUTE}`,
changeOrigin: true,
followRedirects: true,
rewrite: (path2) => path2.replace(DEVTOOLS_UI_ROUTE, "")
};
});
}
onDevToolsInitialized(async () => {
extendServerRpc("nuxt-i18n-micro", {
async saveTranslationContent(file, content) {
const rootDirs = nuxt.options.runtimeConfig.i18nConfig?.rootDirs || [nuxt.options.rootDir];
let filePath = null;
for (const rootDir of rootDirs) {
const localesDir = path.join(rootDir, options.translationDir || "locales");
const candidatePath = path.join(localesDir, file);
if (fs.existsSync(candidatePath)) {
filePath = candidatePath;
break;
}
}
if (!filePath) {
filePath = path.resolve(file);
}
if (fs.existsSync(filePath)) {
fs.writeFileSync(filePath, JSON.stringify(content, null, 2), "utf-8");
} else {
throw new Error(`File not found: ${filePath}`);
}
},
async getConfigs() {
return Promise.resolve(options);
},
async getLocalesAndTranslations() {
const rootDirs = nuxt.options.runtimeConfig.i18nConfig?.rootDirs || [nuxt.options.rootDir];
const filesList = {};
for (const rootDir of rootDirs) {
const localesDir = path.join(rootDir, options.translationDir || "locales");
const pagesDir = path.join(localesDir, "pages");
const processDirectory = (dir, baseDir = localesDir) => {
if (!fs.existsSync(dir)) return;
fs.readdirSync(dir).forEach((file) => {
const filePath = path.join(dir, file);
const stat = fs.lstatSync(filePath);
if (stat.isDirectory()) {
processDirectory(filePath, baseDir);
} else if (file.endsWith(".json")) {
try {
const relativePath = path.relative(baseDir, filePath);
const normalizedPath = relativePath.replace(/\\/g, "/");
filesList[normalizedPath] = JSON.parse(fs.readFileSync(filePath, "utf-8"));
} catch (e) {
console.error(`Error parsing locale file ${filePath}:`, e);
}
}
});
};
processDirectory(localesDir, localesDir);
processDirectory(pagesDir, localesDir);
}
return filesList;
}
});
nuxt.hook("devtools:customTabs", (tabs) => {
tabs.push({
name: "nuxt-i18n-micro",
title: "i18n Micro",
icon: "carbon:language",
view: {
type: "iframe",
src: ROUTE_CLIENT
}
});
});
});
}
function extractScriptContent(content) {
const scriptMatch = content.match(/<script[^>]*>([\s\S]*?)<\/script>/);
return scriptMatch && scriptMatch[1] ? scriptMatch[1] : null;
}
function removeTypeScriptTypes(scriptContent) {
return scriptContent.replace(/\((\w+):[^)]*\)/g, "($1)");
}
function findDefineI18nRouteConfig(scriptContent) {
try {
const defineStart = scriptContent.indexOf("$defineI18nRoute(");
if (defineStart === -1) {
return null;
}
const openParen = scriptContent.indexOf("(", defineStart);
if (openParen === -1) {
return null;
}
let braceCount = 0;
let parenCount = 1;
let i = openParen + 1;
for (; i < scriptContent.length; i++) {
if (scriptContent[i] === "{") braceCount++;
if (scriptContent[i] === "}") braceCount--;
if (scriptContent[i] === "(") parenCount++;
if (scriptContent[i] === ")") {
parenCount--;
if (parenCount === 0 && braceCount === 0) break;
}
}
if (i >= scriptContent.length) {
return null;
}
const configStr = scriptContent.substring(openParen + 1, i);
try {
const cleanConfigStr = removeTypeScriptTypes(configStr);
try {
const configObject = Function('"use strict";return (' + cleanConfigStr + ")")();
try {
const serialized = JSON.stringify(configObject);
return JSON.parse(serialized);
} catch {
return configObject;
}
} catch {
const scriptWithoutImports = scriptContent.split("\n").filter((line) => !line.trim().startsWith("import ")).join("\n");
const cleanScript = removeTypeScriptTypes(scriptWithoutImports);
const safeScript = `
// Mock $defineI18nRoute to prevent errors
const $defineI18nRoute = () => {}
const defineI18nRoute = () => {}
// Mock process.env for conditional logic
const process = { env: { NODE_ENV: 'development' } }
// Execute the script content without imports and TypeScript types
${cleanScript}
// Return the config object
return (${cleanConfigStr})
`;
const configObject = Function('"use strict";' + safeScript)();
try {
const serialized = JSON.stringify(configObject);
return JSON.parse(serialized);
} catch {
return configObject;
}
}
} catch {
return null;
}
} catch {
return null;
}
}
function extractDefineI18nRouteData(content, _filePath) {
try {
const scriptContent = extractScriptContent(content);
if (!scriptContent) {
return null;
}
const configObject = findDefineI18nRouteConfig(scriptContent);
if (!configObject) {
return null;
}
if (configObject.locales && typeof configObject.locales === "object" && !Array.isArray(configObject.locales)) {
const localesObj = configObject.locales;
const normalizedLocales = [];
const normalizedLocaleRoutes = {};
for (const [locale, value] of Object.entries(localesObj)) {
normalizedLocales.push(locale);
if (value && typeof value === "object" && "path" in value && typeof value.path === "string") {
normalizedLocaleRoutes[locale] = value.path;
}
}
return {
...configObject,
locales: normalizedLocales,
localeRoutes: configObject.localeRoutes || Object.keys(normalizedLocaleRoutes).length > 0 ? { ...configObject.localeRoutes, ...normalizedLocaleRoutes } : void 0
};
}
if (Array.isArray(configObject.locales) && configObject.locales.length > 0 && typeof configObject.locales[0] === "object") {
const normalizedLocales = configObject.locales.map((item) => {
if (item && typeof item === "object" && "code" in item) {
return item.code;
}
return String(item);
});
return {
...configObject,
locales: normalizedLocales
};
}
return configObject;
} catch {
return null;
}
}
function normalizeRouteKey(key) {
return key.split("/").map((segment) => {
if (segment.startsWith("[...") && segment.endsWith("]")) {
const paramName = segment.substring(4, segment.length - 1);
return `:${paramName}(.*)*`;
}
if (segment.startsWith("[") && segment.endsWith("]")) {
const paramName = segment.substring(1, segment.length - 1);
return `:${paramName}`;
}
return segment;
}).join("/");
}
const normalizePath = (routePath) => {
if (!routePath) {
return "";
}
const normalized = path.posix.normalize(routePath).replace(/\/+$/, "");
return normalized === "." ? "" : normalized;
};
const cloneArray = (array) => array.map((item) => ({ ...item }));
const isPageRedirectOnly = (page) => !!(page.redirect && !page.file);
const removeLeadingSlash = (routePath) => routePath.startsWith("/") ? routePath.slice(1) : routePath;
const buildRouteName = (baseName, localeCode, isCustom) => isCustom ? `localized-${baseName}-${localeCode}` : `localized-${baseName}`;
const shouldAddLocalePrefix = (locale, defaultLocale, addLocalePrefix, includeDefaultLocaleRoute) => addLocalePrefix && !(locale === defaultLocale.code && !includeDefaultLocaleRoute);
const isLocaleDefault = (locale, defaultLocale, includeDefaultLocaleRoute) => {
const localeCode = typeof locale === "string" ? locale : locale.code;
return localeCode === defaultLocale.code && !includeDefaultLocaleRoute;
};
const buildFullPath = (locale, basePath, customRegex) => {
const regexString = normalizeRegex(customRegex?.toString());
const localeParam = regexString ? regexString : Array.isArray(locale) ? locale.join("|") : locale;
return normalizePath(path.posix.join("/", `:locale(${localeParam})`, basePath));
};
const buildFullPathNoPrefix = (basePath) => {
return normalizePath(basePath);
};
const normalizeRegex = (toNorm) => {
if (typeof toNorm === "undefined") return void 0;
return toNorm.startsWith("/") && toNorm.endsWith("/") ? toNorm?.slice(1, -1) : toNorm;
};
const buildRouteNameFromRoute = (name, path2) => {
return name ?? (path2 ?? "").replace(/[^a-z0-9]/gi, "-").replace(/^-+|-+$/g, "");
};
class PageManager {
locales;
defaultLocale;
strategy;
localizedPaths = {};
activeLocaleCodes;
globalLocaleRoutes;
filesLocaleRoutes;
routeLocales;
noPrefixRedirect;
excludePatterns;
constructor(locales, defaultLocaleCode, strategy, globalLocaleRoutes, filesLocaleRoutes, routeLocales, noPrefixRedirect, excludePatterns) {
this.locales = locales;
this.defaultLocale = this.findLocaleByCode(defaultLocaleCode) || { code: defaultLocaleCode };
this.strategy = strategy;
this.noPrefixRedirect = noPrefixRedirect;
this.excludePatterns = excludePatterns;
this.activeLocaleCodes = this.computeActiveLocaleCodes();
const normalizedGlobalRoutes = {};
for (const key in globalLocaleRoutes) {
const newKey = normalizeRouteKey(key);
const localePaths = globalLocaleRoutes[key];
if (typeof localePaths === "object") {
const normalizedLocalePaths = {};
for (const locale in localePaths) {
const customPath = localePaths[locale];
if (customPath) {
normalizedLocalePaths[locale] = normalizeRouteKey(customPath);
}
}
normalizedGlobalRoutes[newKey] = normalizedLocalePaths;
} else {
normalizedGlobalRoutes[newKey] = localePaths;
}
}
this.globalLocaleRoutes = normalizedGlobalRoutes;
this.filesLocaleRoutes = filesLocaleRoutes || {};
this.routeLocales = routeLocales || {};
}
findLocaleByCode(code) {
return this.locales.find((locale) => locale.code === code);
}
computeActiveLocaleCodes() {
return this.locales.filter((locale) => locale.code !== this.defaultLocale.code || isPrefixAndDefaultStrategy(this.strategy) || isPrefixStrategy(this.strategy)).map((locale) => locale.code);
}
getAllowedLocalesForPage(pagePath, pageName) {
const allowedLocales = this.routeLocales[pagePath] || this.routeLocales[pageName];
if (allowedLocales && allowedLocales.length > 0) {
return allowedLocales.filter(
(locale) => this.locales.some((l) => l.code === locale)
);
}
return this.locales.map((locale) => locale.code);
}
hasLocaleRestrictions(pagePath, pageName) {
return !!(this.routeLocales[pagePath] || this.routeLocales[pageName]);
}
// private isAlreadyLocalized(p: string) {
// const codes = this.locales.map(l => l.code).join('|') // en|de|ru…
// return p.startsWith('/:locale(') // dynamic prefix
// || new RegExp(`^/(${codes})(/|$)`).test(p) // static /de/…
// }
extendPages(pages, customRegex, isCloudflarePages) {
this.localizedPaths = this.extractLocalizedPaths(pages);
const additionalRoutes = [];
const originalPagePaths = /* @__PURE__ */ new Map();
for (const page of [...pages]) {
if (isPageRedirectOnly(page)) {
continue;
}
if (page.path && isInternalPath(page.path, this.excludePatterns)) {
continue;
}
const originalPath = page.path ?? "";
originalPagePaths.set(page, originalPath);
const pageName = buildRouteNameFromRoute(page.name, page.path);
const normalizedOriginalPath = normalizeRouteKey(originalPath);
const customPaths = this.localizedPaths[originalPath] || this.localizedPaths[pageName];
const isLocalizationDisabled = this.globalLocaleRoutes[pageName] === false || this.globalLocaleRoutes[normalizedOriginalPath] === false;
if (isLocalizationDisabled) {
continue;
}
const allowedLocales = this.getAllowedLocalesForPage(originalPath, pageName);
const originalChildren = cloneArray(page.children ?? []);
if (isNoPrefixStrategy(this.strategy)) {
if (customPaths) {
this.locales.forEach((locale) => {
const customPath = customPaths[locale.code];
if (customPath && allowedLocales.includes(locale.code)) {
const newRoute = this.createLocalizedRoute(page, [locale.code], originalChildren, true, customPath, customRegex, false, locale.code, originalPath);
if (newRoute) {
additionalRoutes.push(newRoute);
if (this.noPrefixRedirect && locale.code === this.defaultLocale.code) {
page.redirect = normalizePath(customPath);
}
}
}
});
}
this.handleAliasRoutes(page, additionalRoutes, customRegex, allowedLocales);
continue;
}
const defaultLocaleCode = this.defaultLocale.code;
if (allowedLocales.includes(defaultLocaleCode)) {
const customPath = customPaths?.[defaultLocaleCode];
if (isPrefixExceptDefaultStrategy(this.strategy)) {
if (customPath) {
page.path = normalizePath(customPath);
}
page.children = this.createLocalizedChildren(originalChildren, originalPath, [defaultLocaleCode], false, false, false, customPath ? { [defaultLocaleCode]: customPath } : {});
}
}
const localesToGenerate = this.locales.filter((l) => {
if (!allowedLocales.includes(l.code)) return false;
if (isPrefixExceptDefaultStrategy(this.strategy) && l.code === defaultLocaleCode) return false;
return true;
});
if (localesToGenerate.length > 0) {
if (customPaths) {
localesToGenerate.forEach((locale) => {
if (customPaths[locale.code]) {
if (isPrefixAndDefaultStrategy(this.strategy) && locale.code === defaultLocaleCode) {
const nonPrefixedRoute = this.createLocalizedRoute(page, [locale.code], originalChildren, true, customPaths[locale.code], customRegex, false, locale.code, originalPath);
if (nonPrefixedRoute) additionalRoutes.push(nonPrefixedRoute);
const prefixedRoute = this.createLocalizedRoute(page, [locale.code], originalChildren, true, customPaths[locale.code], customRegex, true, locale.code, originalPath);
if (prefixedRoute) additionalRoutes.push(prefixedRoute);
} else {
const shouldAddPrefix = isPrefixAndDefaultStrategy(this.strategy) && locale.code === defaultLocaleCode;
const newRoute = this.createLocalizedRoute(page, [locale.code], originalChildren, true, customPaths[locale.code], customRegex, shouldAddPrefix, locale.code, originalPath);
if (newRoute) additionalRoutes.push(newRoute);
}
} else {
const newRoute = this.createLocalizedRoute(page, [locale.code], originalChildren, false, "", customRegex, false, locale.code, originalPath);
if (newRoute) additionalRoutes.push(newRoute);
}
});
} else {
const localeCodes = localesToGenerate.map((l) => l.code);
const newRoute = this.createLocalizedRoute(page, localeCodes, originalChildren, false, "", customRegex, false, true, originalPath);
if (newRoute) additionalRoutes.push(newRoute);
}
}
this.handleAliasRoutes(page, additionalRoutes, customRegex, allowedLocales);
}
if (isPrefixStrategy(this.strategy) && !isCloudflarePages) {
for (let i = pages.length - 1; i >= 0; i--) {
const page = pages[i];
if (!page) continue;
const pagePath = page.path ?? "";
const pageName = page.name ?? "";
if (isInternalPath(pagePath, this.excludePatterns)) continue;
if (this.globalLocaleRoutes[pageName] === false) continue;
if (!/^\/:locale/.test(pagePath) && pagePath !== "/") {
pages.splice(i, 1);
}
}
}
pages.push(...additionalRoutes);
}
extractLocalizedPaths(pages, parentPath = "") {
const localizedPaths = {};
pages.forEach((page) => {
const pageName = buildRouteNameFromRoute(page.name, page.path);
const normalizedFullPath = normalizePath(path.posix.join(parentPath, page.path));
const normalizedKey = normalizeRouteKey(normalizedFullPath);
const globalLocalePath = this.globalLocaleRoutes[normalizedKey] || this.globalLocaleRoutes[pageName];
if (!globalLocalePath) {
const filesLocalePath = this.filesLocaleRoutes[pageName];
if (filesLocalePath && typeof filesLocalePath === "object") {
localizedPaths[normalizedFullPath] = filesLocalePath;
}
} else if (typeof globalLocalePath === "object") {
localizedPaths[normalizedFullPath] = globalLocalePath;
}
if (page.children?.length) {
const parentFullPath = normalizePath(path.posix.join(parentPath, page.path));
Object.assign(localizedPaths, this.extractLocalizedPaths(page.children, parentFullPath));
}
});
return localizedPaths;
}
addCustomGlobalLocalizedRoutes(page, customRoutePaths, additionalRoutes, customRegex) {
const normalizedFullPath = normalizePath(page.path);
const pageName = buildRouteNameFromRoute(page.name, page.path);
const allowedLocales = this.getAllowedLocalesForPage(normalizedFullPath, pageName);
const hasRestrictions = this.hasLocaleRestrictions(normalizedFullPath, pageName);
const localesToUse = hasRestrictions ? this.locales.filter((locale) => allowedLocales.includes(locale.code)) : this.locales;
localesToUse.forEach((locale) => {
const customPath = customRoutePaths[locale.code];
const isDefaultLocale = isLocaleDefault(locale, this.defaultLocale, isPrefixStrategy(this.strategy) || isPrefixAndDefaultStrategy(this.strategy));
if (customPath) {
if (isNoPrefixStrategy(this.strategy)) {
const newRoute = this.createLocalizedRoute(page, [locale.code], page.children ?? [], true, customPath, customRegex, false, locale.code);
if (newRoute) {
additionalRoutes.push(newRoute);
if (this.noPrefixRedirect) page.redirect = newRoute.path;
}
} else {
if (isDefaultLocale) {
page.path = normalizePath(customPath);
} else {
const newRoute = this.createLocalizedRoute(page, [locale.code], page.children ?? [], true, customPath, customRegex, false, locale.code);
if (newRoute) additionalRoutes.push(newRoute);
}
}
} else {
const localeCodes = [locale.code];
const originalChildren = cloneArray(page.children ?? []);
const newRoute = this.createLocalizedRoute(page, localeCodes, originalChildren, false, "", customRegex, false, locale.code);
if (newRoute) {
additionalRoutes.push(newRoute);
}
}
});
}
localizePage(page, additionalRoutes, customRegex) {
if (isPageRedirectOnly(page)) return;
const originalChildren = cloneArray(page.children ?? []);
const normalizedFullPath = normalizePath(page.path);
const pageName = buildRouteNameFromRoute(page.name, page.path);
const allowedLocales = this.getAllowedLocalesForPage(normalizedFullPath, pageName);
const hasRestrictions = this.hasLocaleRestrictions(normalizedFullPath, pageName);
const localeCodesWithoutCustomPaths = this.filterLocaleCodesWithoutCustomPaths(normalizedFullPath).filter((locale) => hasRestrictions ? allowedLocales.includes(locale) : true);
if (localeCodesWithoutCustomPaths.length) {
const newRoute = this.createLocalizedRoute(page, localeCodesWithoutCustomPaths, originalChildren, false, "", customRegex, false, true);
if (newRoute) additionalRoutes.push(newRoute);
}
this.addCustomLocalizedRoutes(page, normalizedFullPath, originalChildren, additionalRoutes, hasRestrictions ? allowedLocales : void 0);
this.adjustRouteForDefaultLocale(page, originalChildren);
this.handleAliasRoutes(page, additionalRoutes, customRegex, hasRestrictions ? allowedLocales : void 0);
}
filterLocaleCodesWithoutCustomPaths(fullPath) {
return this.activeLocaleCodes.filter((code) => !this.localizedPaths[fullPath]?.[code]);
}
handleAliasRoutes(page, additionalRoutes, customRegex, allowedLocales) {
const aliasRoutes = page.alias || page.meta?.alias;
if (!aliasRoutes || !Array.isArray(aliasRoutes)) {
return;
}
const localesToUse = allowedLocales || this.activeLocaleCodes;
aliasRoutes.forEach((aliasPath) => {
const localizedAliasPath = buildFullPath(localesToUse, aliasPath, customRegex);
const aliasRoute = {
...page,
path: localizedAliasPath,
name: `localized-${page.name ?? ""}`,
meta: {
...page.meta,
alias: void 0
// Remove alias to prevent infinite recursion
},
alias: void 0
// Remove alias from root to prevent infinite recursion
};
additionalRoutes.push(aliasRoute);
});
}
adjustRouteForDefaultLocale(page, originalChildren) {
if (isNoPrefixStrategy(this.strategy)) {
return;
}
if (isPrefixAndDefaultStrategy(this.strategy)) {
return;
}
const defaultLocalePath = this.localizedPaths[page.path]?.[this.defaultLocale.code];
if (defaultLocalePath) {
page.path = normalizePath(defaultLocalePath);
}
if (originalChildren.length) {
const newName = normalizePath(path.posix.join("/", buildRouteNameFromRoute(page.name, page.path)));
const currentChildren = page.children ? [...page.children] : [];
const localizedChildren = this.createLocalizedChildren(
originalChildren,
newName,
[this.defaultLocale.code],
true,
false,
false
);
const childrenMap = new Map(currentChildren.map((child) => [child.name, child]));
localizedChildren.forEach((localizedChild) => {
if (childrenMap.has(localizedChild.name)) {
const existingChild = childrenMap.get(localizedChild.name);
if (existingChild) {
Object.assign(existingChild, localizedChild);
}
} else {
currentChildren.push(localizedChild);
}
});
page.children = currentChildren;
}
}
addCustomLocalizedRoutes(page, fullPath, originalChildren, additionalRoutes, allowedLocales, customRegex) {
const localesToUse = allowedLocales ? this.locales.filter((locale) => allowedLocales.includes(locale.code)) : this.locales;
localesToUse.forEach((locale) => {
const customPath = this.localizedPaths[fullPath]?.[locale.code];
if (!customPath) return;
const isDefaultLocale = isLocaleDefault(locale, this.defaultLocale, isPrefixStrategy(this.strategy) || isNoPrefixStrategy(this.strategy));
if (isDefaultLocale && isPrefixExceptDefaultStrategy(this.strategy)) {
page.path = normalizePath(customPath);
page.children = this.createLocalizedChildren(originalChildren, "", [locale.code], false, false, false, { [locale.code]: customPath });
} else if (isPrefixAndDefaultStrategy(this.strategy) && locale === this.defaultLocale) {
const nonPrefixedRoute = this.createLocalizedRoute(page, [locale.code], originalChildren, true, customPath, customRegex, false, locale.code);
if (nonPrefixedRoute) {
additionalRoutes.push(nonPrefixedRoute);
}
const prefixedRoute = this.createLocalizedRoute(page, [locale.code], originalChildren, true, customPath, customRegex, true, locale.code);
if (prefixedRoute) {
additionalRoutes.push(prefixedRoute);
}
} else {
const shouldAddPrefix = !isDefaultLocale;
const newRoute = this.createLocalizedRoute(page, [locale.code], originalChildren, true, customPath, customRegex, shouldAddPrefix, locale.code);
if (newRoute) {
additionalRoutes.push(newRoute);
}
}
});
}
createLocalizedChildren(routes, parentPath, localeCodes, modifyName = true, addLocalePrefix = false, parentLocale = false, localizedParentPaths = {}) {
return routes.flatMap(
(route) => this.createLocalizedVariants(
route,
parentPath,
localeCodes,
modifyName,
addLocalePrefix,
parentLocale,
localizedParentPaths
)
);
}
createLocalizedVariants(route, parentPath, localeCodes, modifyName, addLocalePrefix, parentLocale = false, localizedParentPaths) {
const routePath = normalizePath(route.path);
const fullPath = normalizePath(path.posix.join(parentPath, routePath));
let customLocalePaths = this.localizedPaths[fullPath] ?? this.localizedPaths[normalizePath(route.path)];
if (!customLocalePaths && Object.keys(localizedParentPaths).length > 0) {
const hasLocalizedContext = Object.values(localizedParentPaths).some((path2) => path2 && path2 !== "");
if (hasLocalizedContext) {
const originalRoutePath = normalizePath(path.posix.join("/activity-locale", route.path));
customLocalePaths = this.localizedPaths[originalRoutePath];
}
}
const isCustomLocalized = !!customLocalePaths;
const result = [];
if (!isCustomLocalized) {
const finalPathForRoute = removeLeadingSlash(routePath);
const localizedChildren = this.createLocalizedChildren(
cloneArray(route.children ?? []),
path.posix.join(parentPath, routePath),
localeCodes,
modifyName,
addLocalePrefix,
parentLocale,
localizedParentPaths
);
const newName = this.buildChildRouteName(route.name, parentLocale);
result.push({
...route,
name: newName,
path: finalPathForRoute,
children: localizedChildren
});
return result;
}
for (const locale of localeCodes) {
const parentLocalizedPath = localizedParentPaths?.[locale];
const hasParentLocalized = !!parentLocalizedPath;
const customPath = customLocalePaths?.[locale];
let basePath = customPath ? normalizePath(customPath) : normalizePath(route.path);
if (hasParentLocalized && parentLocalizedPath) {
if (customPath) {
basePath = normalizePath(customPath);
} else {
basePath = normalizePath(path.posix.join(parentLocalizedPath, route.path));
}
}
const finalRoutePath = shouldAddLocalePrefix(
locale,
this.defaultLocale,
addLocalePrefix,
isPrefixStrategy(this.strategy)
) ? buildFullPath(locale, basePath) : basePath;
const finalPathForRoute = removeLeadingSlash(finalRoutePath);
const nextParentPath = customPath ? normalizePath(customPath) : hasParentLocalized ? parentLocalizedPath : normalizePath(path.posix.join(parentPath, routePath));
const localizedChildren = this.createLocalizedChildren(
cloneArray(route.children ?? []),
nextParentPath,
[locale],
modifyName,
addLocalePrefix,
locale,
{
...localizedParentPaths,
[locale]: nextParentPath
}
);
const routeName = this.buildLocalizedRouteName(
buildRouteNameFromRoute(route.name, route.path),
locale,
modifyName,
!!customLocalePaths
);
result.push({
...route,
name: routeName,
path: finalPathForRoute,
children: localizedChildren
});
}
return result;
}
buildChildRouteName(baseName, parentLocale) {
if (parentLocale === true) {
return `localized-${baseName}`;
}
if (typeof parentLocale === "string") {
return `localized-${baseName}-${parentLocale}`;
}
return baseName;
}
createLocalizedRoute(page, localeCodes, originalChildren, isCustom, customPath = "", customRegex, force = false, parentLocale = false, originalPagePath) {
const routePath = this.buildRoutePath(localeCodes, page.path, encodeURI(customPath), isCustom, customRegex, force);
const isPrefixAndDefaultWithCustomPath = isPrefixAndDefaultStrategy(this.strategy) && isCustom && customPath;
if (!routePath || !isPrefixAndDefaultWithCustomPath && routePath === page.path) return null;
if (localeCodes.length === 0) return null;
const firstLocale = localeCodes[0];
if (!firstLocale) return null;
const parentPathForChildren = originalPagePath ?? page.path ?? "";
const routeName = buildRouteName(buildRouteNameFromRoute(page.name ?? "", parentPathForChildren), firstLocale, isCustom);
return {
...page,
children: this.createLocalizedChildren(originalChildren, parentPathForChildren, localeCodes, true, false, parentLocale, { [firstLocale]: customPath }),
path: routePath,
name: routeName,
alias: [],
// remove alias to prevent infinite recursion
meta: {
...page.meta,
alias: []
// remove alias to prevent infinite recursion
}
};
}
buildLocalizedRouteName(baseName, locale, modifyName, forceLocaleSuffixOrCustom = false) {
if (!modifyName) return baseName;
if (forceLocaleSuffixOrCustom) {
return `localized-${baseName}-${locale}`;
}
const shouldAddLocaleSuffix = locale && !isLocaleDefault(locale, this.defaultLocale, isPrefixAndDefaultStrategy(this.strategy));
return shouldAddLocaleSuffix ? `localized-${baseName}-${locale}` : `localized-${baseName}`;
}
buildRoutePath(localeCodes, originalPath, customPath, isCustom, customRegex, force = false) {
if (isNoPrefixStrategy(this.strategy)) {
return buildFullPathNoPrefix(isCustom ? customPath : originalPath);
}
if (isCustom) {
const shouldAddPrefix = force || isPrefixStrategy(this.strategy) || isPrefixAndDefaultStrategy(this.strategy) && !localeCodes.includes(this.defaultLocale.code) || !localeCodes.includes(this.defaultLocale.code);
return shouldAddPrefix ? buildFullPath(localeCodes, customPath, customRegex) : normalizePath(customPath);
}
return buildFullPath(localeCodes, originalPath, customRegex);
}
}
class LocaleManager {
locales;
options;
rootDirs;
defaultLocale;
constructor(options, rootDirs) {
this.options = options;
this.rootDirs = rootDirs;
this.locales = this.mergeLocales(options.locales ?? []);
this.defaultLocale = options.defaultLocale ?? "en";
}
mergeLocales(locales) {
return locales.reduce((acc, locale) => {
const existingLocale = acc.find((l) => l.code === locale.code);
if (existingLocale) {
Object.assign(existingLocale, locale);
} else {
acc.push(locale);
}
return acc;
}, []).filter((locale) => !locale.disabled);
}
ensureTranslationFilesExist(pagesNames, translationDir, rootDir) {
this.locales.forEach((locale) => {
const globalFilePath = path.join(rootDir, translationDir, `${locale.code}.json`);
this.ensureFileExists(globalFilePath);
if (!this.options.disablePageLocales) {
pagesNames.forEach((name) => {
const pageFilePath = path.join(rootDir, translationDir, "pages", `${name}/${locale.code}.json`);
this.ensureFileExists(pageFilePath);
});
}
});
}
ensureFileExists(filePath) {
const fileDir = path.dirname(filePath);
if (!existsSync(fileDir)) {
mkdirSync(fileDir, { recursive: true });
}
if (!existsSync(filePath)) {
writeFileSync(filePath, JSON.stringify({}), "utf-8");
}
}
}
function generateI18nTypes() {
return `
import type {PluginsInjections} from "nuxt-i18n-micro";
declare module 'vue/types/vue' {
interface Vue extends PluginsInjections { }
}
declare module '@nuxt/types' {
interface NuxtAppOptions extends PluginsInjections { }
interface Context extends PluginsInjections { }
}
declare module '#app' {
interface NuxtApp extends PluginsInjections { }
}
export {}`;
}
const module = defineNuxtModule({
meta: {
name: "nuxt-i18n-micro",
configKey: "i18n"
},
// Default configuration options of the Nuxt module
defaults: {
locales: [],
meta: true,
debug: false,
define: true,
redirects: true,
plugin: true,
hooks: true,
types: true,
defaultLocale: "en",
strategy: "prefix_except_default",
translationDir: "locales",
autoDetectPath: "/",
autoDetectLanguage: true,
disablePageLocales: false,
disableWatcher: false,
// experimental kept in runtimeConfig only to avoid type drift here
noPrefixRedirect: false,
includeDefaultLocaleRoute: void 0,
fallbackLocale: void 0,
localeCookie: "user-locale",
apiBaseUrl: "_locales",
apiBaseClientHost: void 0,
apiBaseServerHost: void 0,
routesLocaleLinks: {},
globalLocaleRoutes: {},
canonicalQueryWhitelist: ["page", "sort", "filter", "search", "q", "query", "tag"],
plural: defaultPlural,
customRegexMatcher: void 0,
excludePatterns: void 0,
missingWarn: true
},
async setup(options, nuxt) {
const defaultLocale = process.env.DEFAULT_LOCALE ?? options.defaultLocale ?? "en";
const isSSG = nuxt.options.nitro.static ?? nuxt.options._generate ?? false;
const isCloudflarePages = nuxt.options.nitro.preset?.startsWith("cloudflare");
const logger = useLogger("nuxt-i18n-micro");
if (options.includeDefaultLocaleRoute !== void 0) {
logger.debug("The 'includeDefaultLocaleRoute' option is deprecated. Use 'strategy' instead.");
if (options.includeDefaultLocaleRoute) {
options.strategy = "prefix";
} else {
options.strategy = "prefix_except_default";
}
}
const resolver = createResolver(import.meta.url);
const rootDirs = nuxt.options._layers.map((layer) => layer.config.rootDir).reverse();
const localeManager = new LocaleManager(options, rootDirs);
const routeLocales = {};
const globalLocaleRoutes = {};
const routeDisableMeta = {};
const pageFiles = await globby(["pages/**/*.vue", "app/pages/**/*.vue"], { cwd: nuxt.options.rootDir });
for (const pageFile of pageFiles) {
const fullPath = join(nuxt.options.rootDir, pageFile);
try {
const fileContent = readFileSync(fullPath, "utf-8");
const config = extractDefineI18nRouteData(fileContent, fullPath);
if (!config) continue;
const { locales: extractedLocales, localeRoutes, disableMeta } = config;
const routePath = pageFile.replace(/^(app\/)?pages\//, "/").replace(/\/index\.vue$/, "").replace(/\.vue$/, "").replace(/\/$/, "") || "/";
if (extractedLocales) {
if (Array.isArray(extractedLocales)) {
routeLocales[routePath] = extractedLocales;
} else if (typeof extractedLocales === "object") {
routeLocales[routePath] = Object.keys(extractedLocales);
}
}
if (localeRoutes) {
globalLocaleRoutes[routePath] = localeRoutes;
}
if (disableMeta !== void 0) {
routeDisableMeta[routePath] = disableMeta;
}
} catch {
}
}
const mergedGlobalLocaleRoutes = { ...options.globalLocaleRoutes, ...globalLocaleRoutes };
const pageManager = new PageManager(localeManager.locales, defaultLocale, options.strategy, mergedGlobalLocaleRoutes, globalLocaleRoutes, routeLocales, options.noPrefixRedirect, options.excludePatterns);
addTemplate({
filename: "i18n.plural.mjs",
write: true,
getContents: () => `export const plural = ${options.plural.toString()};`
});
let apiBaseClientHost = process.env.NUXT_I18N_APP_BASE_CLIENT_HOST ?? options.apiBaseClientHost ?? void 0;
if (apiBaseClientHost && apiBaseClientHost.endsWith("/")) {
apiBaseClientHost = apiBaseClientHost.slice(0, -1);
}
let apiBaseServerHost = process.env.NUXT_I18N_APP_BASE_SERVER_HOST ?? options.apiBaseServerHost ?? void 0;
if (apiBaseServerHost && apiBaseServerHost.endsWith("/")) {
apiBaseServerHost = apiBaseServerHost.slice(0, -1);
}
const rawUrl = process.env.NUXT_I18N_APP_BASE_URL ?? options.apiBaseUrl ?? "_locales";
if (rawUrl.startsWith("http://") || rawUrl.startsWith("https://")) {
throw new Error("Nuxt-i18n-micro: Please use NUXT_I18N_APP_BASE_CLIENT_HOST or NUXT_I18N_APP_BASE_SERVER_HOST instead.");
}
const apiBaseUrl = rawUrl.replace(/^\/+|\/+$|\/{2,}/, "");
nuxt.options.runtimeConfig.public.i18nConfig = {
locales: localeManager.locales ?? [],
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
metaBaseUrl: options.metaBaseUrl ?? void 0,
defaultLocale,
localeCookie: options.localeCookie ?? "user-locale",
autoDetectPath: options.autoDetectPath ?? "/",
strategy: options.strategy ?? "prefix_except_default",
dateBuild: Date.now(),
hashMode: nuxt.options?.router?.options?.hashMode ?? false,
apiBaseUrl,
apiBaseClientHost,
apiBaseServerHost,
isSSG,
disablePageLocales: options.disablePageLocales ?? false,
canonicalQueryWhitelist: options.canonicalQueryWhitelist ?? [],
excludePatterns: options.excludePatterns ?? [],
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
routeLocales,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
routeDisableMeta,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
globalLocaleRoutes: mergedGlobalLocaleRoutes,
missingWarn: options.missingWarn ?? true,
experimental: {
i18nPreviousPageFallback: options.experimental?.i18nPreviousPageFallback ?? false,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
hmr: options.experimental?.hmr ?? true
}
};
if (typeof options.customRegexMatcher !== "undefined") {
const localeCodes = localeManager.locales.map((l) => l.code);
if (!localeCodes.every((code) => code.match(options.customRegexMatcher))) {
throw new Error("Nuxt-18n-micro: Some locale codes does not match customRegexMatcher");
}
}
nuxt.options.runtimeConfig.i18nConfig = {
rootDir: nuxt.options.rootDir,
rootDirs,
debug: options.debug ?? false,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
fallbackLocale: options.fallbackLocale ?? void 0,
translationDir: options.translationDir ?? "locales",
customRegexMatcher: options.customRegexMatcher,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
routesLocaleLinks: options.routesLocaleLinks ?? {},
apiBaseUrl,
apiBaseClientHost,
apiBaseServerHost
};
addImportsDir(resolver.resolve("./runtime/composables"));
if (process.env && process.env.TEST) {
return;
}
if (options.plugin) {
addPlugin({
src: resolver.resolve("./runtime/plugins/01.plugin"),
name: "i18n-plugin-loader",
order: -5
});
}
if (options.hooks) {
addPlugin({
src: resolver.resolve("./runtime/plugins/05.hooks"),
name: "i18n-plugin-hooks",
order: 1
});
}
if (options.meta) {
addPlugin({
src: resolver.resolve("./runtime/plugins/02.meta"),
name: "i18n-plugin-meta",
order: 2
});
}
if (options.define) {
addPlugin({
src: resolver.resolve("./runtime/plugins/03.define"),
name: "i18n-plugin-define",
mode: "all",
order: 3
});
}
if (options.autoDetectLanguage) {
addPlugin({
src: resolver.resolve("./runtime/plugins/04.auto-detect"),
mode: "server",
name: "i18n-plugin-auto-detect",
order: 4
});
}
if (options.redirects) {
addPlugin({
src: resolver.resolve("./runtime/plugins/06.redirect"),
name: "i18n-plugin-redirect",
mode: "all",
order: 6
});
}
addServerHandler({
route: `/${apiBaseUrl}/:page/:locale/data.json`,
handler: resolver.resolve("./runtime/server/routes/get")
});
addComponentsDir({
path: resolver.resolve("./runtime/components"),
pathPrefix: false,
extensions: ["vue"]
});
if (nuxt.options.dev && (options.experimental?.hmr ?? true)) {
const translationsDir = join(nuxt.options.rootDir, options.translationDir || "locales");
const files = await globby(["**/*.json"], { cwd: translationsDir, absolute: true });
const tpl = addTemplate({
filename: "i18n-hmr-plugin.mjs",
write: true,
getContents: () => generateHmrPlugin(files.map((f) => f.replace(/\\/g, "/")))
});
addPlugin({
src: tpl.dst,
mode: "client",
name: "i18n-hmr-plugin",
order: 10
});
}
if (options.types) {
addTypeTemplate({
filename: "types/i18n-plugin.d.ts",
getContents: () => generateI18nTypes()
});
}
function generateHmrPlugin(files) {
const accepts = files.map((file) => {
const isPage = /\/pages\//.test(file);
let pageName = "";
let locale = "";
if (isPage) {
const m = /\/pages\/([^/]+)\/([^/]+)\.json$/.exec(file);
pageName = m?.[1] || "";
locale = m?.[2] || "";
} else {
const m = /\/([^/]+)\.json$/.exec(file);
locale = m?.[1] || "";
}
return `
if (import.meta.hot) {
import.meta.hot.accept('${file}', async (mod) => {
const nuxtApp = useNuxtApp()
const data = (mod && typeof mod === 'object' && Object.prototype.hasOwnProperty.call(mod, 'default'))
? mod.default
: mod
try {
${isPage ? `await nuxtApp.$loadPageTranslations('${locale}', '${pageName}', data)` : `await nuxtApp.$loadTranslations('${locale}', data)`}
console.log('[i18n HMR] Translations reloaded:', '${isPage ? "page" : "global"}', '${locale}'${isPage ? `, '${pageName}'` : ""})
}
catch (e) {
console.warn('[i18n HMR] Failed to reload translations for', '${file}', e)
}
})
}
`.trim();
}).join("\n");
return `
import { defineNuxtPlugin, useNuxtApp } from '#imports'
export default defineNuxtPlugin(() => {
${accepts}
})
`.trim();
}
nuxt.hook("pages:resolved", (pages) => {
const prerenderRoutes = [];
const routeRules = nuxt.options.routeRules || {};
const pagesNames = pages.map((page) => page.name).filter((name) => name !== void 0 && (!options.routesLocaleLinks || !options.routesLocaleLinks[name]));
localeManager.locales.forEach((locale) => {
if (!options.disablePageLocales) {
pagesNames.forEach((name) => {
prerenderRoutes.push(`/${apiBaseUrl}/${name}/${locale.code}/data.json`);
});
}
prerenderRoutes.push(`/${apiBaseUrl}/general/${locale.code}/data.json`);
});
if (!options.disableWatcher) {
localeManager.ensureTranslationFilesExist(pagesNames, options.translationDir, nuxt.options.rootDir);
}
pageManager.extendPages(pages, options.customRegexMatcher, isCloudflarePages);
if (!isCloudflarePages) {
const strategy = options.strategy;
if (isPrefixStrategy(strategy)) {
const rootPageIndex = pages.findIndex((page) => page.name === "index" && page.path === "/");
if (rootPageIndex > -1) {
pages.splice(rootPageIndex, 1);
}
const fallbackRoute = {
path: "/:pathMatch(.*)*",
name: "custom-fallback-route",
file: resolver.resolve("./runtime/components/locale-redirect.vue")
};
pages.push(fallbackRoute);
logger.info("Strategy 'prefix': Added fallback route to redirect all non-prefixed paths.");
}
const needsFallback = isPrefixStrategy(options.strategy) || isPrefixExceptDefaultStrategy(options.strategy);
if (needsFallback) {
const fallbackRoute = {
path: "/:pathMatch(.*)*",
name: "custom-fallback-route",
file: resolver.resolve("./runtime/components/locale-redirect.vue"),
meta: {
globalLocaleRoutes: options.globalLocaleRoutes
}
};
pages.push(fallbackRoute);
}
}
if (!isNoPrefixStrategy(options.strategy)) {
if (isCloudflarePages) {
const processPageWithChildren = (page, parentPath = "") => {
if (!page.path) return;
const fullPath = path.posix.normalize(`${parentPath}/${page.path}`);
if (isInternalPath(fullPath, options.excludePatterns)) {
return;
}
const routeRule = routeRules[fullPath];
if (routeRule && routeRule.prerender === false) {
return;
}
const localeSegmentMatch = fullPath.match(/:locale\(([^)]+)\)/);
if (localeSegmentMatch && localeSegmentMatch[1]) {
const availableLocales = localeSegmentMatch[1].split("|");
localeManager.locales.forEach((locale) => {
const localeCode = locale.code;
if (availableLocales.includes(localeCode)) {
let localizedPath = fullPath;
localizedPath = localizedPath.replace(/:locale\([^)]+\)/, localeCode);
const localizedRouteRule = routeRules[localizedPath];
if (localizedRouteRule && localizedRouteRule.prerender === false) {
return;
}
if (!isInternalPath(localizedPath, options.excludePatterns)) {
prerenderRoutes.push(localizedPath);
}
}
});
} else {
if (!isInternalPath(fullPath, options.excludePatterns)) {
prerenderRoutes.push(fullPath);
}
}
if (page.children && page.children.length) {
page.children.forEach((childPage) => {
processPageWithChildren(childPage, fullPath);
});
}
};
pages.forEach((page) => {
processPageWithChildren(page);
});
}
}
addPrerenderRoutes(prerenderRoutes);
});
nuxt.hook("nitro:config", (nitroConfig) => {
if (nitroConfig.imports) {
nitroConfig.imports.presets = nitroConfig.imports.presets || [];
nitroConfig.imports.presets.push({
from: resolver.resolve("./runtime/translation-server-middleware"),
imports: ["useTranslationServerMiddleware"]
});
nitroConfig.imports.presets.push({
from: resolver.resolve("./runtime/locale-server-middleware"),
imports: ["useLocaleServerMiddleware"]
});
}
const routeRules = nuxt.options.routeRules || {};
const strategy = options.strategy;
if (routeRules && Object.keys(routeRules).length && !isNoPrefixStrategy(strategy)) {
nitroConfig.routeRules = nitroConfig.routeRules ||