UNPKG

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