UNPKG

@nuxtjs/i18n

Version:

Internationalization for Nuxt

1,362 lines (1,344 loc) 74.4 kB
import { useLogger, resolvePath, tryUseNuxt, addTemplate, updateTemplates, useNuxt, addBuildPlugin, addServerTemplate, addServerImports, resolveModule, addServerPlugin, addServerHandler, createResolver, findPath, defineNuxtModule, addComponent, addImports, addImportsSources, addPlugin, addTypeTemplate, addVitePlugin, useNitro } from '@nuxt/kit'; import defu$1, { defu } from 'defu'; import { readFileSync, existsSync } from 'node:fs'; import { isString, assign, isArray } from '@intlify/shared'; import { parse as parse$1 } from '@vue/compiler-sfc'; import { parseAndWalk, ScopeTracker, walk } from 'oxc-walker'; import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { parseSegment, toVueRouterSegment } from 'unrouting'; import { createHash } from 'node:crypto'; import { resolve, dirname, parse, relative, basename, join as join$1, isAbsolute } from 'pathe'; import { parseSync } from 'oxc-parser'; import { createRoutesContext, resolveOptions } from 'vue-router/unplugin'; import MagicString from 'magic-string'; import { createUnplugin } from 'unplugin'; import { pathToFileURL, fileURLToPath } from 'node:url'; import { parseURL, parseQuery } from 'ufo'; import { findStaticImports, resolveModuleExportNames } from 'mlly'; import { transformSync } from 'oxc-transform'; import yamlPlugin from '@rollup/plugin-yaml'; import json5Plugin from '@miyaneee/rollup-plugin-json5'; import VueI18nPlugin from '@intlify/unplugin-vue-i18n'; import { addDefinePlugin } from 'nuxt-define'; import { genSafeVariableName, genString, genImport, genDynamicImport, genArrayFromRaw, genObjectFromRaw, genObjectFromValues } from 'knitwork'; const STRATEGY_PREFIX_EXCEPT_DEFAULT = "prefix_except_default"; const DYNAMIC_PARAMS_KEY = "nuxtI18nInternal"; const DEFAULT_COOKIE_KEY = "i18n_redirected"; const SWITCH_LOCALE_PATH_LINK_IDENTIFIER = "nuxt-i18n-slp"; const FULL_STATIC_LIFETIME = 60 * 60 * 24; const DEFAULT_OPTIONS = { restructureDir: "i18n", experimental: { localeDetector: "", typedPages: true, typedOptionsAndMessages: false, alternateLinkCanonicalQueries: true, devCache: false, cacheLifetime: void 0, stripMessagesPayload: false, preload: false, strictSeo: false, nitroContextDetection: true, httpCacheDuration: 10, compactRoutes: false, prerenderMessages: false }, bundle: { compositionOnly: true, runtimeOnly: false, fullInstall: true, dropMessageCompiler: false }, compilation: { strictMessage: true, escapeHtml: false }, customBlocks: { defaultSFCLang: "json", globalSFCScope: false }, vueI18n: "", locales: [], defaultLocale: "", defaultDirection: "ltr", routesNameSeparator: "___", trailingSlash: false, defaultLocaleRouteNameSuffix: "default", strategy: STRATEGY_PREFIX_EXCEPT_DEFAULT, langDir: "locales", rootRedirect: void 0, redirectStatusCode: 302, detectBrowserLanguage: { alwaysRedirect: false, cookieCrossOrigin: false, cookieDomain: null, cookieKey: DEFAULT_COOKIE_KEY, cookieSecure: false, fallbackLocale: "", redirectOn: "root", useCookie: true }, differentDomains: false, baseUrl: "", customRoutes: "page", pages: {}, skipSettingLocaleOnNavigate: false, types: "composition", debug: false, parallelPlugin: false, multiDomainLocales: false, hmr: true, autoDeclare: true, serverRoutePrefix: "/_i18n" }; const TS_EXTENSIONS = [".ts", ".cts", ".mts"]; const JS_EXTENSIONS = [".js", ".cjs", ".mjs"]; const EXECUTABLE_EXTENSIONS = [...JS_EXTENSIONS, ...TS_EXTENSIONS]; const EXECUTABLE_EXT_RE = /\.[c|m]?[j|t]s$/; function filterLocales(ctx, nuxt) { const project = getLayerI18n(nuxt.options._layers[0]); const include = toArray(project?.bundle?.onlyLocales ?? []).filter(isString); if (!include.length) { return ctx.options.locales; } return ctx.options.locales.filter((x) => include.includes(isString(x) ? x : x.code)); } function resolveLocales(srcDir, locales, vfs) { const localesResolved = []; for (const locale of locales) { const resolved = assign({ meta: [] }, locale); delete resolved.file; delete resolved.files; for (const f of getLocaleFiles(locale)) { const path = resolve(srcDir, f.path); const type = getLocaleType(path, vfs); resolved.meta.push({ type, path, hash: getHash(path), cache: f.cache ?? type !== "dynamic" }); } localesResolved.push(resolved); } return localesResolved; } const analyzedMap = { object: "static", function: "dynamic", unknown: "unknown" }; function getLocaleType(path, vfs) { if (!EXECUTABLE_EXT_RE.test(path)) { return "static"; } const parsed = parseSync(path, vfs[path] ?? readFileSync(path, "utf-8")); return analyzedMap[scanProgram(parsed.program) || "unknown"]; } function scanProgram(program) { let varDeclarationName; const varDeclarations = []; for (const node of program.body) { switch (node.type) { // collect variable declarations case "VariableDeclaration": for (const decl of node.declarations) { if (decl.type !== "VariableDeclarator" || decl.init == null) { continue; } if ("name" in decl.id === false) { continue; } varDeclarations.push(decl); } break; // check default export - store identifier if exporting variable name case "ExportDefaultDeclaration": if (node.declaration.type === "Identifier") { varDeclarationName = node.declaration; break; } if (node.declaration.type === "ObjectExpression") { return "object"; } if (node.declaration.type === "CallExpression" && node.declaration.callee.type === "Identifier") { const [fnNode] = node.declaration.arguments; if (fnNode?.type === "FunctionExpression" || fnNode?.type === "ArrowFunctionExpression") { return "function"; } } break; } } if (varDeclarationName) { const n = varDeclarations.find((x) => x.id.type === "Identifier" && x.id.name === varDeclarationName.name); if (n) { if (n.init?.type === "ObjectExpression") { return "object"; } if (n.init?.type === "CallExpression" && n.init.callee.type === "Identifier") { const [fnNode] = n.init.arguments; if (fnNode?.type === "FunctionExpression" || fnNode?.type === "ArrowFunctionExpression") { return "function"; } } } } return false; } function resolveVueI18nConfigInfo(path, vfs) { return { path, hash: getHash(path), type: getLocaleType(path, vfs) }; } const getLocaleFiles = (locale) => { return toArray(locale.file ?? locale.files).filter((x) => x != null).map((x) => isString(x) ? { path: x, cache: void 0 } : x); }; function resolveRelativeLocales(locale, config) { return getLocaleFiles(locale).map((file) => ({ path: resolve(config.langDir, file.path), cache: file.cache })); } const mergeConfigLocales = (configs) => { const merged = /* @__PURE__ */ new Map(); for (const config of configs) { for (const locale of config.locales ?? []) { const current = isString(locale) ? { code: locale, language: locale } : assign({}, locale); const files = isString(locale) ? [] : resolveRelativeLocales(current, config); delete current.file; delete current.files; const existing = merged.get(current.code) ?? { code: current.code, language: current.language, files: [] }; existing.files = [...files, ...existing.files]; merged.set(current.code, assign({}, current, existing)); } } return Array.from(merged.values()); }; function getHash(text) { return createHash("sha256").update(text).digest("hex").substring(0, 8); } function computeLocaleHashes(localeInfo, vueI18nConfigPaths) { const hasher = createHash("sha256"); const paths = [ ...localeInfo.flatMap((l) => l.meta.map((m) => m.path)), ...vueI18nConfigPaths.map((c) => c.path) ].sort(); for (const p of paths) { hasher.update(readFileSync(p)); } const digest = hasher.digest("hex").substring(0, 8); const hashes = {}; for (const locale of localeInfo) { hashes[locale.code] = digest; } return hashes; } function getLayerI18n(configLayer) { const layerInlineOptions = (configLayer.config.modules || []).find( (mod) => isArray(mod) && "@nuxtjs/i18n" === mod[0] )?.[1]; if (configLayer.config.i18n) { return defu(configLayer.config.i18n, layerInlineOptions); } return layerInlineOptions; } function toArray(value) { return Array.isArray(value) ? value : [value]; } const logger = useLogger("nuxt-i18n"); const join = (...args) => args.filter(Boolean).join(""); function handlePathNesting(localizedPath, parentLocalizedPath = "") { if (!parentLocalizedPath || parentLocalizedPath === "/") { return localizedPath; } if (localizedPath[0] !== "/") { return localizedPath; } const index = localizedPath.indexOf(parentLocalizedPath); if (index >= 0) { return localizedPath.slice(localizedPath.indexOf(parentLocalizedPath) + parentLocalizedPath.length + 1); } return localizedPath; } function createHandleTrailingSlash(ctx) { return (localizedPath, hasParent) => { if (!localizedPath) { return ""; } const isChildWithRelativePath = hasParent && !localizedPath.startsWith("/"); return localizedPath.replace(/\/+$/, "") + (ctx.trailingSlash ? "/" : "") || (isChildWithRelativePath ? "" : "/"); }; } function createLocalizeAliases(ctx) { return (route, locale, options) => { const aliases = toArray(route.alias).filter(Boolean); return aliases.map((x) => { const alias = ctx.handleTrailingSlash(x, !!options.parent); const shouldPrefix = options.shouldPrefix(x, locale, options); return shouldPrefix ? join("/", locale, alias) : alias; }); }; } function createLocalizeChildren(ctx) { return (route, parentLocalized, locale, opts) => { const localizeParams = { ...opts, parent: route, locales: [locale], parentLocalized }; return route.children?.flatMap((child) => localizeSingleRoute(child, localizeParams, ctx)) ?? []; }; } function getLocalizedRoute(route, locale, localizedPath, options, ctx) { const path = handlePathNesting(localizedPath, options.parentLocalized?.path); const localized = { ...route }; localized.path = ctx.handleTrailingSlash(path, !!options.parent); localized.name &&= ctx.localizeRouteName(localized, locale, options.defaultTree); localized.alias &&= ctx.localizeAliases(localized, locale, options); localized.children &&= ctx.localizeChildren(route, localized, locale, options); return localized; } function localizeSingleRoute(route, options, ctx) { const routeOptions = ctx.optionsResolver(route, options.locales); if (!routeOptions) { return [route]; } if (ctx.compactRoute && !options.defaultTree && options.parent == null && canCompactRoute(routeOptions, options.locales) && canCompactChildren(route.children, options.locales, ctx)) { const compacted = ctx.compactRoute(route, routeOptions, options); if (compacted) { return compacted; } } const resultRoutes = []; for (const locale of routeOptions.locales) { const unprefixed = routeOptions.paths?.[locale] ?? route.path; const prefixed = join("/", locale, unprefixed); const usePrefix = options.shouldPrefix(unprefixed, locale, options); const data = { route, prefixed, unprefixed, locale, usePrefix, ctx, options }; for (const localizer of ctx.localizers) { if (!localizer.enabled(data)) { continue; } resultRoutes.push(...localizer.localizer(data)); } } return resultRoutes; } function createDefaultOptionsResolver(opts) { return (route, locales) => { if (route.redirect && !route.file) { return void 0; } if (opts?.optionsResolver == null) { return { locales, paths: {} }; } return opts.optionsResolver(route, locales); }; } function createLocalizeRouteName(opts) { const separator = opts.routesNameSeparator || "___"; const defaultSuffix = opts.defaultLocaleRouteNameSuffix || "default"; return (route, locale, isDefault) => { if (route.name == null) { return; } return !isDefault ? route.name + separator + locale : route.name + separator + locale + separator + defaultSuffix; }; } function createRouteContext(opts) { const ctx = { localizers: [] }; ctx.trailingSlash = opts.trailingSlash ?? false; ctx.isDefaultLocale = (locale) => opts.defaultLocales.includes(locale); ctx.localizeRouteName = createLocalizeRouteName(opts); ctx.optionsResolver = createDefaultOptionsResolver(opts); ctx.localizeAliases = createLocalizeAliases(ctx); ctx.localizeChildren = createLocalizeChildren(ctx); ctx.handleTrailingSlash = createHandleTrailingSlash(ctx); ctx.localizers.push({ enabled: () => true, localizer: ({ prefixed, unprefixed, route, usePrefix, ctx: ctx2, locale, options }) => [ getLocalizedRoute(route, locale, usePrefix ? prefixed : unprefixed, options, ctx2) ] }); return ctx; } function canCompactRoute(routeOptions, allLocales) { if (!routeOptions) { return false; } if (routeOptions.locales.length !== allLocales.length) { return false; } return Object.keys(routeOptions.paths).length === 0; } function canCompactChildren(children, allLocales, ctx) { if (!children?.length) { return true; } for (const child of children) { const childOptions = ctx.optionsResolver(child, allLocales); if (!canCompactRoute(childOptions, allLocales)) { return false; } if (!canCompactChildren(child.children, allLocales, ctx)) { return false; } } return true; } function createShouldPrefix(opts, ctx) { if (opts.strategy === "no_prefix") { return () => false; } return (path, locale, options) => { if (options.defaultTree) { return false; } if (options.parent != null && !path.startsWith("/")) { return false; } if (ctx.isDefaultLocale(locale) && opts.strategy === "prefix_except_default") { return false; } return true; }; } function shouldLocalizeRoutes(options) { if (options.strategy !== "no_prefix") { return true; } if (!options.differentDomains) { return false; } const domains = /* @__PURE__ */ new Set(); for (const locale of options.locales) { if (!locale.domain) { continue; } if (domains.has(locale.domain)) { console.error( `Cannot use \`strategy: no_prefix\` when using multiple locales on the same domain - found multiple entries with ${locale.domain}` ); return false; } domains.add(locale.domain); } return true; } function resolveDefaultLocales(config) { let defaultLocales = [config.defaultLocale ?? ""]; if (config.differentDomains) { const domainDefaults = config.locales.filter((locale) => !!locale.domainDefault).map((locale) => locale.code); defaultLocales = defaultLocales.concat(domainDefaults); } return defaultLocales; } function localizeRoutes(routes, config) { if (!shouldLocalizeRoutes(config)) { return routes; } const ctx = createRouteContext({ optionsResolver: config.optionsResolver, trailingSlash: config.trailingSlash ?? false, defaultLocales: resolveDefaultLocales(config), routesNameSeparator: config.routesNameSeparator, defaultLocaleRouteNameSuffix: config.defaultLocaleRouteNameSuffix }); const strategy = config.strategy ?? "prefix_and_default"; if (config.compactRoutes && strategy !== "no_prefix" && !config.differentDomains && !config.multiDomainLocales && !(strategy === "prefix" && config.includeUnprefixedFallback)) { const defaultLocale = config.defaultLocale ?? ""; ctx.compactRoute = (route, routeOptions, params2) => { if (route.path.includes(":locale")) { return void 0; } const makeRegexRoute = (locales2) => { const localePattern = locales2.join("|"); const regexPrefix = `/:locale(${localePattern})`; const regexPath = route.path === "/" ? regexPrefix : regexPrefix + route.path; const compacted = { ...route, path: ctx.handleTrailingSlash(regexPath, !!params2.parent), meta: { ...route.meta ?? {}, __i18nCompact: true } }; if (compacted.alias) { const aliases = Array.isArray(compacted.alias) ? compacted.alias : [compacted.alias]; compacted.alias = aliases.map((a) => { const aliasPath = regexPrefix + (a.startsWith("/") ? a : "/" + a); return ctx.handleTrailingSlash(aliasPath, !!params2.parent); }); } return compacted; }; if (strategy === "prefix_except_default" && defaultLocale) { const result = []; const unprefixed = { ...route }; unprefixed.name &&= ctx.localizeRouteName(unprefixed, defaultLocale, false); unprefixed.children &&= ctx.localizeChildren(route, unprefixed, defaultLocale, params2); result.push(unprefixed); const nonDefault = routeOptions.locales.filter((l) => !ctx.isDefaultLocale(l)); if (nonDefault.length > 0) { result.push(makeRegexRoute(nonDefault)); } return result; } if (strategy === "prefix_and_default" && defaultLocale) { const defaultTree = { ...route }; defaultTree.name &&= ctx.localizeRouteName(defaultTree, defaultLocale, true); defaultTree.children &&= ctx.localizeChildren(route, defaultTree, defaultLocale, { ...params2, defaultTree: true }); return [defaultTree, makeRegexRoute(routeOptions.locales)]; } return [makeRegexRoute(routeOptions.locales)]; }; } if (strategy === "prefix_and_default") { ctx.localizers.unshift({ enabled: ({ options, locale }) => ctx.isDefaultLocale(locale) && !options.defaultTree && options.parent == null, localizer: ({ route, ctx: ctx2, locale, options }) => localizeSingleRoute(route, { ...options, locales: [locale], defaultTree: true }, ctx2) }); } const multiDomainLocales = config.multiDomainLocales ?? false; if (multiDomainLocales && (config.strategy === "prefix_except_default" || config.strategy === "prefix_and_default")) { ctx.localizers.unshift({ enabled: ({ usePrefix }) => usePrefix, localizer: ({ unprefixed, route, ctx: ctx2, locale }) => [ { ...route, name: ctx2.localizeRouteName(route, locale, true), path: unprefixed } ] }); } const includeUnprefixedFallback = config.includeUnprefixedFallback ?? false; if (strategy === "prefix" && includeUnprefixedFallback) { ctx.localizers.unshift({ enabled: ({ usePrefix, locale }) => usePrefix && ctx.isDefaultLocale(locale), localizer: ({ route }) => [route] }); } const locales = config.locales.map((x) => x.code); const params = { locales, defaultTree: false, shouldPrefix: createShouldPrefix(config, ctx) }; return routes.flatMap((route) => localizeSingleRoute(route, params, ctx)); } const VIRTUAL_PREFIX_HEX = "\0"; const NUXT_I18N_VIRTUAL_PREFIX = "#nuxt-i18n"; function asI18nVirtual(val) { return NUXT_I18N_VIRTUAL_PREFIX + "/" + val; } function isVue(id, opts = {}) { const { search } = parseURL(decodeURIComponent(pathToFileURL(id).href)); if (id.endsWith(".vue") && !search) { return true; } if (!search) { return false; } const query = parseQuery(search); if (query.nuxt_component) { return false; } if (query.macro && (search === "?macro=true" || !opts.type || opts.type.includes("script"))) { return true; } const type = "setup" in query ? "script" : query.type; if (!("vue" in query) || opts.type && !opts.type.includes(type)) { return false; } return true; } function transform(id, input, options) { const oxcOptions = tryUseNuxt()?.options?.oxc?.transform?.options ?? {}; return transformSync(id, input, { ...oxcOptions, ...options }); } const DEFINE_I18N_FN_RE = /\b(defineI18nLocale|defineI18nConfig)\s*\((.+)\)/gs; const ResourcePlugin = (options, ctx) => createUnplugin(() => { const i18nFileMetas = [...ctx.localeInfo.flatMap((x) => x.meta), ...ctx.vueI18nConfigPaths]; const i18nPathSet = /* @__PURE__ */ new Set(); const i18nFileHashSet = /* @__PURE__ */ new Map(); for (const meta of i18nFileMetas) { if (i18nPathSet.has(meta.path)) { continue; } i18nPathSet.add(meta.path); i18nFileHashSet.set(asI18nVirtual(meta.hash), meta.path); } return { name: "nuxtjs:i18n-resource", enforce: "pre", // resolve virtual hash to file path resolveId(id) { if (!id || id.startsWith(VIRTUAL_PREFIX_HEX) || !id.startsWith(NUXT_I18N_VIRTUAL_PREFIX)) { return; } if (i18nFileHashSet.has(id)) { return i18nFileHashSet.get(id); } }, transformInclude(id) { if (!id || id.startsWith(VIRTUAL_PREFIX_HEX)) { return false; } if (i18nPathSet.has(id)) { return /\.[cm]?[jt]s$/.test(id); } }, /** * Match and replace `defineI18nX(<content>)` with its `<content>` */ transform: { filter: { id: { include: [...i18nPathSet] } }, async handler(_code, id) { let code = _code; const staticImports = findStaticImports(_code); for (const x of staticImports) { if (x.specifier.startsWith("\0")) { continue; } i18nPathSet.add(await resolvePath(resolve(dirname(id), x.specifier))); } if (/[cm]?ts$/.test(id)) { code = transform(id, _code).code; } const s = new MagicString(code); const matches = code.matchAll(DEFINE_I18N_FN_RE); for (const match of matches) { s.overwrite(match.index, match.index + match[0].length, match[2]); } if (s.hasChanged()) { return { code: s.toString(), map: options.sourcemap && !/\.[cm]?ts$/.test(id) ? s.generateMap({ hires: true }) : null }; } } } }; }); class NuxtPageAnalyzeContext { config; pages = /* @__PURE__ */ new Map(); pathToConfig = {}; fileToPath = {}; constructor(config) { this.config = config || {}; } addPage(page, path, name) { this.pages.set(page.file, { path, name }); const p = path === "index" ? "/" : "/" + path.replace(/\/index$/, ""); this.fileToPath[page.file] = p; } } async function setupPages({ localeCodes, options, normalizedLocales }, nuxt) { const routeResources = { i18nPathToPath: {}, pathToI18nConfig: {}, disabledI18nPathToPath: {} }; addTemplate({ filename: "i18n-route-resources.mjs", write: true, getContents: () => { return `// Generated by @nuxtjs/i18n export const pathToI18nConfig = ${JSON.stringify(routeResources.pathToI18nConfig, null, 2)}; export const i18nPathToPath = ${JSON.stringify(routeResources.i18nPathToPath, null, 2)} export const disabledI18nPathToPath = ${JSON.stringify(routeResources.disabledI18nPathToPath, null, 2)};`; } }); if (!localeCodes.length) { return; } let includeUnprefixedFallback = !nuxt.options.ssr; const compactPrerenderRoutes = []; nuxt.hook("nitro:init", (nitro) => { includeUnprefixedFallback = options.strategy !== "prefix"; if (!nuxt.options.nitro.static) { return; } if (options.strategy === "prefix") { nitro.options.prerender.ignore ??= []; nitro.options.prerender.ignore.push(/^\/$/); } nitro.hooks.hook("prerender:routes", (routes) => { if (options.strategy === "prefix") { for (const locale of localeCodes) { routes.add("/" + locale); } } for (const route of compactPrerenderRoutes) { routes.add(route); } }); }); const projectLayer = nuxt.options._layers[0]; const typedRouter = await setupExperimentalTypedRoutes(options, nuxt); nuxt.options.experimental.extraPageMetaExtractionKeys ??= []; nuxt.options.experimental.extraPageMetaExtractionKeys.push("i18n"); nuxt.hook( nuxt.options.experimental.scanPageMeta === "after-resolve" ? "pages:resolved" : "pages:extend", async (pages) => { const ctx = new NuxtPageAnalyzeContext(options.pages); for (const layer of nuxt.options._layers) { const pagesDir = resolve(projectLayer.config.rootDir, layer.config.srcDir, layer.config.dir?.pages ?? "pages"); analyzeNuxtPages(ctx, pagesDir, pages); } if (typedRouter) { await typedRouter.createContext(pages).scanPages(false); } const resolver = createPureOptionsResolver(ctx, options.defaultLocale, options.customRoutes); const localizationOptions = { ...options, includeUnprefixedFallback, locales: normalizedLocales, optionsResolver: resolver, compactRoutes: !!options.experimental?.compactRoutes }; const localizedPages = localizeRoutes(pages, localizationOptions); if (shouldLocalizeRoutes(localizationOptions)) { buildPathToConfig(ctx, localeCodes, resolver, pages); } const indexPage = pages.find((x) => x.path === "/"); if (options.strategy === "prefix" && indexPage != null) { localizedPages.unshift(indexPage); } const invertedMap = {}; const localizedMapInvert = {}; const notLocalizedMapInvert = {}; for (const [path, localeConfig] of Object.entries(ctx.pathToConfig)) { const resPath = resolveRoutePath(path); invertedMap[resPath] ??= {}; let hasLocalized = false; for (const [locale, localePath] of Object.entries(localeConfig)) { const localized = localePath === true ? path : localePath; invertedMap[resPath][locale] = localized && resolveRoutePath(localized); if (invertedMap[resPath][locale]) { localizedMapInvert[invertedMap[resPath][locale]] = resPath; hasLocalized = true; } } if (!hasLocalized) { notLocalizedMapInvert[resPath] = resPath; } } routeResources.i18nPathToPath = localizedMapInvert; routeResources.pathToI18nConfig = invertedMap; routeResources.disabledI18nPathToPath = notLocalizedMapInvert; await updateTemplates({ filter: (template) => template.filename === "i18n-route-resources.mjs" }); if (pages !== localizedPages) { pages.length = 0; pages.unshift(...localizedPages); } if (options.experimental?.compactRoutes && nuxt.options.nitro.static) { compactPrerenderRoutes.push(...collectCompactPrerenderRoutes(localizedPages)); } } ); } const compactRouteRE = /^\/:locale\(([^)]+)\)(.*)$/; const remainingParamRE = /:[A-Z_]/i; function collectCompactPrerenderRoutes(pages) { const out = []; const emit = (locales, rest) => { if (remainingParamRE.test(rest)) { return false; } for (const locale of locales) { out.push("/" + locale + rest); } return true; }; const walkChildren = (children, locales, parentRest) => { if (!children?.length) { return; } for (const child of children) { if (child.path.startsWith("/")) { continue; } if (child.path === "") { walkChildren(child.children, locales, parentRest); continue; } const rest = parentRest.replace(/\/$/, "") + "/" + child.path; if (!emit(locales, rest)) { continue; } walkChildren(child.children, locales, rest); } }; for (const route of pages) { if (!route.meta?.__i18nCompact) { continue; } const match = compactRouteRE.exec(route.path); if (!match) { continue; } const locales = match[1].split("|"); const rest = match[2]; if (!emit(locales, rest)) { continue; } walkChildren(route.children, locales, rest); } return out; } const routeNamedMapTypeRE = /RouteNamedMap\b/; const declarationFile = "./types/typed-router-i18n.d.ts"; async function setupExperimentalTypedRoutes(userOptions, nuxt) { if (!nuxt.options.experimental.typedPages || userOptions.experimental?.typedPages === false) { return void 0; } const dtsFile = resolve(nuxt.options.buildDir, declarationFile); function createContext(pages) { const typedRouteroptions = { routesFolder: [], dts: dtsFile, logs: !!nuxt.options.debug, watch: false, beforeWriteFiles(rootPage) { rootPage.children.forEach((child) => child.delete()); function addPage(parent, page) { const route = parent.insert(page.path, page.file); if (page.meta) { route.addToMeta(page.meta); } if (page.alias) { route.addAlias(Array.isArray(page.alias) ? page.alias : [page.alias]); } if (page.name) { route.name = page.name; } if (page.children) { page.children.forEach((child) => addPage(route, child)); } } for (const page of pages) { addPage(rootPage, page); } } }; const context = createRoutesContext(resolveOptions(typedRouteroptions)); const originalScanPages = context.scanPages.bind(context); context.scanPages = async function(watchers = false) { await mkdir(dirname(dtsFile), { recursive: true }); await originalScanPages(watchers); const dtsContent = await readFile(dtsFile, "utf-8"); if (routeNamedMapTypeRE.test(dtsContent)) { await writeFile(dtsFile, dtsContent.replace(routeNamedMapTypeRE, "RouteNamedMapI18n")); } }; return context; } addTemplate({ filename: resolve(nuxt.options.buildDir, "./types/i18n-generated-route-types.d.ts"), getContents: () => { return `// Generated by @nuxtjs/i18n import type { RouteNamedMapI18n } from 'vue-router/auto-routes' declare module 'vue-router' { export interface TypesConfig { RouteNamedMapI18n: RouteNamedMapI18n } } export {}`; } }); nuxt.hook("prepare:types", ({ references }) => { references.push({ path: declarationFile }); references.push({ types: "./types/i18n-generated-route-types.d.ts" }); }); await createContext(nuxt.apps.default?.pages ?? []).scanPages(false); return { createContext }; } function analyzePagePath(pagePath, parents = 0) { const { dir, name } = parse(pagePath); const analyzed = parents > 0 || dir !== "/" ? `${dir.slice(1, dir.length)}/${name}` : name; return stripRouteGroups(analyzed); } function stripRouteGroups(path) { return path.split("/").filter((s) => !/^\([^)]+\)$/.test(s)).join("/"); } function analyzeNuxtPages(ctx, pagesDir, pages) { if (pages == null || pages.length === 0) { return; } for (const page of pages) { if (page.file == null) { continue; } const [, filePath] = page.file.split(pagesDir); if (filePath == null) { continue; } ctx.addPage(page, analyzePagePath(filePath), page.name ?? page.children?.find((x) => x.path.endsWith("/index"))?.name); analyzeNuxtPages(ctx, pagesDir, page.children); } } function createPureOptionsResolver(ctx, defaultLocale, customRoutes) { const cache = /* @__PURE__ */ new Map(); return (route, localeCodes) => { const key = `${route.file ?? route.name ?? route.path}::${localeCodes.join(",")}`; if (cache.has(key)) { return cache.get(key); } const resolved = getRouteOptions(route, localeCodes, ctx, defaultLocale, customRoutes); cache.set(key, resolved); return resolved; }; } function buildPathToConfig(ctx, localeCodes, resolver, routes) { for (const route of routes) { if (route.file) { const res = resolver(route, localeCodes); const localeCfg = res?.srcPaths; const mappedPath = ctx.fileToPath[route.file]; if (mappedPath) { ctx.pathToConfig[mappedPath] ??= {}; for (const l of localeCodes) { ctx.pathToConfig[mappedPath][l] ??= localeCfg?.[l] ?? false; } for (const l of res?.locales ?? []) { ctx.pathToConfig[mappedPath][l] ||= true; } } } if (route.children?.length) { buildPathToConfig(ctx, localeCodes, resolver, route.children); } } } function resolveRoutePath(path) { const tokens = parseSegment(path.slice(1)); return "/" + toVueRouterSegment(tokens); } function getRouteFromConfig(ctx, route, localeCodes) { const pageMeta = ctx.pages.get(route.file); if (pageMeta == null) { return void 0; } const valueByName = pageMeta?.name ? ctx.config?.[pageMeta.name] : void 0; const valueByPath = pageMeta?.path != null ? ctx.config?.[pageMeta.path] : void 0; const resolved = valueByName ?? valueByPath; if (!resolved) { return resolved; } return { paths: resolved ?? {}, locales: localeCodes.filter((locale) => resolved[locale] !== false) }; } function getRouteFromResource(localeCodes, resolved) { if (!resolved) { return resolved; } return { paths: resolved.paths ?? {}, locales: resolved?.locales || localeCodes }; } function getRouteOptions(route, localeCodes, ctx, defaultLocale, mode = "config") { let resolvedOptions; if (mode === "config") { resolvedOptions = getRouteFromConfig(ctx, route, localeCodes); } else { resolvedOptions = getRouteFromResource( localeCodes, mode === "page" ? getI18nRouteConfig(route.file) : route.meta?.i18n ); } if (resolvedOptions === false) { return void 0; } const locales = resolvedOptions?.locales || localeCodes; const paths = {}; if (!resolvedOptions) { return { locales, paths }; } for (const locale of resolvedOptions.locales) { if (isString(resolvedOptions.paths[locale])) { paths[locale] = resolveRoutePath(resolvedOptions.paths[locale]); continue; } if (isString(resolvedOptions.paths[defaultLocale])) { paths[locale] = resolveRoutePath(resolvedOptions.paths[defaultLocale]); } } return { locales, paths, srcPaths: resolvedOptions.paths }; } function getI18nRouteConfig(absolutePath, vfs = {}) { let extract = void 0; try { const content = absolutePath in vfs ? vfs[absolutePath] : readFileSync(absolutePath, "utf-8"); if (!content.includes("defineI18nRoute")) { return void 0; } const { descriptor } = parse$1(content); const script = descriptor.scriptSetup || descriptor.script; if (!script) { return void 0; } const lang = typeof script.attrs.lang === "string" && /j|tsx/.test(script.attrs.lang) ? "tsx" : "ts"; let code = script.content; parseAndWalk(script.content, absolutePath.replace(/\.\w+$/, "." + lang), (node) => { if (extract != null) { return; } if (node.type !== "CallExpression" || node.callee.type !== "Identifier" || node.callee.name !== "defineI18nRoute") { return; } let routeArgument = node.arguments[0]; if (routeArgument == null) { return; } if (typeof script.attrs.lang === "string" && /tsx?/.test(script.attrs.lang)) { const transformed = transform("", script.content.slice(node.start, node.end).trim(), { lang }); code = transformed.code; if (transformed.errors.length) { for (const error of transformed.errors) { console.warn(`Error while transforming \`defineI18nRoute()\`` + error.codeframe); } return; } routeArgument = parseSync("", transformed.code, { lang: "js" }).program.body[0].expression.arguments[0]; } extract = evalAndValidateValue(code.slice(routeArgument.start, routeArgument.end).trim()); }); } catch (e) { console.warn(`[nuxt-i18n] Couldn't read component data at ${absolutePath}: (${e.message})`); } return extract; } function evalValue(value) { try { return new Function(`return (${value})`)(); } catch { console.error(`[nuxt-i18n] Cannot evaluate value: ${value}`); return; } } function evalAndValidateValue(value) { const evaluated = evalValue(value); if (evaluated == null) { return; } if (typeof evaluated === "boolean" && evaluated === false) { return evaluated; } if (Object.prototype.toString.call(evaluated) === "[object Object]") { if (evaluated.locales) { if (!Array.isArray(evaluated.locales) || evaluated.locales.some((locale) => typeof locale !== "string")) { console.warn(`[nuxt-i18n] Invalid locale option used with \`defineI18nRoute\`: ${value}`); return; } } if (evaluated.paths && Object.prototype.toString.call(evaluated.paths) !== "[object Object]") { console.warn(`[nuxt-i18n] Invalid paths option used with \`defineI18nRoute\`: ${value}`); return; } return evaluated; } console.warn(`[nuxt-i18n] Invalid value passed to \`defineI18nRoute\`: ${value}`); } const I18N_MACRO_FN_RE = /\bdefineI18nRoute\s*\(\s*/; const TransformMacroPlugin = (options) => createUnplugin(() => { return { name: "nuxtjs:i18n-macros-transform", enforce: "pre", transformInclude(id) { if (!id || id.startsWith(VIRTUAL_PREFIX_HEX)) { return false; } return isVue(id, { type: ["script"] }); }, transform: { filter: { code: { include: I18N_MACRO_FN_RE } }, handler(code) { const parsed = parse$1(code, { sourceMap: false }); const script = parsed.descriptor.scriptSetup ?? parsed.descriptor.script; if (!script) { return; } const s = new MagicString(code); const match = script.content.match(I18N_MACRO_FN_RE); if (match?.[0]) { const scriptString = new MagicString(script.content); scriptString.overwrite(match.index, match.index + match[0].length, `false && /*#__PURE__*/ ${match[0]}`); s.overwrite(script.loc.start.offset, script.loc.end.offset, scriptString.toString()); } if (s.hasChanged()) { return { code: s.toString(), map: options.sourcemap ? s.generateMap({ hires: true }) : void 0 }; } } } }; }); const TRANSLATION_FUNCTIONS = ["$t", "$rt", "$d", "$n", "$tm", "$te"]; const TRANSLATION_FUNCTIONS_RE = /\$([tdn]|rt|tm|te)\s*\(\s*/; const TRANSLATION_FUNCTIONS_MAP = { $t: "t: $t", $rt: "rt: $rt", $d: "d: $d", $n: "n: $n", $tm: "tm: $tm", $te: "te: $te" }; const QUERY_RE = /\?.*$/; function withoutQuery(id) { return id.replace(QUERY_RE, ""); } const TransformI18nFunctionPlugin = (options) => createUnplugin(() => { return { name: "nuxtjs:i18n-function-injection", enforce: "pre", transformInclude(id) { return isVue(id, { type: ["script"] }); }, transform: { filter: { code: { include: TRANSLATION_FUNCTIONS_RE } }, handler(code, id) { const script = extractScriptSetupContent(code); if (!script) { return; } const filepath = withoutQuery(id).replace(/\.\w+$/, "." + script.loader); const missing = collectMissingI18nFunctions(script.code, filepath); if (!missing.size) { return; } const assignments = []; for (const entry of missing) { assignments.push(TRANSLATION_FUNCTIONS_MAP[entry]); } const s = new MagicString(code); s.appendLeft(script.start, ` const { ${assignments.join(", ")} } = useI18n() `); return { code: s.toString(), map: options.sourcemap ? s.generateMap({ hires: true }) : void 0 }; } } }; }); function collectMissingI18nFunctions(script, id) { const scopeTracker = new ScopeTracker({ preserveExitedScopes: true }); const ast = parseAndWalk(script, id, { scopeTracker }); const missing = /* @__PURE__ */ new Set(); walk(ast.program, { scopeTracker, enter(node) { if (node.type !== "CallExpression" || node.callee.type !== "Identifier") { return; } const name = node.callee.name; if (!name || !TRANSLATION_FUNCTIONS.includes(name) || scopeTracker.isDeclared(name)) { return; } missing.add(name); } }); return missing; } const SFC_SCRIPT_COMPLEX_RE = /<script(?<attrs>[^>]*)>(?<content>[\s\S]*?)<\/script[^>]*>/i; function extractScriptSetupContent(sfc) { const match = sfc.match(SFC_SCRIPT_COMPLEX_RE); if (match?.groups?.content && match.groups.attrs && match.groups.attrs.includes("setup")) { return { code: match.groups.content.trim(), loader: match.groups.attrs && /[tj]sx/.test(match.groups.attrs) ? "tsx" : "ts", start: sfc.indexOf(match.groups.content) }; } } const HeistPlugin = (options, ctx, nuxt = useNuxt()) => { const shared = ctx.resolver.resolve(ctx.distDir, "runtime/shared/*"); const replacementName = `__nuxtMock`; const replacementMock = `const ${replacementName} = { runWithContext: async (fn) => await fn() };`; const resources = ["i18n-route-resources.mjs", "i18n-options.mjs"]; return createUnplugin(() => ({ name: "nuxtjs:i18n-heist", enforce: "pre", transform: { filter: { id: [shared, relative(nuxt.options.rootDir, shared)] }, handler(code) { const s = new MagicString(code); if (code.includes("useRuntimeConfig()")) { s.prepend('import { useRuntimeConfig } from "nitropack/runtime";\n'); } s.replace(/import.+["']#app["'];?/, replacementMock); s.replaceAll(/useNuxtApp\(\)/g, replacementName); for (const resource of resources) { s.replaceAll(new RegExp(`#build/${resource}`, "g"), `#internal/${resource}`); } return { code: s.toString(), map: options.sourcemap ? s.generateMap({ hires: true }) : void 0 }; } } })); }; const version = "10.4.0"; async function extendBundler(ctx, nuxt) { const pluginOptions = { sourcemap: !!nuxt.options.sourcemap.server || !!nuxt.options.sourcemap.client }; const resourcePlugin = ResourcePlugin(pluginOptions, ctx); addBuildPlugin(resourcePlugin); nuxt.hook("nitro:config", async (cfg) => { cfg.rollupConfig.plugins = await cfg.rollupConfig.plugins || []; cfg.rollupConfig.plugins = toArray(cfg.rollupConfig.plugins); cfg.rollupConfig.plugins.push(HeistPlugin(pluginOptions, ctx).rollup()); cfg.rollupConfig.plugins.push(resourcePlugin.rollup()); }); const localePaths = [...new Set(ctx.localeInfo.flatMap((x) => x.meta.map((m) => m.path)))]; ctx.fullStatic = ctx.localeInfo.flatMap((x) => x.meta).every((x) => x.type === "static" || x.cache !== false); const vueI18nPluginOptions = { ...ctx.options.bundle, ...ctx.options.compilation, ...ctx.options.customBlocks, allowDynamic: true, optimizeTranslationDirective: false, include: localePaths.length ? localePaths : [] }; addBuildPlugin({ vite: () => VueI18nPlugin.vite(vueI18nPluginOptions), webpack: () => VueI18nPlugin.webpack(vueI18nPluginOptions) }); addBuildPlugin(TransformMacroPlugin(pluginOptions)); if (ctx.options.autoDeclare && nuxt.options.imports.autoImport !== false) { addBuildPlugin(TransformI18nFunctionPlugin(pluginOptions)); } const defineConfig = getDefineConfig(ctx); await addDefinePlugin(defineConfig); } function getDefineConfig({ options, fullStatic, localeHashes }, server = false, nuxt = useNuxt()) { const cacheLifetime = options.experimental.cacheLifetime ?? (fullStatic ? FULL_STATIC_LIFETIME : -1); const isCacheEnabled = cacheLifetime >= 0 && (!nuxt.options.dev || !!options.experimental.devCache); let stripMessagesPayload = !!options.experimental.preload; if (nuxt.options.i18n && nuxt.options.i18n.experimental?.stripMessagesPayload != null) { stripMessagesPayload = nuxt.options.i18n.experimental.stripMessagesPayload; } const common = { __IS_SSR__: String(nuxt.options.ssr), __IS_SSG__: String(!!nuxt.options.nitro.static), __PARALLEL_PLUGIN__: String(options.parallelPlugin), __DYNAMIC_PARAMS_KEY__: JSON.stringify(DYNAMIC_PARAMS_KEY), __DEFAULT_COOKIE_KEY__: JSON.stringify(DEFAULT_COOKIE_KEY), __NUXT_I18N_VERSION__: JSON.stringify(version), __SWITCH_LOCALE_PATH_LINK_IDENTIFIER__: JSON.stringify(SWITCH_LOCALE_PATH_LINK_IDENTIFIER), __I18N_STRATEGY__: JSON.stringify(options.strategy), __DIFFERENT_DOMAINS__: String(options.differentDomains), __MULTI_DOMAIN_LOCALES__: String(options.multiDomainLocales), __ROUTE_NAME_SEPARATOR__: JSON.stringify(options.routesNameSeparator), __ROUTE_NAME_DEFAULT_SUFFIX__: JSON.stringify(options.defaultLocaleRouteNameSuffix), __TRAILING_SLASH__: String(options.trailingSlash), __DEFAULT_DIRECTION__: JSON.stringify(options.defaultDirection), __I18N_CACHE__: String(isCacheEnabled), __I18N_CACHE_LIFETIME__: JSON.stringify(cacheLifetime), __I18N_HTTP_CACHE_DURATION__: JSON.stringify(options.experimental.httpCacheDuration ?? 10), __I18N_FULL_STATIC__: String(fullStatic), __I18N_STRIP_UNUSED__: JSON.stringify(stripMessagesPayload), __I18N_PRELOAD__: JSON.stringify(!!options.experimental.preload), __I18N_ROUTING__: JSON.stringify(nuxt.options.pages.toString() && options.strategy !== "no_prefix"), __I18N_COMPACT_ROUTES__: String(!!options.experimental?.compactRoutes), __I18N_STRICT_SEO__: JSON.stringify(!!options.experimental.strictSeo), __I18N_SERVER_ROUTE__: JSON.stringify(options.serverRoutePrefix), // SSG already prerenders the messages routes (runtime `prerenderRoutes`), so they exist at the // CDN origin there too — honor `app.cdnURL` for both that and the opt-in `prerenderMessages`. __I18N_CDN__: String(!!nuxt.options.app.cdnURL && (!!options.experimental.prerenderMessages || !!nuxt.options.nitro.static)), __I18N_LOCALE_HASHES__: JSON.stringify(localeHashes), __I18N_SERVER_REDIRECT__: JSON.stringify(!!options.experimental.nitroContextDetection) }; if (nuxt.options.ssr || !server) { return { ...common, __VUE_I18N_LEGACY_API__: String(!(options.bundle?.compositionOnly ?? true)), __VUE_I18N_FULL_INSTALL__: String(options.bundle?.fullInstall ?? true), __INTLIFY_PROD_DEVTOOLS__: "false", __INTLIFY_DROP_MESSAGE_COMPILER__: String(options.bundle?.dropMessageCompiler ?? false) }; } return common; } function stripLocaleFiles(locale) { delete locale.files; delete locale.file; return locale; } function simplifyLocaleOptions(ctx, _nuxt) { const locales = ctx.options.locales ?? []; const hasLocaleObjects = locales?.some((x) => !isString(x)); return locales.map((locale) => !hasLocaleObjects ? locale.code : stripLocaleFiles(locale)); } function generateLoaderOptions(ctx, nuxt) { const importMapper = /* @__PURE__ */ new Map(); const localeLoaders = {}; const importStatements = /* @__PURE__ */ new Set(); for (const locale of ctx.localeInfo) { localeLoaders[locale.code] ??= []; for (const meta of locale.meta) { if (!importMapper.has(meta.path)) { const identifier = `locale_${genSafeVariableName(basename(meta.path))}_${meta.hash}`; const key = genString(identifier); importStatements.add(genImport(asI18nVirtual(meta.hash), identifier)); importMapper.set(meta.path, { key, relative: relative(nuxt.options.buildDir, meta.path), cache: meta.cache ?? true, load: genDynamicImport(asI18nVirtual(meta.hash), { comment: `webpackChunkName: ${key}` }), loadServer: `() => Promise.resolve(${identifier})` }); } localeLoaders[locale.code].push(importMapper.get(meta.path)); } } const vueI18nConfigs = []; for (let i = ctx.vueI18nConfigPaths.length - 1; i >= 0; i--) { const config = ctx.vueI18nConfigPaths[i]; const identifier = `config_${genSafeVariableName(basename(config.path))}_${config.hash}`; const key = genString(identifier); importStatements.add(genImport(asI18nVirtual(config.hash), identifier)); vueI18nConfigs.push({ importer: genDynamicImport(asI18nVirtual(config.hash), { comment: `webpackChunkName: ${key}` }), importerServer: `() => Promise.resolve(${identifier})`, relative: relative(nuxt.options.buildDir, config.path) }); } const normalizedLocales = ctx.normalizedLocales.map((x) => stripLocaleFiles(x)); return { localeLoaders, vueI18nConfigs, normalizedLocales, importStatements: Array.from(importStatements) }; } const typedRouterAugmentations = ` declare module 'vue-router' { export type RouteMapI18n = TypesConfig extends Record<'RouteNamedMapI18n', infer RouteNamedMap> ? RouteNamedMap : RouteMapGeneric // Prefer named resolution for i18n export type RouteLocationNamedI18n<Name extends keyof RouteMapI18n = keyof RouteMapI18n> = | Name | Omit<RouteLocationAsRelativeI18n, 'path'> & { path?: string } /** * Note: disabled route path string autocompletion, this can break depending on \`strategy\` * this can be enabled again after route resolve has been improved. */ // | RouteLocationAsStringI18n // | RouteLocationAsPathI18n export type RouteLocationRawI18n<Name extends keyof RouteMapI18n = keyof RouteMapI18n> = RouteMapGeneric extends RouteMapI18n ? RouteLocationAsStringI18n | RouteLocationAsRelativeGeneric | RouteLocationAsPathGeneric : | _LiteralUnion<RouteLocationAsStringTypedList<RouteMapI18n>[Name], string> | RouteLocationAsRelativeTypedList<RouteMapI18n>[Name] export type RouteLocationResolvedI18n<Name extends keyof RouteMapI18n = keyof RouteMapI18n> = RouteMapGeneric extends RouteMapI18n ? RouteLocationResolvedGeneric : RouteLo