Skip to content

Commit b2d1d87

Browse files
authored
Add initial changes for i18n support (#17370)
This adds the initial changes outlined in the [i18n routing RFC](#17078). This currently treats the locale prefix on routes similar to how the basePath is treated in that the config doesn't require any changes to your pages directory and is automatically stripped/added based on the detected locale that should be used. Currently redirecting occurs on the `/` route if a locale is detected regardless of if an optional catch-all route would match the `/` route or not we may want to investigate whether we want to disable this redirection automatically if an `/index.js` file isn't present at root of the pages directory. TODO: - [x] ensure locale detection/populating works in serverless mode correctly - [x] add tests for locale handling in different modes, fallback/getStaticProps/getServerSideProps To be continued in fall-up PRs - [ ] add tests for revalidate, auto-export, basePath + i18n - [ ] add mapping of domains with locales - [ ] investigate detecting locale against non-index routes and populating the locale in a cookie x-ref: #17110
1 parent 6588108 commit b2d1d87

36 files changed

+1147
-42
lines changed

packages/next/build/entries.ts

+3
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,9 @@ export function createEntrypoints(
9797
loadedEnvFiles: Buffer.from(JSON.stringify(loadedEnvFiles)).toString(
9898
'base64'
9999
),
100+
i18n: config.experimental.i18n
101+
? JSON.stringify(config.experimental.i18n)
102+
: '',
100103
}
101104

102105
Object.keys(pages).forEach((page) => {

packages/next/build/index.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -563,7 +563,9 @@ export default async function build(
563563
let workerResult = await staticCheckWorkers.isPageStatic(
564564
page,
565565
serverBundle,
566-
runtimeEnvConfig
566+
runtimeEnvConfig,
567+
config.experimental.i18n?.locales,
568+
config.experimental.i18n?.defaultLocale
567569
)
568570

569571
if (workerResult.isHybridAmp) {

packages/next/build/utils.ts

+36-6
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { denormalizePagePath } from '../next-server/server/normalize-page-path'
2727
import { BuildManifest } from '../next-server/server/get-page-files'
2828
import { removePathTrailingSlash } from '../client/normalize-trailing-slash'
2929
import type { UnwrapPromise } from '../lib/coalesced-function'
30+
import { normalizeLocalePath } from '../next-server/lib/i18n/normalize-locale-path'
3031

3132
const fileGzipStats: { [k: string]: Promise<number> } = {}
3233
const fsStatGzip = (file: string) => {
@@ -530,7 +531,9 @@ export async function getJsPageSizeInKb(
530531

531532
export async function buildStaticPaths(
532533
page: string,
533-
getStaticPaths: GetStaticPaths
534+
getStaticPaths: GetStaticPaths,
535+
locales?: string[],
536+
defaultLocale?: string
534537
): Promise<
535538
Omit<UnwrapPromise<ReturnType<GetStaticPaths>>, 'paths'> & { paths: string[] }
536539
> {
@@ -595,7 +598,17 @@ export async function buildStaticPaths(
595598
// route.
596599
if (typeof entry === 'string') {
597600
entry = removePathTrailingSlash(entry)
598-
const result = _routeMatcher(entry)
601+
602+
const localePathResult = normalizeLocalePath(entry, locales)
603+
let cleanedEntry = entry
604+
605+
if (localePathResult.detectedLocale) {
606+
cleanedEntry = entry.substr(localePathResult.detectedLocale.length + 1)
607+
} else if (defaultLocale) {
608+
entry = `/${defaultLocale}${entry}`
609+
}
610+
611+
const result = _routeMatcher(cleanedEntry)
599612
if (!result) {
600613
throw new Error(
601614
`The provided path \`${entry}\` does not match the page: \`${page}\`.`
@@ -607,7 +620,10 @@ export async function buildStaticPaths(
607620
// For the object-provided path, we must make sure it specifies all
608621
// required keys.
609622
else {
610-
const invalidKeys = Object.keys(entry).filter((key) => key !== 'params')
623+
const invalidKeys = Object.keys(entry).filter(
624+
(key) => key !== 'params' && key !== 'locale'
625+
)
626+
611627
if (invalidKeys.length) {
612628
throw new Error(
613629
`Additional keys were returned from \`getStaticPaths\` in page "${page}". ` +
@@ -657,7 +673,14 @@ export async function buildStaticPaths(
657673
.replace(/(?!^)\/$/, '')
658674
})
659675

660-
prerenderPaths?.add(builtPage)
676+
if (entry.locale && !locales?.includes(entry.locale)) {
677+
throw new Error(
678+
`Invalid locale returned from getStaticPaths for ${page}, the locale ${entry.locale} is not specified in next.config.js`
679+
)
680+
}
681+
const curLocale = entry.locale || defaultLocale || ''
682+
683+
prerenderPaths?.add(`${curLocale ? `/${curLocale}` : ''}${builtPage}`)
661684
}
662685
})
663686

@@ -667,7 +690,9 @@ export async function buildStaticPaths(
667690
export async function isPageStatic(
668691
page: string,
669692
serverBundle: string,
670-
runtimeEnvConfig: any
693+
runtimeEnvConfig: any,
694+
locales?: string[],
695+
defaultLocale?: string
671696
): Promise<{
672697
isStatic?: boolean
673698
isAmpOnly?: boolean
@@ -755,7 +780,12 @@ export async function isPageStatic(
755780
;({
756781
paths: prerenderRoutes,
757782
fallback: prerenderFallback,
758-
} = await buildStaticPaths(page, mod.getStaticPaths))
783+
} = await buildStaticPaths(
784+
page,
785+
mod.getStaticPaths,
786+
locales,
787+
defaultLocale
788+
))
759789
}
760790

761791
const config = mod.config || {}

packages/next/build/webpack-config.ts

+3
Original file line numberDiff line numberDiff line change
@@ -986,6 +986,9 @@ export default async function getBaseWebpackConfig(
986986
),
987987
'process.env.__NEXT_ROUTER_BASEPATH': JSON.stringify(config.basePath),
988988
'process.env.__NEXT_HAS_REWRITES': JSON.stringify(hasRewrites),
989+
'process.env.__NEXT_i18n_SUPPORT': JSON.stringify(
990+
!!config.experimental.i18n
991+
),
989992
...(isServer
990993
? {
991994
// Fix bad-actors in the npm ecosystem (e.g. `node-formidable`)

packages/next/build/webpack/loaders/next-serverless-loader.ts

+64
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export type ServerlessLoaderQuery = {
2828
runtimeConfig: string
2929
previewProps: string
3030
loadedEnvFiles: string
31+
i18n: string
3132
}
3233

3334
const vercelHeader = 'x-vercel-id'
@@ -49,6 +50,7 @@ const nextServerlessLoader: loader.Loader = function () {
4950
runtimeConfig,
5051
previewProps,
5152
loadedEnvFiles,
53+
i18n,
5254
}: ServerlessLoaderQuery =
5355
typeof this.query === 'string' ? parse(this.query.substr(1)) : this.query
5456

@@ -66,6 +68,8 @@ const nextServerlessLoader: loader.Loader = function () {
6668
JSON.parse(previewProps) as __ApiPreviewProps
6769
)
6870

71+
const i18nEnabled = !!i18n
72+
6973
const defaultRouteRegex = pageIsDynamicRoute
7074
? `
7175
const defaultRouteRegex = getRouteRegex("${page}")
@@ -212,6 +216,58 @@ const nextServerlessLoader: loader.Loader = function () {
212216
`
213217
: ''
214218

219+
const handleLocale = i18nEnabled
220+
? `
221+
// get pathname from URL with basePath stripped for locale detection
222+
const i18n = ${i18n}
223+
const accept = require('@hapi/accept')
224+
const { detectLocaleCookie } = require('next/dist/next-server/lib/i18n/detect-locale-cookie')
225+
const { normalizeLocalePath } = require('next/dist/next-server/lib/i18n/normalize-locale-path')
226+
let detectedLocale = detectLocaleCookie(req, i18n.locales)
227+
228+
if (!detectedLocale) {
229+
detectedLocale = accept.language(
230+
req.headers['accept-language'],
231+
i18n.locales
232+
) || i18n.defaultLocale
233+
}
234+
235+
if (
236+
!nextStartMode &&
237+
i18n.localeDetection !== false &&
238+
denormalizePagePath(parsedUrl.pathname || '/') === '/'
239+
) {
240+
res.setHeader(
241+
'Location',
242+
formatUrl({
243+
// make sure to include any query values when redirecting
244+
...parsedUrl,
245+
pathname: \`/\${detectedLocale}\`,
246+
})
247+
)
248+
res.statusCode = 307
249+
res.end()
250+
}
251+
252+
// TODO: domain based locales (domain to locale mapping needs to be provided in next.config.js)
253+
const localePathResult = normalizeLocalePath(parsedUrl.pathname, i18n.locales)
254+
255+
if (localePathResult.detectedLocale) {
256+
detectedLocale = localePathResult.detectedLocale
257+
req.url = formatUrl({
258+
...parsedUrl,
259+
pathname: localePathResult.pathname,
260+
})
261+
parsedUrl.pathname = localePathResult.pathname
262+
}
263+
264+
detectedLocale = detectedLocale || i18n.defaultLocale
265+
`
266+
: `
267+
const i18n = {}
268+
const detectedLocale = undefined
269+
`
270+
215271
if (page.match(API_ROUTE)) {
216272
return `
217273
import initServer from 'next-plugin-loader?middleware=on-init-server!'
@@ -305,6 +361,7 @@ const nextServerlessLoader: loader.Loader = function () {
305361
const { renderToHTML } = require('next/dist/next-server/server/render');
306362
const { tryGetPreviewData } = require('next/dist/next-server/server/api-utils');
307363
const { denormalizePagePath } = require('next/dist/next-server/server/denormalize-page-path')
364+
const { setLazyProp, getCookieParser } = require('next/dist/next-server/server/api-utils')
308365
const {sendPayload} = require('next/dist/next-server/server/send-payload');
309366
const buildManifest = require('${buildManifest}');
310367
const reactLoadableManifest = require('${reactLoadableManifest}');
@@ -338,6 +395,9 @@ const nextServerlessLoader: loader.Loader = function () {
338395
export const _app = App
339396
export async function renderReqToHTML(req, res, renderMode, _renderOpts, _params) {
340397
const fromExport = renderMode === 'export' || renderMode === true;
398+
const nextStartMode = renderMode === 'passthrough'
399+
400+
setLazyProp({ req }, 'cookies', getCookieParser(req))
341401
342402
const options = {
343403
App,
@@ -388,12 +448,16 @@ const nextServerlessLoader: loader.Loader = function () {
388448
routeNoAssetPath = parsedUrl.pathname
389449
}
390450
451+
${handleLocale}
452+
391453
const renderOpts = Object.assign(
392454
{
393455
Component,
394456
pageConfig: config,
395457
nextExport: fromExport,
396458
isDataReq: _nextData,
459+
locale: detectedLocale,
460+
locales: i18n.locales,
397461
},
398462
options,
399463
)

packages/next/client/index.tsx

+27-1
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,11 @@ import type {
1111
AppProps,
1212
PrivateRouteInfo,
1313
} from '../next-server/lib/router/router'
14-
import { delBasePath, hasBasePath } from '../next-server/lib/router/router'
14+
import {
15+
delBasePath,
16+
hasBasePath,
17+
delLocale,
18+
} from '../next-server/lib/router/router'
1519
import { isDynamicRoute } from '../next-server/lib/router/utils/is-dynamic'
1620
import * as querystring from '../next-server/lib/router/utils/querystring'
1721
import * as envConfig from '../next-server/lib/runtime-config'
@@ -60,8 +64,11 @@ const {
6064
dynamicIds,
6165
isFallback,
6266
head: initialHeadData,
67+
locales,
6368
} = data
6469

70+
let { locale } = data
71+
6572
const prefix = assetPrefix || ''
6673

6774
// With dynamic assetPrefix it's no longer possible to set assetPrefix at the build time
@@ -80,6 +87,23 @@ if (hasBasePath(asPath)) {
8087
asPath = delBasePath(asPath)
8188
}
8289

90+
asPath = delLocale(asPath, locale)
91+
92+
if (process.env.__NEXT_i18n_SUPPORT) {
93+
const {
94+
normalizeLocalePath,
95+
} = require('../next-server/lib/i18n/normalize-locale-path')
96+
97+
if (isFallback && locales) {
98+
const localePathResult = normalizeLocalePath(asPath, locales)
99+
100+
if (localePathResult.detectedLocale) {
101+
asPath = asPath.substr(localePathResult.detectedLocale.length + 1)
102+
locale = localePathResult.detectedLocale
103+
}
104+
}
105+
}
106+
83107
type RegisterFn = (input: [string, () => void]) => void
84108

85109
const pageLoader = new PageLoader(buildId, prefix, page)
@@ -291,6 +315,8 @@ export default async (opts: { webpackHMR?: any } = {}) => {
291315
isFallback: Boolean(isFallback),
292316
subscription: ({ Component, styleSheets, props, err }, App) =>
293317
render({ App, Component, styleSheets, props, err }),
318+
locale,
319+
locales,
294320
})
295321

296322
// call init-client middleware

packages/next/client/link.tsx

+2-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import React, { Children } from 'react'
22
import { UrlObject } from 'url'
33
import {
44
addBasePath,
5+
addLocale,
56
isLocalURL,
67
NextRouter,
78
PrefetchOptions,
@@ -331,7 +332,7 @@ function Link(props: React.PropsWithChildren<LinkProps>) {
331332
// If child is an <a> tag and doesn't have a href attribute, or if the 'passHref' property is
332333
// defined, we specify the current 'href', so that repetition is not needed by the user
333334
if (props.passHref || (child.type === 'a' && !('href' in child.props))) {
334-
childProps.href = addBasePath(as)
335+
childProps.href = addBasePath(addLocale(as, router && router.locale))
335336
}
336337

337338
return React.cloneElement(child, childProps)

packages/next/client/page-loader.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
addBasePath,
88
markLoadingError,
99
interpolateAs,
10+
addLocale,
1011
} from '../next-server/lib/router/router'
1112

1213
import getAssetPathFromRoute from '../next-server/lib/router/utils/get-asset-path-from-route'
@@ -202,13 +203,13 @@ export default class PageLoader {
202203
* @param {string} href the route href (file-system path)
203204
* @param {string} asPath the URL as shown in browser (virtual path); used for dynamic routes
204205
*/
205-
getDataHref(href: string, asPath: string, ssg: boolean) {
206+
getDataHref(href: string, asPath: string, ssg: boolean, locale?: string) {
206207
const { pathname: hrefPathname, query, search } = parseRelativeUrl(href)
207208
const { pathname: asPathname } = parseRelativeUrl(asPath)
208209
const route = normalizeRoute(hrefPathname)
209210

210211
const getHrefForSlug = (path: string) => {
211-
const dataRoute = getAssetPathFromRoute(path, '.json')
212+
const dataRoute = addLocale(getAssetPathFromRoute(path, '.json'), locale)
212213
return addBasePath(
213214
`/_next/data/${this.buildId}${dataRoute}${ssg ? '' : search}`
214215
)

packages/next/client/router.ts

+6-1
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ const urlPropertyFields = [
3737
'components',
3838
'isFallback',
3939
'basePath',
40+
'locale',
41+
'locales',
4042
]
4143
const routerEvents = [
4244
'routeChangeStart',
@@ -144,7 +146,10 @@ export function makePublicRouterInstance(router: Router): NextRouter {
144146

145147
for (const property of urlPropertyFields) {
146148
if (typeof _router[property] === 'object') {
147-
instance[property] = Object.assign({}, _router[property]) // makes sure query is not stateful
149+
instance[property] = Object.assign(
150+
Array.isArray(_router[property]) ? [] : {},
151+
_router[property]
152+
) // makes sure query is not stateful
148153
continue
149154
}
150155

packages/next/export/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,8 @@ export default async function exportApp(
298298
ampValidatorPath: nextConfig.experimental.amp?.validator || undefined,
299299
ampSkipValidation: nextConfig.experimental.amp?.skipValidation || false,
300300
ampOptimizerConfig: nextConfig.experimental.amp?.optimizer || undefined,
301+
locales: nextConfig.experimental.i18n?.locales,
302+
locale: nextConfig.experimental.i18n?.defaultLocale,
301303
}
302304

303305
const { serverRuntimeConfig, publicRuntimeConfig } = nextConfig

0 commit comments

Comments
 (0)