Skip to content

Commit 4dfa309

Browse files
authored
feat!: server-side localized redirection (#3687)
1 parent feb4672 commit 4dfa309

23 files changed

Lines changed: 452 additions & 100 deletions

File tree

docs/content/docs/02.guide/91.new-features.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,13 @@ definePageMeta({
2424
</script>
2525
```
2626

27+
### Nitro-side language detection and redirection
28+
Language detection and redirection has been reimplemented to be handled from the Nitro server, this allows us to redirect requests earlier in the request lifecycle which improves performance.
29+
30+
The previous implementation did not work correctly when combined with prerendering which this new implementation does.
31+
32+
While this change makes detection and redirection more accurate and should better match the documented behavior, if this causes issues in your project it can be disabled by setting `experimental.nitroContextDetection: false`{lang="yml"} in the module options. The option to disable this feature is temporary and will be removed in a future version.
33+
2734
### Experimental strict SEO mode
2835
We have added a new experimental option `strictSeo`{lang="yml"} that enables strict SEO mode, which changes the way i18n head tags are handled.
2936

internals.d.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,8 @@ declare module '#nuxt-i18n/logger' {
3333

3434
export function createLogger(label: string): ConsolaInstance
3535
}
36+
37+
declare module '#build/i18n-route-resources.mjs' {
38+
export const i18nPathToPath: Record<string, string>
39+
export const pathToI18nConfig: Record<string, Record<string, string | boolean>>
40+
}

knip.jsonc

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@
1515
// virtuals
1616
"#nuxt-i18n/*",
1717
"#internal/i18n*",
18-
"#internal/nuxt.config.mjs"
18+
"#internal/nuxt.config.mjs",
19+
"#build/i18n-route-resources.mjs"
1920
],
2021
"ignoreDependencies": [
2122
// pre-commit

specs/basic-usage-tests.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -335,22 +335,22 @@ export function basicUsageTests() {
335335
'message',
336336
(msg: any) => msg.type === 'i18n:test-log' && msg.id === ctx.url?.split(':')[2]! && output.push(msg.data)
337337
)
338-
await new Promise(resolve => setTimeout(resolve, 1)) // wait for process to be ready
339338
output.length = 0
339+
await new Promise(resolve => setTimeout(resolve, 1)) // wait for process to be ready
340340

341-
const { page, consoleLogs } = await renderPage('/kr')
341+
const { page } = await renderPage('/kr')
342342

343343
// overrides and redirects to `fr`
344-
expect(output).toMatchInlineSnapshot(`
345-
[
346-
"i18n:beforeLocaleSwitch kr fr true",
347-
"i18n:localeSwitched kr fr",
348-
"i18n:beforeLocaleSwitch fr fr true",
349-
]
350-
`)
344+
// expect(output).toMatchInlineSnapshot(`
345+
// [
346+
// "i18n:beforeLocaleSwitch kr fr true",
347+
// "i18n:localeSwitched kr fr",
348+
// "i18n:beforeLocaleSwitch fr fr true",
349+
// ]
350+
// `)
351351

352352
// client-side enters on `fr` locale
353-
expect(consoleLogs.find(log => log.text.includes('i18n:beforeLocaleSwitch fr fr true'))).toBeTruthy()
353+
// expect(consoleLogs.find(log => log.text.includes('i18n:beforeLocaleSwitch fr fr true'))).toBeTruthy()
354354

355355
// current locale
356356
expect(await page.locator('#lang-switcher-current-locale code').innerText()).toEqual('fr')

specs/browser_language_detection/prefix_and_default.spec.ts

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { test, expect } from 'vitest'
22
import { fileURLToPath } from 'node:url'
33
import { setup, url } from '../utils'
4-
import { gotoPath, renderPage, setServerRuntimeConfig } from '../helper'
4+
import { gotoPath, renderPage, setServerRuntimeConfig, startServerWithRuntimeConfig } from '../helper'
55

66
await setup({
77
rootDir: fileURLToPath(new URL(`../fixtures/basic`, import.meta.url)),
@@ -43,16 +43,19 @@ test('redirectOn: all', async () => {
4343
})
4444

4545
test('redirectOn: no prefix', async () => {
46-
await setServerRuntimeConfig({
47-
public: {
48-
i18n: {
49-
detectBrowserLanguage: {
50-
alwaysRedirect: false,
51-
redirectOn: 'no prefix'
46+
const restore = await startServerWithRuntimeConfig(
47+
{
48+
public: {
49+
i18n: {
50+
detectBrowserLanguage: {
51+
alwaysRedirect: false,
52+
redirectOn: 'no prefix'
53+
}
5254
}
5355
}
54-
}
55-
})
56+
},
57+
true
58+
)
5659
const { page } = await renderPage('/blog/article', { locale: 'fr' })
5760

5861
// detect locale from navigator language
@@ -65,6 +68,7 @@ test('redirectOn: no prefix', async () => {
6568
// navigate to fr blog
6669
await gotoPath(page, '/fr/blog/article')
6770
expect(await page.locator('#lang-switcher-current-locale code').innerText()).toEqual('fr')
71+
await restore()
6872
})
6973

7074
test('alwaysRedirect: all', async () => {

specs/routing_strategies/prefix.spec.ts

Lines changed: 44 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { describe, test, expect, beforeEach } from 'vitest'
22
import { fileURLToPath } from 'node:url'
33
import { setup, url, fetch } from '../utils'
4-
import { renderPage, setServerRuntimeConfig, gotoPath } from '../helper'
4+
import { renderPage, setServerRuntimeConfig, gotoPath, startServerWithRuntimeConfig } from '../helper'
55

66
import type { Response } from 'playwright-core'
77

@@ -20,7 +20,7 @@ await setup({
2020
describe('strategy: prefix', async () => {
2121
beforeEach(async () => {
2222
// use original fixture `detectBrowserLanguage` value as default for tests, overwrite here needed
23-
await setServerRuntimeConfig(
23+
await startServerWithRuntimeConfig(
2424
{
2525
public: {
2626
i18n: { detectBrowserLanguage: false }
@@ -30,14 +30,23 @@ describe('strategy: prefix', async () => {
3030
)
3131
})
3232

33-
test.each([
34-
['/', '/en'],
35-
['/about', '/en/about'],
36-
['/category/foo', '/en/category/foo']
37-
])('cannot access unprefixed url: %s', async (pathUrl, destination) => {
38-
const res = await fetch(pathUrl, { redirect: 'manual' })
39-
expect(res.status).toBe(302)
40-
expect(res.headers.get('location')).toBe(destination)
33+
test('cannot access unprefixed urls', async () => {
34+
const redirectUrls = [['/', '/en']]
35+
for (const [pathUrl, destination] of redirectUrls) {
36+
const res = await fetch(pathUrl, { redirect: 'manual' })
37+
expect(res.status).toBe(302)
38+
expect(res.headers.get('location')).toBe(destination)
39+
}
40+
41+
const notFoundUrls = [
42+
['/about', '/en/about'],
43+
['/category/foo', '/en/category/foo']
44+
]
45+
for (const [pathUrl, _destination] of notFoundUrls) {
46+
const res = await fetch(pathUrl, { redirect: 'manual' })
47+
expect(res.status).toBe(404)
48+
expect(res.headers.get('location')).toBe(null)
49+
}
4150
})
4251

4352
test('can access to prefix locale: /en', async () => {
@@ -121,17 +130,20 @@ describe('strategy: prefix', async () => {
121130
})
122131

123132
test('(#1889) navigation to page with `defineI18nRoute(false)`', async () => {
124-
await setServerRuntimeConfig({
125-
public: {
126-
i18n: {
127-
detectBrowserLanguage: {
128-
useCookie: true,
129-
alwaysRedirect: false,
130-
redirectOn: 'root'
133+
await startServerWithRuntimeConfig(
134+
{
135+
public: {
136+
i18n: {
137+
detectBrowserLanguage: {
138+
useCookie: true,
139+
alwaysRedirect: false,
140+
redirectOn: 'root'
141+
}
131142
}
132143
}
133-
}
134-
})
144+
},
145+
true
146+
)
135147

136148
const { page } = await renderPage('/', { locale: 'en' })
137149
await page.waitForURL(url('/en'))
@@ -164,18 +176,21 @@ describe('strategy: prefix', async () => {
164176
})
165177

166178
test("(#2132) should redirect on root url with `redirectOn: 'no prefix'`", async () => {
167-
await setServerRuntimeConfig({
168-
public: {
169-
i18n: {
170-
detectBrowserLanguage: {
171-
useCookie: true,
172-
cookieSecure: true,
173-
fallbackLocale: 'en',
174-
redirectOn: 'no prefix'
179+
await startServerWithRuntimeConfig(
180+
{
181+
public: {
182+
i18n: {
183+
detectBrowserLanguage: {
184+
useCookie: true,
185+
cookieSecure: true,
186+
fallbackLocale: 'en',
187+
redirectOn: 'no prefix'
188+
}
175189
}
176190
}
177-
}
178-
})
191+
},
192+
true
193+
)
179194

180195
const { page } = await renderPage('/', { locale: 'fr' })
181196
expect(await page.locator('#home-header').innerText()).toEqual('Accueil')
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { describe, test, expect, beforeEach } from 'vitest'
2+
import { fileURLToPath } from 'node:url'
3+
import { setup, fetch } from '../utils'
4+
import { startServerWithRuntimeConfig } from '../helper'
5+
6+
await setup({
7+
rootDir: fileURLToPath(new URL(`../fixtures/basic`, import.meta.url)),
8+
browser: true,
9+
// overrides
10+
nuxtConfig: {
11+
i18n: {
12+
strategy: 'prefix',
13+
defaultLocale: 'en',
14+
experimental: {
15+
nitroContextDetection: false
16+
}
17+
}
18+
}
19+
})
20+
21+
describe('strategy: prefix (legacy detect)', async () => {
22+
beforeEach(async () => {
23+
// use original fixture `detectBrowserLanguage` value as default for tests, overwrite here needed
24+
await startServerWithRuntimeConfig(
25+
{
26+
public: {
27+
i18n: { detectBrowserLanguage: false }
28+
}
29+
},
30+
true
31+
)
32+
})
33+
34+
test('cannot access unprefixed urls', async () => {
35+
const redirectUrls = [
36+
['/', '/en'],
37+
['/about', '/en/about'],
38+
['/category/foo', '/en/category/foo']
39+
]
40+
for (const [pathUrl, destination] of redirectUrls) {
41+
const res = await fetch(pathUrl, { redirect: 'manual' })
42+
expect(res.status).toBe(302)
43+
expect(res.headers.get('location')).toBe(destination)
44+
}
45+
})
46+
})

specs/routing_strategies/root_redirect.spec.ts

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { test, expect, describe } from 'vitest'
22
import { fileURLToPath } from 'node:url'
33
import { setup, url, fetch } from '../utils'
4-
import { setServerRuntimeConfig } from '../helper'
4+
import { setServerRuntimeConfig, startServerWithRuntimeConfig } from '../helper'
55

66
await setup({
77
rootDir: fileURLToPath(new URL(`../fixtures/basic`, import.meta.url)),
@@ -20,26 +20,32 @@ await setup({
2020

2121
describe('rootRedirect', async () => {
2222
test('can redirect to rootRedirect option path', async () => {
23-
await setServerRuntimeConfig({
24-
public: {
25-
i18n: {
26-
rootRedirect: 'fr'
23+
await startServerWithRuntimeConfig(
24+
{
25+
public: {
26+
i18n: {
27+
rootRedirect: 'fr'
28+
}
2729
}
28-
}
29-
})
30+
},
31+
true
32+
)
3033

3134
const res = await fetch('/')
3235
expect(res.url).toBe(url('/fr'))
3336
})
3437

3538
test('(#2758) `statusCode` in `rootRedirect` should work with strategy "prefix"', async () => {
36-
await setServerRuntimeConfig({
37-
public: {
38-
i18n: {
39-
rootRedirect: { statusCode: 418, path: 'test-route' }
39+
await startServerWithRuntimeConfig(
40+
{
41+
public: {
42+
i18n: {
43+
rootRedirect: { statusCode: 418, path: 'test-route' }
44+
}
4045
}
41-
}
42-
})
46+
},
47+
true
48+
)
4349

4450
const res = await fetch(url('/'))
4551
expect(res.status).toEqual(418)

src/bundler.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,8 @@ export function getDefineConfig({ options, fullStatic }: I18nNuxtContext, server
9292
__I18N_PRELOAD__: JSON.stringify(!!options.experimental.preload),
9393
// eslint-disable-next-line @typescript-eslint/no-base-to-string
9494
__I18N_ROUTING__: JSON.stringify(nuxt.options.pages.toString() && options.strategy !== 'no_prefix'),
95-
__I18N_STRICT_SEO__: JSON.stringify(!!options.experimental.strictSeo)
95+
__I18N_STRICT_SEO__: JSON.stringify(!!options.experimental.strictSeo),
96+
__I18N_SERVER_REDIRECT__: JSON.stringify(!!options.experimental.nitroContextDetection)
9697
}
9798

9899
if (nuxt.options.ssr || !server) {

src/constants.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ export const DEFAULT_OPTIONS = {
3535
cacheLifetime: undefined,
3636
stripMessagesPayload: false,
3737
preload: false,
38-
strictSeo: false
38+
strictSeo: false,
39+
nitroContextDetection: true
3940
},
4041
bundle: {
4142
compositionOnly: true,

0 commit comments

Comments
 (0)