diff --git a/package.json b/package.json index 5cdf80ece..3e940617c 100644 --- a/package.json +++ b/package.json @@ -74,7 +74,7 @@ "lint:eslint": "eslint .", "test": "pnpm build && run-s test:types test:unit test:spec", "test:types": "tsc --noEmit", - "test:unit": "vitest run test", + "test:unit": "vitest run test -c vitest.config.test.ts", "test:spec": "vitest run specs" }, "pnpm": { diff --git a/src/runtime/composables/index.ts b/src/runtime/composables/index.ts index 6631cee4b..3f6bc1960 100644 --- a/src/runtime/composables/index.ts +++ b/src/runtime/composables/index.ts @@ -11,13 +11,13 @@ import { getHreflangLinks, getOgUrl, localeHead -} from '../routing/compatibles/head' -import { getRouteBaseName, localePath, localeRoute, switchLocalePath } from '../routing/compatibles/routing' +} from '../routing/head' +import { getRouteBaseName, localePath, localeRoute, switchLocalePath } from '../routing/routing' import { findBrowserLocale } from '../routing/utils' import { getComposer } from '../compatibility' import type { Ref } from 'vue' import type { Locale } from 'vue-i18n' -import type { resolveRoute } from '../routing/compatibles/routing' +import type { resolveRoute } from '../routing/routing' import type { I18nHeadMetaInfo, I18nHeadOptions, LocaleObject, SeoAttributesOptions } from '#internal-i18n-types' import type { HeadParam } from '../utils' import type { RouteLocationAsRelativeI18n, RouteLocationRaw, RouteLocationResolvedI18n, RouteMapI18n } from 'vue-router' diff --git a/src/runtime/plugins/i18n.ts b/src/runtime/plugins/i18n.ts index 7a9ce4b6a..c345bd957 100644 --- a/src/runtime/plugins/i18n.ts +++ b/src/runtime/plugins/i18n.ts @@ -23,13 +23,12 @@ import { wrapComposable, defineGetter } from '../internal' -import { inBrowser, resolveBaseUrl } from '../routing/utils' -import { extendI18n } from '../routing/extends/i18n' -import { createLocaleFromRouteGetter } from '../routing/extends/router' +import { createLocaleFromRouteGetter, resolveBaseUrl } from '../routing/utils' +import { extendI18n } from '../routing/i18n' import { createLogger } from 'virtual:nuxt-i18n-logger' import { getI18nTarget } from '../compatibility' -import { resolveRoute } from '../routing/compatibles/routing' -import { localeHead } from '../routing/compatibles/head' +import { resolveRoute } from '../routing/routing' +import { localeHead } from '../routing/head' import { useLocalePath, useLocaleRoute, useRouteBaseName, useSwitchLocalePath, useLocaleLocation } from '../composables' import type { Locale, I18nOptions, Composer, I18n } from 'vue-i18n' @@ -108,7 +107,7 @@ export default defineNuxtPlugin({ composer.localeCodes = computed(() => _localeCodes.value) composer.baseUrl = computed(() => _baseUrl.value) - if (inBrowser) { + if (import.meta.client) { watch( composer.locale, () => { diff --git a/src/runtime/plugins/route-locale-detect.ts b/src/runtime/plugins/route-locale-detect.ts index 5c579dcba..b17d41ac2 100644 --- a/src/runtime/plugins/route-locale-detect.ts +++ b/src/runtime/plugins/route-locale-detect.ts @@ -2,8 +2,8 @@ import { unref } from 'vue' import { hasPages, isSSG } from '#build/i18n.options.mjs' import { addRouteMiddleware, defineNuxtPlugin, defineNuxtRouteMiddleware } from '#imports' import { createLogger } from 'virtual:nuxt-i18n-logger' -import { createLocaleFromRouteGetter } from '../routing/extends/router' import { detectLocale, detectRedirect, loadAndSetLocale, navigate } from '../utils' +import { createLocaleFromRouteGetter } from '../routing/utils' import type { NuxtApp } from '#app' import type { CompatRoute } from '../types' diff --git a/src/runtime/routing/compatibles/utils.ts b/src/runtime/routing/compatibles/utils.ts deleted file mode 100644 index 319abe02a..000000000 --- a/src/runtime/routing/compatibles/utils.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { assign } from '@intlify/shared' - -import type { Locale } from 'vue-i18n' -import type { RouteLocationPathRaw } from 'vue-router' -import type { Strategies } from '#internal-i18n-types' -import type { CommonComposableOptions } from '../../utils' -import type { CompatRoute } from '../../types' - -function split(str: string, index: number) { - const result = [str.slice(0, index), str.slice(index)] - return result -} - -/** - * NOTE: - * Nuxt route uses a proxy with getters for performance reasons (https://github.com/nuxt/nuxt/pull/21957). - * Spreading will result in an empty object, so we make a copy of the route by accessing each getter property by name. - */ -export function routeToObject(route: CompatRoute) { - const { fullPath, query, hash, name, path, params, meta, redirectedFrom, matched } = route - return { - fullPath, - params, - query, - hash, - name, - path, - meta, - matched, - redirectedFrom - } -} - -/** - * NOTE: - * vue-router v4.x `router.resolve` for a non exists path will output a warning. - * `router.hasRoute`, which checks for the route can only be a named route. - * When using the `prefix` strategy, the path specified by `localePath` is specified as a path not prefixed with a locale. - * This will cause vue-router to issue a warning, so we can work-around by using `router.options.routes`. - */ -export function resolve( - { router }: CommonComposableOptions, - route: RouteLocationPathRaw, - strategy: Strategies, - locale: Locale -) { - if (strategy !== 'prefix') { - return router.resolve(route) - } - - // if (isArray(route.matched) && route.matched.length > 0) { - // return route.matched[0] - // } - - const [rootSlash, restPath] = split(route.path, 1) - const targetPath = `${rootSlash}${locale}${restPath === '' ? restPath : `/${restPath}`}` - const _route = router.options?.routes?.find(r => r.path === targetPath) - - if (_route == null) { - return route - } - - const _resolvableRoute = assign({}, route, _route) - _resolvableRoute.path = targetPath - return router.resolve(_resolvableRoute) -} diff --git a/src/runtime/routing/extends/router.ts b/src/runtime/routing/extends/router.ts deleted file mode 100644 index 6453893b0..000000000 --- a/src/runtime/routing/extends/router.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { getLocalesRegex, getRouteName } from '../utils' -import { localeCodes } from '#build/i18n.options.mjs' -import { useRuntimeConfig } from '#imports' - -import type { CompatRoute } from '../../types' - -const localesPattern = `(${localeCodes.join('|')})` -const regexpPath = getLocalesRegex(localeCodes) - -export function createLocaleFromRouteGetter() { - const { routesNameSeparator, defaultLocaleRouteNameSuffix } = useRuntimeConfig().public.i18n - const defaultSuffixPattern = `(?:${routesNameSeparator}${defaultLocaleRouteNameSuffix})?` - const regexpName = new RegExp(`${routesNameSeparator}${localesPattern}${defaultSuffixPattern}$`, 'i') - - /** - * extract locale code from route name or path - */ - const getLocaleFromRoute = (route: string | CompatRoute) => { - let matches: RegExpMatchArray | null = null - - if (typeof route === 'string') { - matches = route.match(regexpPath) - return matches?.[1] ?? '' - } - - if (route.name) { - // extract from route name - matches = getRouteName(route.name).match(regexpName) - } else if (route.path) { - // extract from path - matches = route.path.match(regexpPath) - } - - return matches?.[1] ?? '' - } - - return getLocaleFromRoute -} diff --git a/src/runtime/routing/compatibles/head.ts b/src/runtime/routing/head.ts similarity index 96% rename from src/runtime/routing/compatibles/head.ts rename to src/runtime/routing/head.ts index 62841551f..9787a437b 100644 --- a/src/runtime/routing/compatibles/head.ts +++ b/src/runtime/routing/head.ts @@ -2,13 +2,13 @@ import { joinURL } from 'ufo' import { isArray, isObject } from '@intlify/shared' import { unref, useNuxtApp, useRuntimeConfig } from '#imports' -import { getNormalizedLocales } from '../utils' +import { getNormalizedLocales } from './utils' import { getRouteBaseName, localeRoute, switchLocalePath } from './routing' -import { getComposer } from '../../compatibility' +import { getComposer } from '../compatibility' import type { I18n } from 'vue-i18n' import type { I18nHeadMetaInfo, MetaAttrs, LocaleObject, I18nHeadOptions } from '#internal-i18n-types' -import type { CommonComposableOptions } from '../../utils' +import type { CommonComposableOptions } from '../utils' /** * Returns localized head properties for locale-related aspects. @@ -45,7 +45,7 @@ export function localeHead( const locale = unref(nuxtApp.$i18n.locale) const locales = unref(nuxtApp.$i18n.locales) - const currentLocale = getNormalizedLocales(locales).find(l => l.code === locale) || { + const currentLocale: LocaleObject = getNormalizedLocales(locales).find(l => l.code === locale) || { code: locale } const currentLanguage = currentLocale.language diff --git a/src/runtime/routing/extends/i18n.ts b/src/runtime/routing/i18n.ts similarity index 97% rename from src/runtime/routing/extends/i18n.ts rename to src/runtime/routing/i18n.ts index f23c38320..0b659f227 100644 --- a/src/runtime/routing/extends/i18n.ts +++ b/src/runtime/routing/i18n.ts @@ -1,5 +1,5 @@ import { effectScope } from '#imports' -import { isVueI18n, getComposer } from '../../compatibility' +import { isVueI18n, getComposer } from '../compatibility' import type { NuxtApp } from 'nuxt/app' import type { Composer, ComposerExtender, ExportedGlobalComposer, I18n, VueI18n, VueI18nExtender } from 'vue-i18n' diff --git a/src/runtime/routing/compatibles/routing.ts b/src/runtime/routing/routing.ts similarity index 80% rename from src/runtime/routing/compatibles/routing.ts rename to src/runtime/routing/routing.ts index 015f78a11..b341c8947 100644 --- a/src/runtime/routing/compatibles/routing.ts +++ b/src/runtime/routing/routing.ts @@ -4,16 +4,15 @@ import { hasProtocol, parsePath, parseQuery, withTrailingSlash, withoutTrailingS import { DEFAULT_DYNAMIC_PARAMS_KEY } from '#build/i18n.options.mjs' import { unref } from '#imports' -import { getI18nTarget } from '../../compatibility' -import { resolve, routeToObject } from './utils' -import { getLocaleRouteName, getRouteName } from '../utils' -import { extendPrefixable, extendSwitchLocalePathIntercepter, type CommonComposableOptions } from '../../utils' +import { getI18nTarget } from '../compatibility' +import { getLocaleRouteName, getRouteName } from './utils' +import { extendPrefixable, extendSwitchLocalePathIntercepter, type CommonComposableOptions } from '../utils' import type { Strategies, PrefixableOptions } from '#internal-i18n-types' import type { Locale } from 'vue-i18n' import type { RouteLocation, RouteLocationRaw, Router, RouteLocationPathRaw, RouteLocationNamedRaw } from 'vue-router' import type { I18nPublicRuntimeConfig } from '#internal-i18n-types' -import type { CompatRoute } from '../../types' +import type { CompatRoute } from '../types' const RESOLVED_PREFIXED = new Set(['prefix_and_default', 'prefix_except_default']) @@ -241,3 +240,62 @@ export function switchLocalePath(common: CommonComposableOptions, locale: Locale // custom locale path with interceptor return switchLocalePathIntercepter(path, locale) } + +function split(str: string, index: number) { + const result = [str.slice(0, index), str.slice(index)] + return result +} + +/** + * NOTE: + * Nuxt route uses a proxy with getters for performance reasons (https://github.com/nuxt/nuxt/pull/21957). + * Spreading will result in an empty object, so we make a copy of the route by accessing each getter property by name. + */ +export function routeToObject(route: CompatRoute) { + const { fullPath, query, hash, name, path, params, meta, redirectedFrom, matched } = route + return { + fullPath, + params, + query, + hash, + name, + path, + meta, + matched, + redirectedFrom + } +} + +/** + * NOTE: + * vue-router v4.x `router.resolve` for a non exists path will output a warning. + * `router.hasRoute`, which checks for the route can only be a named route. + * When using the `prefix` strategy, the path specified by `localePath` is specified as a path not prefixed with a locale. + * This will cause vue-router to issue a warning, so we can work-around by using `router.options.routes`. + */ +export function resolve( + { router }: CommonComposableOptions, + route: RouteLocationPathRaw, + strategy: Strategies, + locale: Locale +) { + if (strategy !== 'prefix') { + return router.resolve(route) + } + + // if (isArray(route.matched) && route.matched.length > 0) { + // return route.matched[0] + // } + + const [rootSlash, restPath] = split(route.path, 1) + const targetPath = `${rootSlash}${locale}${restPath === '' ? restPath : `/${restPath}`}` + const _route = router.options?.routes?.find(r => r.path === targetPath) + + if (_route == null) { + return route + } + + const _resolvableRoute = assign({}, route, _route) + _resolvableRoute.path = targetPath + return router.resolve(_resolvableRoute) +} diff --git a/src/runtime/routing/utils.ts b/src/runtime/routing/utils.ts index a70107c8a..7873be7bf 100644 --- a/src/runtime/routing/utils.ts +++ b/src/runtime/routing/utils.ts @@ -1,34 +1,18 @@ -import { isString, isSymbol, isFunction } from '@intlify/shared' +import { isFunction } from '@intlify/shared' +import { localeCodes } from '#build/i18n.options.mjs' +import { useRuntimeConfig } from '#app' -import type { LocaleObject, Strategies, BaseUrlResolveHandler } from '#internal-i18n-types' +import type { LocaleObject, BaseUrlResolveHandler, I18nPublicRuntimeConfig } from '#internal-i18n-types' import type { Locale } from 'vue-i18n' - -export const inBrowser = typeof window !== 'undefined' +import type { CompatRoute } from '../types' export function getNormalizedLocales(locales: Locale[] | LocaleObject[]): LocaleObject[] { - locales = locales || [] - const normalized: LocaleObject[] = [] - for (const locale of locales) { - if (isString(locale)) { - normalized.push({ code: locale }) - } else { - normalized.push(locale) - } - } - return normalized -} - -export function adjustRoutePathForTrailingSlash( - pagePath: string, - trailingSlash: boolean, - isChildWithRelativePath: boolean -) { - return pagePath.replace(/\/+$/, '') + (trailingSlash ? '/' : '') || (isChildWithRelativePath ? '' : '/') + return locales.map(x => (typeof x === 'string' ? { code: x } : x)) } export function getRouteName(routeName?: string | symbol | null) { - if (isString(routeName)) return routeName - if (isSymbol(routeName)) return routeName.toString() + if (typeof routeName === 'string') return routeName + if (routeName != null) return routeName.toString() return '(null)' } @@ -41,13 +25,7 @@ export function getLocaleRouteName( routesNameSeparator, defaultLocaleRouteNameSuffix, differentDomains - }: { - defaultLocale: string - strategy: Strategies - routesNameSeparator: string - defaultLocaleRouteNameSuffix: string - differentDomains: boolean - } + }: I18nPublicRuntimeConfig ) { const localizedRoutes = strategy !== 'no_prefix' || differentDomains let name = getRouteName(routeName) + (localizedRoutes ? routesNameSeparator + locale : '') @@ -79,7 +57,7 @@ export function resolveBaseUrl(baseUrl: string | BaseUrlResol * @remarks * This type is used by {@link FindBrowserLocaleOptions#sorter | sorter} in {@link findBrowserLocale} function */ -export interface BrowserLocale { +interface BrowserLocale { /** * The locale code, such as BCP 47 (e.g `en-US`), or `ja` */ @@ -99,7 +77,7 @@ export interface BrowserLocale { * @remarks * This type is used by {@link BrowserLocaleMatcher} first argument */ -export type TargetLocale = Required> +type TargetLocale = { code: string; language: string } /** * The browser locale matcher @@ -112,15 +90,8 @@ export type TargetLocale = Required> * * @returns The matched {@link BrowserLocale | locale info} */ -export type BrowserLocaleMatcher = (locales: TargetLocale[], browserLocales: string[]) => BrowserLocale[] - -/** - * The options for {@link findBrowserLocale} function - */ -export interface FindBrowserLocaleOptions { - matcher?: BrowserLocaleMatcher - comparer?: (a: BrowserLocale, b: BrowserLocale) => number -} +type BrowserLocaleMatcher = (locales: TargetLocale[], browserLocales: string[]) => BrowserLocale[] +type LocaleComparer = (a: BrowserLocale, b: BrowserLocale) => number function matchBrowserLocale(locales: TargetLocale[], browserLocales: string[]): BrowserLocale[] { const matchedLocales = [] as BrowserLocale[] @@ -148,11 +119,6 @@ function matchBrowserLocale(locales: TargetLocale[], browserLocales: string[]): return matchedLocales } -/** - * The default browser locale matcher - */ -export const DefaultBrowserLocaleMatcher = matchBrowserLocale - function compareBrowserLocale(a: BrowserLocale, b: BrowserLocale): number { if (a.score === b.score) { // if scores are equal then pick more specific (longer) code. @@ -161,15 +127,10 @@ function compareBrowserLocale(a: BrowserLocale, b: BrowserLocale): number { return b.score - a.score } -/** - * The default browser locale comparer - */ -export const DefaultBrowserLocaleComparer = compareBrowserLocale - /** * Find the browser locale * - * @param locales - The target {@link LocaleObject | locale} list + * @param locales - The target {@link LocaleObject} list * @param browserLocales - The locale code list that is used in browser * @param options - The options for {@link findBrowserLocale} function * @@ -178,7 +139,10 @@ export const DefaultBrowserLocaleComparer = compareBrowserLocale export function findBrowserLocale( locales: LocaleObject[], browserLocales: string[], - { matcher = DefaultBrowserLocaleMatcher, comparer = DefaultBrowserLocaleComparer }: FindBrowserLocaleOptions = {} + { + matcher = matchBrowserLocale, + comparer = compareBrowserLocale + }: { matcher?: BrowserLocaleMatcher; comparer?: LocaleComparer } = {} ): string { const normalizedLocales = [] for (const l of locales) { @@ -190,14 +154,51 @@ export function findBrowserLocale( // finding! const matchedLocales = matcher(normalizedLocales, browserLocales) - // sort! + if (matchedLocales.length === 0) { + return '' + } + if (matchedLocales.length > 1) { + // sort! matchedLocales.sort(comparer) } - return matchedLocales.length ? matchedLocales[0].code : '' + return matchedLocales[0].code } export function getLocalesRegex(localeCodes: string[]) { return new RegExp(`^/(${localeCodes.join('|')})(?:/|$)`, 'i') } + +const localesPattern = `(${localeCodes.join('|')})` +const regexpPath = getLocalesRegex(localeCodes) + +export function createLocaleFromRouteGetter() { + const { routesNameSeparator, defaultLocaleRouteNameSuffix } = useRuntimeConfig().public.i18n + const defaultSuffixPattern = `(?:${routesNameSeparator}${defaultLocaleRouteNameSuffix})?` + const regexpName = new RegExp(`${routesNameSeparator}${localesPattern}${defaultSuffixPattern}$`, 'i') + + /** + * extract locale code from route name or path + */ + const getLocaleFromRoute = (route: string | CompatRoute) => { + let matches: RegExpMatchArray | null = null + + if (typeof route === 'string') { + matches = route.match(regexpPath) + return matches?.[1] ?? '' + } + + if (route.name) { + // extract from route name + matches = getRouteName(route.name).match(regexpName) + } else if (route.path) { + // extract from path + matches = route.path.match(regexpPath) + } + + return matches?.[1] ?? '' + } + + return getLocaleFromRoute +} diff --git a/src/runtime/utils.ts b/src/runtime/utils.ts index 1beee653c..a98be5725 100644 --- a/src/runtime/utils.ts +++ b/src/runtime/utils.ts @@ -11,9 +11,8 @@ import { getHost } from './internal' import { loadLocale, makeFallbackLocaleCodes } from './messages' -import { localePath, switchLocalePath, DefaultPrefixable } from './routing/compatibles/routing' +import { localePath, switchLocalePath, DefaultPrefixable } from './routing/routing' import { createLogger } from 'virtual:nuxt-i18n-logger' -import { createLocaleFromRouteGetter } from './routing/extends/router' import { unref } from 'vue' import type { I18n, Locale } from 'vue-i18n' @@ -31,6 +30,7 @@ import type { LocaleObject } from '#internal-i18n-types' import type { CompatRoute } from './types' +import { createLocaleFromRouteGetter } from './routing/utils' /** * Common options used internally by composable functions, these diff --git a/test/mocks/i18n.options.ts b/test/mocks/i18n.options.ts new file mode 100644 index 000000000..72ceb86e1 --- /dev/null +++ b/test/mocks/i18n.options.ts @@ -0,0 +1 @@ +export const localeCodes = [] diff --git a/test/routing-utils.test.ts b/test/routing-utils.test.ts index a6c606e6f..df6b20be5 100644 --- a/test/routing-utils.test.ts +++ b/test/routing-utils.test.ts @@ -1,50 +1,6 @@ import { describe, it, assert, test } from 'vitest' import * as utils from '../src/runtime/routing/utils' -describe('adjustRouteDefinitionForTrailingSlash', function () { - describe('pagePath: /foo/bar', function () { - describe('trailingSlash: false, isChildWithRelativePath: true', function () { - it('should be trailed with slash: /foo/bar/', function () { - assert.equal(utils.adjustRoutePathForTrailingSlash('/foo/bar', true, true), '/foo/bar/') - }) - }) - - describe('trailingSlash: false, isChildWithRelativePath: true', function () { - it('should not be trailed with slash: /foo/bar/', function () { - assert.equal(utils.adjustRoutePathForTrailingSlash('/foo/bar', false, true), '/foo/bar') - }) - }) - - describe('trailingSlash: false, isChildWithRelativePath: false', function () { - it('should be trailed with slash: /foo/bar/', function () { - assert.equal(utils.adjustRoutePathForTrailingSlash('/foo/bar', true, false), '/foo/bar/') - }) - }) - - describe('trailingSlash: false, isChildWithRelativePath: false', function () { - it('should not be trailed with slash: /foo/bar/', function () { - assert.equal(utils.adjustRoutePathForTrailingSlash('/foo/bar', false, false), '/foo/bar') - }) - }) - }) - - describe('pagePath: /', function () { - describe('trailingSlash: false, isChildWithRelativePath: true', function () { - it('should not be trailed with slash: empty', function () { - assert.equal(utils.adjustRoutePathForTrailingSlash('/', false, true), '') - }) - }) - }) - - describe('pagePath: empty', function () { - describe('trailingSlash: true, isChildWithRelativePath: true', function () { - it('should not be trailed with slash: /', function () { - assert.equal(utils.adjustRoutePathForTrailingSlash('', true, true), '/') - }) - }) - }) -}) - describe('getLocaleRouteName', () => { describe('strategy: prefix_and_default', () => { it('should be `route1___en___default`', () => { diff --git a/vitest.config.test.ts b/vitest.config.test.ts new file mode 100644 index 000000000..884a6cae3 --- /dev/null +++ b/vitest.config.test.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vitest/config' +import vitestConfig from './vitest.config' +import { resolve } from 'pathe' + +export default defineConfig({ + ...vitestConfig, + test: { + ...vitestConfig.test, + alias: { + ...(vitestConfig.test?.alias ?? {}), + '#build/i18n.options.mjs': resolve('./test/mocks/i18n.options.ts'), + '#app': 'nuxt' + } + } +})