From 7559d95d408745f6176b2802d6a238976ae25f54 Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Sat, 26 Sep 2020 00:02:49 -0500 Subject: [PATCH 01/12] Add i18n changes --- packages/next/build/index.ts | 4 +- packages/next/build/utils.ts | 40 +++++++-- packages/next/client/index.tsx | 23 +++++- packages/next/client/link.tsx | 3 +- packages/next/client/page-loader.ts | 5 +- packages/next/client/router.ts | 7 +- packages/next/export/index.ts | 2 + packages/next/export/worker.ts | 10 +++ .../lib/i18n/detect-locale-cookie.ts | 19 +++++ .../lib/i18n/normalize-locale-path.ts | 22 +++++ .../next/next-server/lib/router/router.ts | 30 ++++++- packages/next/next-server/lib/utils.ts | 2 + packages/next/next-server/server/config.ts | 40 +++++++++ .../next/next-server/server/next-server.ts | 82 +++++++++++++++++-- packages/next/next-server/server/render.tsx | 25 +++++- packages/next/package.json | 1 + packages/next/types/index.d.ts | 6 +- test/integration/i18n-support/next.config.js | 8 ++ .../integration/i18n-support/pages/another.js | 29 +++++++ .../i18n-support/pages/gsp/fallback/[slug].js | 35 ++++++++ .../i18n-support/pages/gsp/index.js | 22 +++++ .../pages/gsp/no-fallback/[slug].js | 38 +++++++++ .../i18n-support/pages/gssp/[slug].js | 24 ++++++ .../i18n-support/pages/gssp/index.js | 23 ++++++ test/integration/i18n-support/pages/index.js | 49 +++++++++++ .../i18n-support/test/index.test.js | 14 ++++ yarn.lock | 20 +++++ 27 files changed, 556 insertions(+), 27 deletions(-) create mode 100644 packages/next/next-server/lib/i18n/detect-locale-cookie.ts create mode 100644 packages/next/next-server/lib/i18n/normalize-locale-path.ts create mode 100644 test/integration/i18n-support/next.config.js create mode 100644 test/integration/i18n-support/pages/another.js create mode 100644 test/integration/i18n-support/pages/gsp/fallback/[slug].js create mode 100644 test/integration/i18n-support/pages/gsp/index.js create mode 100644 test/integration/i18n-support/pages/gsp/no-fallback/[slug].js create mode 100644 test/integration/i18n-support/pages/gssp/[slug].js create mode 100644 test/integration/i18n-support/pages/gssp/index.js create mode 100644 test/integration/i18n-support/pages/index.js create mode 100644 test/integration/i18n-support/test/index.test.js diff --git a/packages/next/build/index.ts b/packages/next/build/index.ts index 60d8b35d0339953..8b105b65d324a46 100644 --- a/packages/next/build/index.ts +++ b/packages/next/build/index.ts @@ -563,7 +563,9 @@ export default async function build( let workerResult = await staticCheckWorkers.isPageStatic( page, serverBundle, - runtimeEnvConfig + runtimeEnvConfig, + config.experimental.i18n?.locales, + config.experimental.i18n?.defaultLocale ) if (workerResult.isHybridAmp) { diff --git a/packages/next/build/utils.ts b/packages/next/build/utils.ts index 6c5e894c59ac497..eda0bde16d05e11 100644 --- a/packages/next/build/utils.ts +++ b/packages/next/build/utils.ts @@ -27,6 +27,7 @@ import { denormalizePagePath } from '../next-server/server/normalize-page-path' import { BuildManifest } from '../next-server/server/get-page-files' import { removePathTrailingSlash } from '../client/normalize-trailing-slash' import type { UnwrapPromise } from '../lib/coalesced-function' +import { normalizeLocalePath } from '../next-server/lib/i18n/normalize-locale-path' const fileGzipStats: { [k: string]: Promise } = {} const fsStatGzip = (file: string) => { @@ -530,7 +531,9 @@ export async function getJsPageSizeInKb( export async function buildStaticPaths( page: string, - getStaticPaths: GetStaticPaths + getStaticPaths: GetStaticPaths, + locales?: string[], + defaultLocale?: string ): Promise< Omit>, 'paths'> & { paths: string[] } > { @@ -595,7 +598,15 @@ export async function buildStaticPaths( // route. if (typeof entry === 'string') { entry = removePathTrailingSlash(entry) - const result = _routeMatcher(entry) + + const localePathResult = normalizeLocalePath(entry, locales) + let cleanedEntry = entry + + if (localePathResult.detectedLocale) { + cleanedEntry = entry.substr(localePathResult.detectedLocale.length + 1) + } + + const result = _routeMatcher(cleanedEntry) if (!result) { throw new Error( `The provided path \`${entry}\` does not match the page: \`${page}\`.` @@ -607,7 +618,10 @@ export async function buildStaticPaths( // For the object-provided path, we must make sure it specifies all // required keys. else { - const invalidKeys = Object.keys(entry).filter((key) => key !== 'params') + const invalidKeys = Object.keys(entry).filter( + (key) => key !== 'params' && key !== 'locale' + ) + if (invalidKeys.length) { throw new Error( `Additional keys were returned from \`getStaticPaths\` in page "${page}". ` + @@ -657,7 +671,14 @@ export async function buildStaticPaths( .replace(/(?!^)\/$/, '') }) - prerenderPaths?.add(builtPage) + if (entry.locale && !locales?.includes(entry.locale)) { + throw new Error( + `Invalid locale returned from getStaticPaths for ${page}, the locale ${entry.locale} is not specified in next.config.js` + ) + } + const curLocale = entry.locale || defaultLocale || '' + + prerenderPaths?.add(`${curLocale ? `/${curLocale}` : ''}${builtPage}`) } }) @@ -667,7 +688,9 @@ export async function buildStaticPaths( export async function isPageStatic( page: string, serverBundle: string, - runtimeEnvConfig: any + runtimeEnvConfig: any, + locales?: string[], + defaultLocale?: string ): Promise<{ isStatic?: boolean isAmpOnly?: boolean @@ -755,7 +778,12 @@ export async function isPageStatic( ;({ paths: prerenderRoutes, fallback: prerenderFallback, - } = await buildStaticPaths(page, mod.getStaticPaths)) + } = await buildStaticPaths( + page, + mod.getStaticPaths, + locales, + defaultLocale + )) } const config = mod.config || {} diff --git a/packages/next/client/index.tsx b/packages/next/client/index.tsx index 7739cb72afffccd..21e3e2afeedccd1 100644 --- a/packages/next/client/index.tsx +++ b/packages/next/client/index.tsx @@ -3,6 +3,7 @@ import '@next/polyfill-module' import React from 'react' import ReactDOM from 'react-dom' import { HeadManagerContext } from '../next-server/lib/head-manager-context' +import { normalizeLocalePath } from '../next-server/lib/i18n/normalize-locale-path' import mitt from '../next-server/lib/mitt' import { RouterContext } from '../next-server/lib/router-context' import type Router from '../next-server/lib/router/router' @@ -11,7 +12,11 @@ import type { AppProps, PrivateRouteInfo, } from '../next-server/lib/router/router' -import { delBasePath, hasBasePath } from '../next-server/lib/router/router' +import { + delBasePath, + hasBasePath, + delLocale, +} from '../next-server/lib/router/router' import { isDynamicRoute } from '../next-server/lib/router/utils/is-dynamic' import * as querystring from '../next-server/lib/router/utils/querystring' import * as envConfig from '../next-server/lib/runtime-config' @@ -60,8 +65,11 @@ const { dynamicIds, isFallback, head: initialHeadData, + locales, } = data +let { locale } = data + const prefix = assetPrefix || '' // With dynamic assetPrefix it's no longer possible to set assetPrefix at the build time @@ -80,6 +88,17 @@ if (hasBasePath(asPath)) { asPath = delBasePath(asPath) } +asPath = delLocale(asPath, locale) + +if (isFallback && locales) { + const localePathResult = normalizeLocalePath(asPath, locales) + + if (localePathResult.detectedLocale) { + asPath = asPath.substr(localePathResult.detectedLocale.length + 1) + locale = localePathResult.detectedLocale + } +} + type RegisterFn = (input: [string, () => void]) => void const pageLoader = new PageLoader(buildId, prefix, page) @@ -291,6 +310,8 @@ export default async (opts: { webpackHMR?: any } = {}) => { isFallback: Boolean(isFallback), subscription: ({ Component, styleSheets, props, err }, App) => render({ App, Component, styleSheets, props, err }), + locale, + locales, }) // call init-client middleware diff --git a/packages/next/client/link.tsx b/packages/next/client/link.tsx index c0bd24bb73abaf6..3924a1874feb1fd 100644 --- a/packages/next/client/link.tsx +++ b/packages/next/client/link.tsx @@ -2,6 +2,7 @@ import React, { Children } from 'react' import { UrlObject } from 'url' import { addBasePath, + addLocale, isLocalURL, NextRouter, PrefetchOptions, @@ -331,7 +332,7 @@ function Link(props: React.PropsWithChildren) { // If child is an tag and doesn't have a href attribute, or if the 'passHref' property is // defined, we specify the current 'href', so that repetition is not needed by the user if (props.passHref || (child.type === 'a' && !('href' in child.props))) { - childProps.href = addBasePath(as) + childProps.href = addBasePath(addLocale(as, router.locale)) } return React.cloneElement(child, childProps) diff --git a/packages/next/client/page-loader.ts b/packages/next/client/page-loader.ts index 96c1d7f1199c5a4..57e8c3374bfb72d 100644 --- a/packages/next/client/page-loader.ts +++ b/packages/next/client/page-loader.ts @@ -7,6 +7,7 @@ import { addBasePath, markLoadingError, interpolateAs, + addLocale, } from '../next-server/lib/router/router' import getAssetPathFromRoute from '../next-server/lib/router/utils/get-asset-path-from-route' @@ -202,13 +203,13 @@ export default class PageLoader { * @param {string} href the route href (file-system path) * @param {string} asPath the URL as shown in browser (virtual path); used for dynamic routes */ - getDataHref(href: string, asPath: string, ssg: boolean) { + getDataHref(href: string, asPath: string, ssg: boolean, locale?: string) { const { pathname: hrefPathname, query, search } = parseRelativeUrl(href) const { pathname: asPathname } = parseRelativeUrl(asPath) const route = normalizeRoute(hrefPathname) const getHrefForSlug = (path: string) => { - const dataRoute = getAssetPathFromRoute(path, '.json') + const dataRoute = addLocale(getAssetPathFromRoute(path, '.json'), locale) return addBasePath( `/_next/data/${this.buildId}${dataRoute}${ssg ? '' : search}` ) diff --git a/packages/next/client/router.ts b/packages/next/client/router.ts index 81f10d960936f68..54a3f65b378f7fa 100644 --- a/packages/next/client/router.ts +++ b/packages/next/client/router.ts @@ -37,6 +37,8 @@ const urlPropertyFields = [ 'components', 'isFallback', 'basePath', + 'locale', + 'locales', ] const routerEvents = [ 'routeChangeStart', @@ -144,7 +146,10 @@ export function makePublicRouterInstance(router: Router): NextRouter { for (const property of urlPropertyFields) { if (typeof _router[property] === 'object') { - instance[property] = Object.assign({}, _router[property]) // makes sure query is not stateful + instance[property] = Object.assign( + Array.isArray(_router[property]) ? [] : {}, + _router[property] + ) // makes sure query is not stateful continue } diff --git a/packages/next/export/index.ts b/packages/next/export/index.ts index 0ad98237782887e..69d8417e205e8f8 100644 --- a/packages/next/export/index.ts +++ b/packages/next/export/index.ts @@ -281,6 +281,8 @@ export default async function exportApp( ampValidatorPath: nextConfig.experimental.amp?.validator || undefined, ampSkipValidation: nextConfig.experimental.amp?.skipValidation || false, ampOptimizerConfig: nextConfig.experimental.amp?.optimizer || undefined, + locales: nextConfig.experimental.i18n?.locales, + locale: nextConfig.experimental.i18n?.defaultLocale, } const { serverRuntimeConfig, publicRuntimeConfig } = nextConfig diff --git a/packages/next/export/worker.ts b/packages/next/export/worker.ts index eb8734f07119ffe..6e9efdf1a75c876 100644 --- a/packages/next/export/worker.ts +++ b/packages/next/export/worker.ts @@ -15,6 +15,7 @@ import { ComponentType } from 'react' import { GetStaticProps } from '../types' import { requireFontManifest } from '../next-server/server/require' import { FontManifest } from '../next-server/server/font-utils' +import { normalizeLocalePath } from '../next-server/lib/i18n/normalize-locale-path' const envConfig = require('../next-server/lib/runtime-config') @@ -67,6 +68,8 @@ interface RenderOpts { optimizeFonts?: boolean optimizeImages?: boolean fontManifest?: FontManifest + locales?: string[] + locale?: string } type ComponentModule = ComponentType<{}> & { @@ -100,6 +103,13 @@ export default async function exportPage({ let query = { ...originalQuery } let params: { [key: string]: string | string[] } | undefined + const localePathResult = normalizeLocalePath(path, renderOpts.locales) + + if (localePathResult.detectedLocale) { + path = localePathResult.pathname + renderOpts.locale = localePathResult.detectedLocale + } + // We need to show a warning if they try to provide query values // for an auto-exported page since they won't be available const hasOrigQueryValues = Object.keys(originalQuery).length > 0 diff --git a/packages/next/next-server/lib/i18n/detect-locale-cookie.ts b/packages/next/next-server/lib/i18n/detect-locale-cookie.ts new file mode 100644 index 000000000000000..06e78221be80bbf --- /dev/null +++ b/packages/next/next-server/lib/i18n/detect-locale-cookie.ts @@ -0,0 +1,19 @@ +import { IncomingMessage } from 'http' +import cookie from 'next/dist/compiled/cookie' + +export function detectLocaleCookie(req: IncomingMessage, locales: string[]) { + let detectedLocale: string | undefined + + if (req.headers.cookie && req.headers.cookie.includes('NEXT_LOCALE')) { + const header = req.headers.cookie + const { NEXT_LOCALE } = cookie.parse( + Array.isArray(header) ? header.join(';') : header + ) + + if (locales.some((locale: string) => NEXT_LOCALE === locale)) { + detectedLocale = NEXT_LOCALE + } + } + + return detectedLocale +} diff --git a/packages/next/next-server/lib/i18n/normalize-locale-path.ts b/packages/next/next-server/lib/i18n/normalize-locale-path.ts new file mode 100644 index 000000000000000..ca88a7277c04daa --- /dev/null +++ b/packages/next/next-server/lib/i18n/normalize-locale-path.ts @@ -0,0 +1,22 @@ +export function normalizeLocalePath( + pathname: string, + locales?: string[] +): { + detectedLocale?: string + pathname: string +} { + let detectedLocale: string | undefined + ;(locales || []).some((locale) => { + if (pathname.startsWith(`/${locale}`)) { + detectedLocale = locale + pathname = pathname.replace(new RegExp(`^/${locale}`), '') || '/' + return true + } + return false + }) + + return { + pathname, + detectedLocale, + } +} diff --git a/packages/next/next-server/lib/router/router.ts b/packages/next/next-server/lib/router/router.ts index 0ef8fde67aa9727..139a996625ce104 100644 --- a/packages/next/next-server/lib/router/router.ts +++ b/packages/next/next-server/lib/router/router.ts @@ -47,6 +47,18 @@ function buildCancellationError() { }) } +export function addLocale(path: string, locale?: string) { + return normalizePathTrailingSlash( + `${locale && !path.startsWith('/' + locale) ? `/${locale}` : ''}${path}` + ) +} + +export function delLocale(path: string, locale?: string) { + return locale && path.startsWith('/' + locale) + ? path.substr(locale.length + 1) + : path +} + export function hasBasePath(path: string): boolean { return path === basePath || path.startsWith(basePath + '/') } @@ -222,6 +234,8 @@ export type BaseRouter = { query: ParsedUrlQuery asPath: string basePath: string + locale?: string + locales?: string[] } export type NextRouter = BaseRouter & @@ -330,6 +344,8 @@ export default class Router implements BaseRouter { isFallback: boolean _inFlightRoute?: string _shallow?: boolean + locale?: string + locales?: string[] static events: MittEmitter = mitt() @@ -347,6 +363,8 @@ export default class Router implements BaseRouter { err, subscription, isFallback, + locale, + locales, }: { subscription: Subscription initialProps: any @@ -357,6 +375,8 @@ export default class Router implements BaseRouter { wrapApp: (App: AppComponent) => any err?: Error isFallback: boolean + locale?: string + locales?: string[] } ) { // represents the current component key @@ -406,6 +426,8 @@ export default class Router implements BaseRouter { this.isSsr = true this.isFallback = isFallback + this.locale = locale + this.locales = locales if (typeof window !== 'undefined') { // make sure "as" doesn't start with double slashes or else it can @@ -561,6 +583,7 @@ export default class Router implements BaseRouter { this.abortComponentLoad(this._inFlightRoute) } + as = addLocale(as, this.locale) const cleanedAs = hasBasePath(as) ? delBasePath(as) : as this._inFlightRoute = as @@ -650,7 +673,7 @@ export default class Router implements BaseRouter { } } } - resolvedAs = delBasePath(resolvedAs) + resolvedAs = delLocale(delBasePath(resolvedAs), this.locale) if (isDynamicRoute(route)) { const parsedAs = parseRelativeUrl(resolvedAs) @@ -751,7 +774,7 @@ export default class Router implements BaseRouter { } Router.events.emit('beforeHistoryChange', as) - this.changeState(method, url, as, options) + this.changeState(method, url, addLocale(as, this.locale), options) if (process.env.NODE_ENV !== 'production') { const appComp: any = this.components['/_app'].Component @@ -920,7 +943,8 @@ export default class Router implements BaseRouter { dataHref = this.pageLoader.getDataHref( formatWithValidation({ pathname, query }), delBasePath(as), - __N_SSG + __N_SSG, + this.locale ) } diff --git a/packages/next/next-server/lib/utils.ts b/packages/next/next-server/lib/utils.ts index 9d2770362f3fc13..dc41662a903b9e3 100644 --- a/packages/next/next-server/lib/utils.ts +++ b/packages/next/next-server/lib/utils.ts @@ -101,6 +101,8 @@ export type NEXT_DATA = { gip?: boolean appGip?: boolean head: HeadEntry[] + locale?: string + locales?: string[] } /** diff --git a/packages/next/next-server/server/config.ts b/packages/next/next-server/server/config.ts index 3de166a548902ad..987851913ed106e 100644 --- a/packages/next/next-server/server/config.ts +++ b/packages/next/next-server/server/config.ts @@ -54,6 +54,7 @@ const defaultConfig: { [key: string]: any } = { optimizeFonts: false, optimizeImages: false, scrollRestoration: false, + i18n: false, }, future: { excludeDefaultMomentLocales: false, @@ -206,6 +207,45 @@ function assignDefaults(userConfig: { [key: string]: any }) { } } } + + if (result.experimental?.i18n) { + const { i18n } = result.experimental + const i18nType = typeof i18n + + if (i18nType !== 'object') { + throw new Error(`Specified i18n should be an object received ${i18nType}`) + } + + if (!Array.isArray(i18n.locales)) { + throw new Error( + `Specified i18n.locales should be an Array received ${typeof i18n.lcoales}` + ) + } + + const defaultLocaleType = typeof i18n.defaultLocale + + if (!i18n.defaultLocale || defaultLocaleType !== 'string') { + throw new Error(`Specified i18n.defaultLocale should be a string`) + } + + if (!i18n.locales.includes(i18n.defaultLocale)) { + throw new Error( + `Specified i18n.defaultLocale should be included in i18n.locales` + ) + } + + const localeDetectionType = typeof i18n.locales.localeDetection + + if ( + localeDetectionType !== 'boolean' && + localeDetectionType !== 'undefined' + ) { + throw new Error( + `Specified i18n.localeDetection should be undefined or a boolean received ${localeDetectionType}` + ) + } + } + return result } diff --git a/packages/next/next-server/server/next-server.ts b/packages/next/next-server/server/next-server.ts index 268a3c9f11d24f0..9d9d329ff46b3da 100644 --- a/packages/next/next-server/server/next-server.ts +++ b/packages/next/next-server/server/next-server.ts @@ -65,6 +65,9 @@ import { removePathTrailingSlash } from '../../client/normalize-trailing-slash' import getRouteFromAssetPath from '../lib/router/utils/get-route-from-asset-path' import { FontManifest } from './font-utils' import { denormalizePagePath } from './denormalize-page-path' +import accept from '@hapi/accept' +import { normalizeLocalePath } from '../lib/i18n/normalize-locale-path' +import { detectLocaleCookie } from '../lib/i18n/detect-locale-cookie' const getCustomRouteMatcher = pathMatch(true) @@ -124,6 +127,8 @@ export default class Server { optimizeFonts: boolean fontManifest: FontManifest optimizeImages: boolean + locale?: string + locales?: string[] } private compression?: Middleware private onErrorMiddleware?: ({ err }: { err: Error }) => Promise @@ -176,6 +181,7 @@ export default class Server { ? requireFontManifest(this.distDir, this._isLikeServerless) : null, optimizeImages: this.nextConfig.experimental.optimizeImages, + locales: this.nextConfig.experimental.i18n?.locales, } // Only the `publicRuntimeConfig` key is exposed to the client side @@ -274,6 +280,7 @@ export default class Server { } const { basePath } = this.nextConfig + const { i18n } = this.nextConfig.experimental if (basePath && req.url?.startsWith(basePath)) { // store original URL to allow checking if basePath was @@ -282,6 +289,49 @@ export default class Server { req.url = req.url!.replace(basePath, '') || '/' } + if (i18n) { + // get pathname from URL with basePath stripped for locale detection + const { pathname, ...parsed } = parseUrl(req.url || '/') + let detectedLocale = detectLocaleCookie(req, i18n.locales) + + if (!detectedLocale) { + detectedLocale = accept.language( + req.headers['accept-language'], + i18n.locales + ) + } + + if ( + i18n.localeDetection !== false && + denormalizePagePath(pathname || '/') === '/' + ) { + res.setHeader( + 'Location', + formatUrl({ + // make sure to include any query values when redirecting + ...parsed, + pathname: `/${detectedLocale}`, + }) + ) + res.statusCode = 307 + res.end() + } + + // TODO: domain based locales (domain to locale mapping needs to be provided in next.config.js) + const localePathResult = normalizeLocalePath(pathname!, i18n.locales) + + if (localePathResult.detectedLocale) { + detectedLocale = localePathResult.detectedLocale + req.url = formatUrl({ + ...parsed, + pathname: localePathResult.pathname, + }) + parsedUrl.pathname = localePathResult.pathname + } + + ;(req as any)._nextLocale = detectedLocale || i18n.defaultLocale + } + res.statusCode = 200 try { return await this.run(req, res, parsedUrl) @@ -427,15 +477,23 @@ export default class Server { } // re-create page's pathname - const pathname = getRouteFromAssetPath( - `/${params.path - // we need to re-encode the params since they are decoded - // by path-match and we are re-building the URL - .map((param: string) => encodeURIComponent(param)) - .join('/')}`, - '.json' + let pathname = `/${params.path + // we need to re-encode the params since they are decoded + // by path-match and we are re-building the URL + .map((param: string) => encodeURIComponent(param)) + .join('/')}` + + const localePathResult = normalizeLocalePath( + pathname, + this.renderOpts.locales ) + if (localePathResult.detectedLocale) { + ;(req as any)._nextLocale = localePathResult.detectedLocale + pathname = localePathResult.pathname + } + pathname = getRouteFromAssetPath(pathname, '.json') + const parsedUrl = parseUrl(pathname, true) await this.render( @@ -1031,7 +1089,7 @@ export default class Server { (path.split(this.buildId).pop() || '/').replace(/\.json$/, '') ) } - return path + return normalizeLocalePath(path, this.renderOpts.locales).pathname } // remove /_next/data prefix from urlPathname so it matches @@ -1041,10 +1099,14 @@ export default class Server { urlPathname = stripNextDataPath(urlPathname) } + const locale = (req as any)._nextLocale + const ssgCacheKey = isPreviewMode || !isSSG ? undefined // Preview mode bypasses the cache - : `${resolvedUrlPathname}${query.amp ? '.amp' : ''}` + : `${locale ? `/${locale}` : ''}${resolvedUrlPathname}${ + query.amp ? '.amp' : '' + }` // Complete the response with cached data if its present const cachedData = ssgCacheKey @@ -1110,6 +1172,7 @@ export default class Server { 'passthrough', { fontManifest: this.renderOpts.fontManifest, + locale: (req as any)._nextLocale, } ) @@ -1129,6 +1192,7 @@ export default class Server { ...opts, isDataReq, resolvedUrl, + locale: (req as any)._nextLocale, // For getServerSideProps we need to ensure we use the original URL // and not the resolved URL to prevent a hydration mismatch on // asPath diff --git a/packages/next/next-server/server/render.tsx b/packages/next/next-server/server/render.tsx index 6cefc51b078bf63..0ed5f3827396aa9 100644 --- a/packages/next/next-server/server/render.tsx +++ b/packages/next/next-server/server/render.tsx @@ -67,6 +67,8 @@ class ServerRouter implements NextRouter { basePath: string events: any isFallback: boolean + locale?: string + locales?: string[] // TODO: Remove in the next major version, as this would mean the user is adding event listeners in server-side `render` method static events: MittEmitter = mitt() @@ -75,7 +77,9 @@ class ServerRouter implements NextRouter { query: ParsedUrlQuery, as: string, { isFallback }: { isFallback: boolean }, - basePath: string + basePath: string, + locale?: string, + locales?: string[] ) { this.route = pathname.replace(/\/$/, '') || '/' this.pathname = pathname @@ -83,6 +87,8 @@ class ServerRouter implements NextRouter { this.asPath = as this.isFallback = isFallback this.basePath = basePath + this.locale = locale + this.locales = locales } push(): any { noRouter() @@ -156,6 +162,8 @@ export type RenderOptsPartial = { devOnlyCacheBusterQueryString?: string resolvedUrl?: string resolvedAsPath?: string + locale?: string + locales?: string[] } export type RenderOpts = LoadComponentsReturnType & RenderOptsPartial @@ -193,6 +201,8 @@ function renderDocument( appGip, unstable_runtimeJS, devOnlyCacheBusterQueryString, + locale, + locales, }: RenderOpts & { props: any docComponentsRendered: DocumentProps['docComponentsRendered'] @@ -239,6 +249,8 @@ function renderDocument( customServer, // whether the user is using a custom server gip, // whether the page has getInitialProps appGip, // whether the _app has getInitialProps + locale, + locales, head: React.Children.toArray(docProps.head || []) .map((elem) => { const { children } = elem?.props @@ -487,6 +499,9 @@ export async function renderToHTML( } if (isAutoExport) renderOpts.autoExport = true if (isSSG) renderOpts.nextExport = false + // don't set default locale for fallback pages since this needs to be + // handled at request time + if (isFallback) renderOpts.locale = undefined await Loadable.preloadAll() // Make sure all dynamic imports are loaded @@ -499,7 +514,9 @@ export async function renderToHTML( { isFallback: isFallback, }, - basePath + basePath, + renderOpts.locale, + renderOpts.locales ) const ctx = { err, @@ -581,6 +598,8 @@ export async function renderToHTML( ...(previewData !== false ? { preview: true, previewData: previewData } : undefined), + locales: renderOpts.locales, + locale: renderOpts.locale, }) } catch (staticPropsError) { // remove not found error code to prevent triggering legacy @@ -696,6 +715,8 @@ export async function renderToHTML( ...(previewData !== false ? { preview: true, previewData: previewData } : undefined), + locales: renderOpts.locales, + locale: renderOpts.locale, }) } catch (serverSidePropsError) { // remove not found error code to prevent triggering legacy diff --git a/packages/next/package.json b/packages/next/package.json index b7cb5a038bc58b3..339e1b32a4b00e7 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -76,6 +76,7 @@ "@babel/preset-typescript": "7.10.4", "@babel/runtime": "7.11.2", "@babel/types": "7.11.5", + "@hapi/accept": "5.0.1", "@next/polyfill-module": "9.5.4-canary.20", "@next/react-dev-overlay": "9.5.4-canary.20", "@next/react-refresh-utils": "9.5.4-canary.20", diff --git a/packages/next/types/index.d.ts b/packages/next/types/index.d.ts index 7416c841dd3f624..3b26f7f10b386ea 100644 --- a/packages/next/types/index.d.ts +++ b/packages/next/types/index.d.ts @@ -81,6 +81,8 @@ export type GetStaticPropsContext = { params?: Q preview?: boolean previewData?: any + locale?: string + locales?: string[] } export type GetStaticPropsResult

= { @@ -103,7 +105,7 @@ export type InferGetStaticPropsType = T extends GetStaticProps : never export type GetStaticPathsResult

= { - paths: Array + paths: Array fallback: boolean | 'unstable_blocking' } @@ -121,6 +123,8 @@ export type GetServerSidePropsContext< preview?: boolean previewData?: any resolvedUrl: string + locale?: string + locales?: string[] } export type GetServerSidePropsResult

= { diff --git a/test/integration/i18n-support/next.config.js b/test/integration/i18n-support/next.config.js new file mode 100644 index 000000000000000..0762a5e45ebb672 --- /dev/null +++ b/test/integration/i18n-support/next.config.js @@ -0,0 +1,8 @@ +module.exports = { + experimental: { + i18n: { + locales: ['nl-NL', 'nl-BE', 'nl', 'en-US', 'en'], + defaultLocale: 'en', + }, + }, +} diff --git a/test/integration/i18n-support/pages/another.js b/test/integration/i18n-support/pages/another.js new file mode 100644 index 000000000000000..05d544c99934043 --- /dev/null +++ b/test/integration/i18n-support/pages/another.js @@ -0,0 +1,29 @@ +import Link from 'next/link' +import { useRouter } from 'next/router' + +export default function Page(props) { + const router = useRouter() + + return ( + <> +

another page

+

{JSON.stringify(props)}

+

{router.locale}

+

{JSON.stringify(router.locales)}

+

{JSON.stringify(router.query)}

+ +
to / + +
+ + ) +} + +export const getServerSideProps = ({ locale, locales }) => { + return { + props: { + locale, + locales, + }, + } +} diff --git a/test/integration/i18n-support/pages/gsp/fallback/[slug].js b/test/integration/i18n-support/pages/gsp/fallback/[slug].js new file mode 100644 index 000000000000000..e1605de88d31438 --- /dev/null +++ b/test/integration/i18n-support/pages/gsp/fallback/[slug].js @@ -0,0 +1,35 @@ +import { useRouter } from 'next/router' + +export default function Page(props) { + const router = useRouter() + + if (router.isFallback) return 'Loading...' + + return ( + <> +

{JSON.stringify(props)}

+

{router.locale}

+

{JSON.stringify(router.locales)}

+

{JSON.stringify(router.query)}

+ + ) +} + +export const getStaticProps = ({ params, locale, locales }) => { + return { + props: { + params, + locale, + locales, + }, + } +} + +export const getStaticPaths = () => { + return { + paths: ['first', 'second'].map((slug) => ({ + params: { slug }, + })), + fallback: true, + } +} diff --git a/test/integration/i18n-support/pages/gsp/index.js b/test/integration/i18n-support/pages/gsp/index.js new file mode 100644 index 000000000000000..0d53b8f0ca729e4 --- /dev/null +++ b/test/integration/i18n-support/pages/gsp/index.js @@ -0,0 +1,22 @@ +import { useRouter } from 'next/router' + +export default function Page(props) { + const router = useRouter() + + return ( + <> +

{JSON.stringify(props)}

+

{router.locale}

+

{JSON.stringify(router.locales)}

+ + ) +} + +export const getStaticProps = ({ locale, locales }) => { + return { + props: { + locale, + locales, + }, + } +} diff --git a/test/integration/i18n-support/pages/gsp/no-fallback/[slug].js b/test/integration/i18n-support/pages/gsp/no-fallback/[slug].js new file mode 100644 index 000000000000000..7ee262c96478f71 --- /dev/null +++ b/test/integration/i18n-support/pages/gsp/no-fallback/[slug].js @@ -0,0 +1,38 @@ +import { useRouter } from 'next/router' + +export default function Page(props) { + const router = useRouter() + + if (router.isFallback) return 'Loading...' + + return ( + <> +

{JSON.stringify(props)}

+

{router.locale}

+

{JSON.stringify(router.locales)}

+

{JSON.stringify(router.query)}

+ + ) +} + +export const getStaticProps = ({ params, locale, locales }) => { + return { + props: { + params, + locale, + locales, + }, + } +} + +export const getStaticPaths = () => { + return { + paths: [ + { params: { slug: 'first' } }, + '/gsp/no-fallback/second', + { params: { slug: 'first' }, locale: 'en-US' }, + '/nl-NL/gsp/no-fallback/second', + ], + fallback: false, + } +} diff --git a/test/integration/i18n-support/pages/gssp/[slug].js b/test/integration/i18n-support/pages/gssp/[slug].js new file mode 100644 index 000000000000000..cc2274a913b6377 --- /dev/null +++ b/test/integration/i18n-support/pages/gssp/[slug].js @@ -0,0 +1,24 @@ +import { useRouter } from 'next/router' + +export default function Page(props) { + const router = useRouter() + + return ( + <> +

{JSON.stringify(props)}

+

{router.locale}

+

{JSON.stringify(router.locales)}

+

{JSON.stringify(router.query)}

+ + ) +} + +export const getServerSideProps = ({ params, locale, locales }) => { + return { + props: { + params, + locale, + locales, + }, + } +} diff --git a/test/integration/i18n-support/pages/gssp/index.js b/test/integration/i18n-support/pages/gssp/index.js new file mode 100644 index 000000000000000..e7f80f2917271c3 --- /dev/null +++ b/test/integration/i18n-support/pages/gssp/index.js @@ -0,0 +1,23 @@ +import { useRouter } from 'next/router' + +export default function Page(props) { + const router = useRouter() + + return ( + <> +

{JSON.stringify(props)}

+

{router.locale}

+

{JSON.stringify(router.locales)}

+

{JSON.stringify(router.query)}

+ + ) +} + +export const getServerSideProps = ({ locale, locales }) => { + return { + props: { + locale, + locales, + }, + } +} diff --git a/test/integration/i18n-support/pages/index.js b/test/integration/i18n-support/pages/index.js new file mode 100644 index 000000000000000..3706a9a21cde74c --- /dev/null +++ b/test/integration/i18n-support/pages/index.js @@ -0,0 +1,49 @@ +import Link from 'next/link' +import { useRouter } from 'next/router' + +export default function Page(props) { + const router = useRouter() + + return ( + <> +

index page

+

{JSON.stringify(props)}

+

{router.locale}

+

{JSON.stringify(router.locales)}

+

{JSON.stringify(router.query)}

+ + to /another + +
+ + to /gsp/fallback/first + +
+ + to /gsp/fallback/hello + +
+ + to /gsp/no-fallback/first + +
+ + to /gssp + +
+ + to /gssp/first + +
+ + ) +} + +export const getServerSideProps = ({ locale, locales }) => { + return { + props: { + locale, + locales, + }, + } +} diff --git a/test/integration/i18n-support/test/index.test.js b/test/integration/i18n-support/test/index.test.js new file mode 100644 index 000000000000000..5b0d53598347060 --- /dev/null +++ b/test/integration/i18n-support/test/index.test.js @@ -0,0 +1,14 @@ +/* eslint-env jest */ + +import { join } from 'path' +import { nextBuild } from 'next-test-utils' + +jest.setTimeout(1000 * 60 * 2) + +const appDir = join(__dirname, '../') + +describe('i18n Support', () => { + it('builds successfully', async () => { + await nextBuild(appDir) + }) +}) diff --git a/yarn.lock b/yarn.lock index 30c492a6a51f73b..2ac55160d99d7af 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1825,6 +1825,26 @@ lodash.camelcase "^4.3.0" protobufjs "^6.8.6" +"@hapi/accept@5.0.1": + version "5.0.1" + resolved "https://registry.yarnpkg.com/@hapi/accept/-/accept-5.0.1.tgz#068553e867f0f63225a506ed74e899441af53e10" + integrity sha512-fMr4d7zLzsAXo28PRRQPXR1o2Wmu+6z+VY1UzDp0iFo13Twj8WePakwXBiqn3E1aAlTpSNzCXdnnQXFhst8h8Q== + dependencies: + "@hapi/boom" "9.x.x" + "@hapi/hoek" "9.x.x" + +"@hapi/boom@9.x.x": + version "9.1.0" + resolved "https://registry.yarnpkg.com/@hapi/boom/-/boom-9.1.0.tgz#0d9517657a56ff1e0b42d0aca9da1b37706fec56" + integrity sha512-4nZmpp4tXbm162LaZT45P7F7sgiem8dwAh2vHWT6XX24dozNjGMg6BvKCRvtCUcmcXqeMIUqWN8Rc5X8yKuROQ== + dependencies: + "@hapi/hoek" "9.x.x" + +"@hapi/hoek@9.x.x": + version "9.1.0" + resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-9.1.0.tgz#6c9eafc78c1529248f8f4d92b0799a712b6052c6" + integrity sha512-i9YbZPN3QgfighY/1X1Pu118VUz2Fmmhd6b2n0/O8YVgGGfw0FbUYoA97k7FkpGJ+pLCFEDLUmAPPV4D1kpeFw== + "@istanbuljs/load-nyc-config@^1.0.0": version "1.0.0" resolved "https://registry.yarnpkg.com/@istanbuljs/load-nyc-config/-/load-nyc-config-1.0.0.tgz#10602de5570baea82f8afbfa2630b24e7a8cfe5b" From fce07edd270d2955e7df5fda887d368b054028e0 Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Sat, 26 Sep 2020 00:04:58 -0500 Subject: [PATCH 02/12] Handle invalid href case --- packages/next/next-server/lib/router/router.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/next/next-server/lib/router/router.ts b/packages/next/next-server/lib/router/router.ts index 139a996625ce104..bbb7562ebe3f86b 100644 --- a/packages/next/next-server/lib/router/router.ts +++ b/packages/next/next-server/lib/router/router.ts @@ -48,9 +48,11 @@ function buildCancellationError() { } export function addLocale(path: string, locale?: string) { - return normalizePathTrailingSlash( - `${locale && !path.startsWith('/' + locale) ? `/${locale}` : ''}${path}` - ) + return path.startsWith('/') + ? normalizePathTrailingSlash( + `${locale && !path.startsWith('/' + locale) ? `/${locale}` : ''}${path}` + ) + : path } export function delLocale(path: string, locale?: string) { From 7487f19176cf31246d4daa46d1d53fbbd1486de0 Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Sat, 26 Sep 2020 00:05:27 -0500 Subject: [PATCH 03/12] Update size-limit test --- test/integration/size-limit/test/index.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/integration/size-limit/test/index.test.js b/test/integration/size-limit/test/index.test.js index 69d1d60c16753b6..02b215d6ad01b1b 100644 --- a/test/integration/size-limit/test/index.test.js +++ b/test/integration/size-limit/test/index.test.js @@ -80,7 +80,7 @@ describe('Production response size', () => { ) // These numbers are without gzip compression! - const delta = responseSizesBytes - 279 * 1024 + const delta = responseSizesBytes - 280 * 1024 expect(delta).toBeLessThanOrEqual(1024) // don't increase size more than 1kb expect(delta).toBeGreaterThanOrEqual(-1024) // don't decrease size more than 1kb without updating target }) @@ -100,7 +100,7 @@ describe('Production response size', () => { ) // These numbers are without gzip compression! - const delta = responseSizesBytes - 170 * 1024 + const delta = responseSizesBytes - 171 * 1024 expect(delta).toBeLessThanOrEqual(1024) // don't increase size more than 1kb expect(delta).toBeGreaterThanOrEqual(-1024) // don't decrease size more than 1kb without updating target }) From f152d57b3aaa71e04f05a1593f6c0ed672cd211f Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Sat, 26 Sep 2020 00:09:34 -0500 Subject: [PATCH 04/12] Update build output test --- test/integration/build-output/test/index.test.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/integration/build-output/test/index.test.js b/test/integration/build-output/test/index.test.js index b8970b78e32a8b4..4a394e03cc2ded9 100644 --- a/test/integration/build-output/test/index.test.js +++ b/test/integration/build-output/test/index.test.js @@ -95,16 +95,16 @@ describe('Build Output', () => { expect(indexSize.endsWith('B')).toBe(true) // should be no bigger than 60.8 kb - expect(parseFloat(indexFirstLoad) - 60.8).toBeLessThanOrEqual(0) + expect(parseFloat(indexFirstLoad) - 61).toBeLessThanOrEqual(0) expect(indexFirstLoad.endsWith('kB')).toBe(true) expect(parseFloat(err404Size) - 3.5).toBeLessThanOrEqual(0) expect(err404Size.endsWith('kB')).toBe(true) - expect(parseFloat(err404FirstLoad) - 63.8).toBeLessThanOrEqual(0) + expect(parseFloat(err404FirstLoad) - 64.1).toBeLessThanOrEqual(0) expect(err404FirstLoad.endsWith('kB')).toBe(true) - expect(parseFloat(sharedByAll) - 60.4).toBeLessThanOrEqual(0) + expect(parseFloat(sharedByAll) - 60.7).toBeLessThanOrEqual(0) expect(sharedByAll.endsWith('kB')).toBe(true) if (_appSize.endsWith('kB')) { From cb2ff33be8a1efac961a3e566e3c334b09617a48 Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Sat, 26 Sep 2020 16:18:42 -0500 Subject: [PATCH 05/12] Update locale adding --- packages/next/client/link.tsx | 2 +- .../next/next-server/lib/router/router.ts | 20 ++++++++++--------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/packages/next/client/link.tsx b/packages/next/client/link.tsx index 3924a1874feb1fd..76b59444dde7081 100644 --- a/packages/next/client/link.tsx +++ b/packages/next/client/link.tsx @@ -332,7 +332,7 @@ function Link(props: React.PropsWithChildren) { // If child is an tag and doesn't have a href attribute, or if the 'passHref' property is // defined, we specify the current 'href', so that repetition is not needed by the user if (props.passHref || (child.type === 'a' && !('href' in child.props))) { - childProps.href = addBasePath(addLocale(as, router.locale)) + childProps.href = addBasePath(addLocale(as, router && router.locale)) } return React.cloneElement(child, childProps) diff --git a/packages/next/next-server/lib/router/router.ts b/packages/next/next-server/lib/router/router.ts index bbb7562ebe3f86b..181a4a801b2d369 100644 --- a/packages/next/next-server/lib/router/router.ts +++ b/packages/next/next-server/lib/router/router.ts @@ -47,11 +47,17 @@ function buildCancellationError() { }) } +function addPathPrefix(path: string, prefix?: string) { + return prefix && path.startsWith('/') + ? path === '/' + ? normalizePathTrailingSlash(prefix) + : `${prefix}${path}` + : path +} + export function addLocale(path: string, locale?: string) { - return path.startsWith('/') - ? normalizePathTrailingSlash( - `${locale && !path.startsWith('/' + locale) ? `/${locale}` : ''}${path}` - ) + return locale && !path.startsWith('/' + locale) + ? addPathPrefix(path, '/' + locale) : path } @@ -67,11 +73,7 @@ export function hasBasePath(path: string): boolean { export function addBasePath(path: string): string { // we only add the basepath on relative urls - return basePath && path.startsWith('/') - ? path === '/' - ? normalizePathTrailingSlash(basePath) - : basePath + path - : path + return addPathPrefix(path, basePath) } export function delBasePath(path: string): string { From 69df47eb6694ad0b54e8b3357838e87ccfcbfc68 Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Sun, 27 Sep 2020 23:24:32 -0500 Subject: [PATCH 06/12] Add i18n tests --- packages/next/build/utils.ts | 2 + .../next/next-server/lib/router/router.ts | 7 +- .../next/next-server/server/next-server.ts | 6 +- packages/next/server/next-dev-server.ts | 5 +- packages/next/server/static-paths-worker.ts | 11 +- .../integration/i18n-support/pages/another.js | 4 +- .../i18n-support/pages/gsp/fallback/[slug].js | 11 +- .../i18n-support/pages/gsp/index.js | 12 +- .../pages/gsp/no-fallback/[slug].js | 10 +- .../i18n-support/pages/gssp/[slug].js | 10 +- .../i18n-support/pages/gssp/index.js | 10 +- test/integration/i18n-support/pages/index.js | 8 +- .../i18n-support/test/index.test.js | 314 +++++++++++++++++- 13 files changed, 394 insertions(+), 16 deletions(-) diff --git a/packages/next/build/utils.ts b/packages/next/build/utils.ts index eda0bde16d05e11..be53e423c8cd9bf 100644 --- a/packages/next/build/utils.ts +++ b/packages/next/build/utils.ts @@ -604,6 +604,8 @@ export async function buildStaticPaths( if (localePathResult.detectedLocale) { cleanedEntry = entry.substr(localePathResult.detectedLocale.length + 1) + } else if (defaultLocale) { + entry = `/${defaultLocale}${entry}` } const result = _routeMatcher(cleanedEntry) diff --git a/packages/next/next-server/lib/router/router.ts b/packages/next/next-server/lib/router/router.ts index 181a4a801b2d369..0cc9b2d0b4d5b2d 100644 --- a/packages/next/next-server/lib/router/router.ts +++ b/packages/next/next-server/lib/router/router.ts @@ -63,7 +63,7 @@ export function addLocale(path: string, locale?: string) { export function delLocale(path: string, locale?: string) { return locale && path.startsWith('/' + locale) - ? path.substr(locale.length + 1) + ? path.substr(locale.length + 1) || '/' : path } @@ -588,7 +588,10 @@ export default class Router implements BaseRouter { } as = addLocale(as, this.locale) - const cleanedAs = hasBasePath(as) ? delBasePath(as) : as + const cleanedAs = delLocale( + hasBasePath(as) ? delBasePath(as) : as, + this.locale + ) this._inFlightRoute = as // If the url change is only related to a hash change diff --git a/packages/next/next-server/server/next-server.ts b/packages/next/next-server/server/next-server.ts index b28ad94a77ad907..c014493179f3edb 100644 --- a/packages/next/next-server/server/next-server.ts +++ b/packages/next/next-server/server/next-server.ts @@ -1272,7 +1272,11 @@ export default class Server { // `getStaticPaths` (isProduction || !staticPaths || - !staticPaths.includes(resolvedUrlPathname)) + // static paths always includes locale so make sure it's prefixed + // with it + !staticPaths.includes( + `${locale ? '/' + locale : ''}${resolvedUrlPathname}` + )) ) { if ( // In development, fall through to render to handle missing diff --git a/packages/next/server/next-dev-server.ts b/packages/next/server/next-dev-server.ts index 8bddcb130f02312..d2b70721c3c0fd2 100644 --- a/packages/next/server/next-dev-server.ts +++ b/packages/next/server/next-dev-server.ts @@ -534,6 +534,7 @@ export default class DevServer extends Server { const __getStaticPaths = async () => { const { publicRuntimeConfig, serverRuntimeConfig } = this.nextConfig + const { locales, defaultLocale } = this.nextConfig.experimental.i18n || {} const paths = await this.staticPathsWorker.loadStaticPaths( this.distDir, @@ -542,7 +543,9 @@ export default class DevServer extends Server { { publicRuntimeConfig, serverRuntimeConfig, - } + }, + locales, + defaultLocale ) return paths } diff --git a/packages/next/server/static-paths-worker.ts b/packages/next/server/static-paths-worker.ts index 77da28a2c4b3844..ffd2e02866c0129 100644 --- a/packages/next/server/static-paths-worker.ts +++ b/packages/next/server/static-paths-worker.ts @@ -13,7 +13,9 @@ export async function loadStaticPaths( distDir: string, pathname: string, serverless: boolean, - config: RuntimeConfig + config: RuntimeConfig, + locales?: string[], + defaultLocale?: string ) { // we only want to use each worker once to prevent any invalid // caches @@ -35,5 +37,10 @@ export async function loadStaticPaths( } workerWasUsed = true - return buildStaticPaths(pathname, components.getStaticPaths) + return buildStaticPaths( + pathname, + components.getStaticPaths, + locales, + defaultLocale + ) } diff --git a/test/integration/i18n-support/pages/another.js b/test/integration/i18n-support/pages/another.js index 05d544c99934043..0713a50be1bbb2c 100644 --- a/test/integration/i18n-support/pages/another.js +++ b/test/integration/i18n-support/pages/another.js @@ -9,8 +9,10 @@ export default function Page(props) {

another page

{JSON.stringify(props)}

{router.locale}

-

{JSON.stringify(router.locales)}

+

{JSON.stringify(router.locales)}

{JSON.stringify(router.query)}

+

{router.pathname}

+

{router.asPath}

to / diff --git a/test/integration/i18n-support/pages/gsp/fallback/[slug].js b/test/integration/i18n-support/pages/gsp/fallback/[slug].js index e1605de88d31438..ec236891864f732 100644 --- a/test/integration/i18n-support/pages/gsp/fallback/[slug].js +++ b/test/integration/i18n-support/pages/gsp/fallback/[slug].js @@ -1,3 +1,4 @@ +import Link from 'next/link' import { useRouter } from 'next/router' export default function Page(props) { @@ -7,10 +8,17 @@ export default function Page(props) { return ( <> +

gsp page

{JSON.stringify(props)}

{router.locale}

-

{JSON.stringify(router.locales)}

+

{JSON.stringify(router.locales)}

{JSON.stringify(router.query)}

+

{router.pathname}

+

{router.asPath}

+ + to / + +
) } @@ -27,6 +35,7 @@ export const getStaticProps = ({ params, locale, locales }) => { export const getStaticPaths = () => { return { + // the default locale will be used since one isn't defined here paths: ['first', 'second'].map((slug) => ({ params: { slug }, })), diff --git a/test/integration/i18n-support/pages/gsp/index.js b/test/integration/i18n-support/pages/gsp/index.js index 0d53b8f0ca729e4..8c573d748dcc06a 100644 --- a/test/integration/i18n-support/pages/gsp/index.js +++ b/test/integration/i18n-support/pages/gsp/index.js @@ -1,3 +1,4 @@ +import Link from 'next/link' import { useRouter } from 'next/router' export default function Page(props) { @@ -5,13 +6,22 @@ export default function Page(props) { return ( <> +

gsp page

{JSON.stringify(props)}

{router.locale}

-

{JSON.stringify(router.locales)}

+

{JSON.stringify(router.locales)}

+

{JSON.stringify(router.query)}

+

{router.pathname}

+

{router.asPath}

+ + to / + +
) } +// TODO: should non-dynamic GSP pages pre-render for each locale? export const getStaticProps = ({ locale, locales }) => { return { props: { diff --git a/test/integration/i18n-support/pages/gsp/no-fallback/[slug].js b/test/integration/i18n-support/pages/gsp/no-fallback/[slug].js index 7ee262c96478f71..2df6728803f7a2d 100644 --- a/test/integration/i18n-support/pages/gsp/no-fallback/[slug].js +++ b/test/integration/i18n-support/pages/gsp/no-fallback/[slug].js @@ -1,3 +1,4 @@ +import Link from 'next/link' import { useRouter } from 'next/router' export default function Page(props) { @@ -7,10 +8,17 @@ export default function Page(props) { return ( <> +

gsp page

{JSON.stringify(props)}

{router.locale}

-

{JSON.stringify(router.locales)}

+

{JSON.stringify(router.locales)}

{JSON.stringify(router.query)}

+

{router.pathname}

+

{router.asPath}

+ + to / + +
) } diff --git a/test/integration/i18n-support/pages/gssp/[slug].js b/test/integration/i18n-support/pages/gssp/[slug].js index cc2274a913b6377..759937cc84c8eab 100644 --- a/test/integration/i18n-support/pages/gssp/[slug].js +++ b/test/integration/i18n-support/pages/gssp/[slug].js @@ -1,3 +1,4 @@ +import Link from 'next/link' import { useRouter } from 'next/router' export default function Page(props) { @@ -5,10 +6,17 @@ export default function Page(props) { return ( <> +

gssp page

{JSON.stringify(props)}

{router.locale}

-

{JSON.stringify(router.locales)}

+

{JSON.stringify(router.locales)}

{JSON.stringify(router.query)}

+

{router.pathname}

+

{router.asPath}

+ + to / + +
) } diff --git a/test/integration/i18n-support/pages/gssp/index.js b/test/integration/i18n-support/pages/gssp/index.js index e7f80f2917271c3..6919f3548f7cbd2 100644 --- a/test/integration/i18n-support/pages/gssp/index.js +++ b/test/integration/i18n-support/pages/gssp/index.js @@ -1,3 +1,4 @@ +import Link from 'next/link' import { useRouter } from 'next/router' export default function Page(props) { @@ -5,10 +6,17 @@ export default function Page(props) { return ( <> +

gssp page

{JSON.stringify(props)}

{router.locale}

-

{JSON.stringify(router.locales)}

+

{JSON.stringify(router.locales)}

{JSON.stringify(router.query)}

+

{router.pathname}

+

{router.asPath}

+ + to / + +
) } diff --git a/test/integration/i18n-support/pages/index.js b/test/integration/i18n-support/pages/index.js index 3706a9a21cde74c..649d86f6a3d912b 100644 --- a/test/integration/i18n-support/pages/index.js +++ b/test/integration/i18n-support/pages/index.js @@ -9,12 +9,18 @@ export default function Page(props) {

index page

{JSON.stringify(props)}

{router.locale}

-

{JSON.stringify(router.locales)}

+

{JSON.stringify(router.locales)}

{JSON.stringify(router.query)}

+

{router.pathname}

+

{router.asPath}

to /another
+ + to /gsp + +
to /gsp/fallback/first diff --git a/test/integration/i18n-support/test/index.test.js b/test/integration/i18n-support/test/index.test.js index 5b0d53598347060..dd390acec6d5cf0 100644 --- a/test/integration/i18n-support/test/index.test.js +++ b/test/integration/i18n-support/test/index.test.js @@ -1,14 +1,322 @@ /* eslint-env jest */ +import url from 'url' +// import fs from 'fs-extra' +import cheerio from 'cheerio' import { join } from 'path' -import { nextBuild } from 'next-test-utils' +import webdriver from 'next-webdriver' +import { + fetchViaHTTP, + findPort, + killApp, + launchApp, + nextBuild, + nextStart, + renderViaHTTP, +} from 'next-test-utils' jest.setTimeout(1000 * 60 * 2) const appDir = join(__dirname, '../') +let app +let appPort +// let buildId + +const locales = ['nl-NL', 'nl-BE', 'nl', 'en-US', 'en'] + +function runTests() { + it('should redirect to locale prefixed route for /', async () => { + const res = await fetchViaHTTP(appPort, '/', undefined, { + redirect: 'manual', + headers: { + 'Accept-Language': 'nl-NL,nl;q=0.9,en-US;q=0.8,en;q=0.7', + }, + }) + expect(res.status).toBe(307) + + const parsedUrl = url.parse(res.headers.get('location'), true) + expect(parsedUrl.pathname).toBe('/nl-NL') + expect(parsedUrl.query).toEqual({}) + + const res2 = await fetchViaHTTP( + appPort, + '/', + { hello: 'world' }, + { + redirect: 'manual', + headers: { + 'Accept-Language': 'en-US,en;q=0.9', + }, + } + ) + expect(res2.status).toBe(307) + + const parsedUrl2 = url.parse(res2.headers.get('location'), true) + expect(parsedUrl2.pathname).toBe('/en-US') + expect(parsedUrl2.query).toEqual({ hello: 'world' }) + }) + + it('should load getStaticProps page correctly SSR', async () => { + const html = await renderViaHTTP(appPort, '/en-US/gsp') + const $ = cheerio.load(html) + + expect(JSON.parse($('#props').text())).toEqual({ + locale: 'en-US', + locales, + }) + expect($('#router-locale').text()).toBe('en-US') + expect(JSON.parse($('#router-locales').text())).toEqual(locales) + }) + + it('should load getStaticProps fallback prerender page correctly SSR', async () => { + const html = await renderViaHTTP(appPort, '/en/gsp/fallback/first') + const $ = cheerio.load(html) + + expect(JSON.parse($('#props').text())).toEqual({ + locale: 'en', + locales, + params: { + slug: 'first', + }, + }) + expect(JSON.parse($('#router-query').text())).toEqual({ + slug: 'first', + }) + expect($('#router-locale').text()).toBe('en') + expect(JSON.parse($('#router-locales').text())).toEqual(locales) + }) + + it('should load getStaticProps fallback non-prerender page correctly', async () => { + const browser = await webdriver(appPort, '/en-US/gsp/fallback/another') + + await browser.waitForElementByCss('#props') + + expect(JSON.parse(await browser.elementByCss('#props').text())).toEqual({ + locale: 'en-US', + locales, + params: { + slug: 'another', + }, + }) + expect( + JSON.parse(await browser.elementByCss('#router-query').text()) + ).toEqual({ + slug: 'another', + }) + expect(await browser.elementByCss('#router-locale').text()).toBe('en-US') + expect( + JSON.parse(await browser.elementByCss('#router-locales').text()) + ).toEqual(locales) + }) + + it('should load getStaticProps fallback non-prerender page another locale correctly', async () => { + const browser = await webdriver(appPort, '/nl-NL/gsp/fallback/another') + + await browser.waitForElementByCss('#props') + + expect(JSON.parse(await browser.elementByCss('#props').text())).toEqual({ + locale: 'nl-NL', + locales, + params: { + slug: 'another', + }, + }) + expect( + JSON.parse(await browser.elementByCss('#router-query').text()) + ).toEqual({ + slug: 'another', + }) + expect(await browser.elementByCss('#router-locale').text()).toBe('nl-NL') + expect( + JSON.parse(await browser.elementByCss('#router-locales').text()) + ).toEqual(locales) + }) + + it('should load getStaticProps non-fallback correctly', async () => { + const browser = await webdriver(appPort, '/en/gsp/no-fallback/first') + + await browser.waitForElementByCss('#props') + + expect(JSON.parse(await browser.elementByCss('#props').text())).toEqual({ + locale: 'en', + locales, + params: { + slug: 'first', + }, + }) + expect( + JSON.parse(await browser.elementByCss('#router-query').text()) + ).toEqual({ + slug: 'first', + }) + expect(await browser.elementByCss('#router-locale').text()).toBe('en') + expect( + JSON.parse(await browser.elementByCss('#router-locales').text()) + ).toEqual(locales) + }) + + it('should load getStaticProps non-fallback correctly another locale', async () => { + const browser = await webdriver(appPort, '/nl-NL/gsp/no-fallback/second') + + await browser.waitForElementByCss('#props') + + expect(JSON.parse(await browser.elementByCss('#props').text())).toEqual({ + locale: 'nl-NL', + locales, + params: { + slug: 'second', + }, + }) + expect( + JSON.parse(await browser.elementByCss('#router-query').text()) + ).toEqual({ + slug: 'second', + }) + expect(await browser.elementByCss('#router-locale').text()).toBe('nl-NL') + expect( + JSON.parse(await browser.elementByCss('#router-locales').text()) + ).toEqual(locales) + }) + + it('should load getServerSideProps page correctly SSR', async () => { + const html = await renderViaHTTP(appPort, '/en-US/gssp') + const $ = cheerio.load(html) + + expect(JSON.parse($('#props').text())).toEqual({ + locale: 'en-US', + locales, + }) + expect($('#router-locale').text()).toBe('en-US') + expect(JSON.parse($('#router-locales').text())).toEqual(locales) + expect(JSON.parse($('#router-query').text())).toEqual({}) + + const html2 = await renderViaHTTP(appPort, '/nl-NL/gssp') + const $2 = cheerio.load(html2) + + expect(JSON.parse($2('#props').text())).toEqual({ + locale: 'nl-NL', + locales, + }) + expect($2('#router-locale').text()).toBe('nl-NL') + expect(JSON.parse($2('#router-locales').text())).toEqual(locales) + expect(JSON.parse($2('#router-query').text())).toEqual({}) + }) + + it('should load dynamic getServerSideProps page correctly SSR', async () => { + const html = await renderViaHTTP(appPort, '/en-US/gssp/first') + const $ = cheerio.load(html) + + expect(JSON.parse($('#props').text())).toEqual({ + locale: 'en-US', + locales, + params: { + slug: 'first', + }, + }) + expect($('#router-locale').text()).toBe('en-US') + expect(JSON.parse($('#router-locales').text())).toEqual(locales) + expect(JSON.parse($('#router-query').text())).toEqual({ slug: 'first' }) + + const html2 = await renderViaHTTP(appPort, '/nl-NL/gssp/first') + const $2 = cheerio.load(html2) + + expect(JSON.parse($2('#props').text())).toEqual({ + locale: 'nl-NL', + locales, + params: { + slug: 'first', + }, + }) + expect($2('#router-locale').text()).toBe('nl-NL') + expect(JSON.parse($2('#router-locales').text())).toEqual(locales) + expect(JSON.parse($2('#router-query').text())).toEqual({ slug: 'first' }) + }) + + it('should navigate to another page and back correctly with locale', async () => { + const browser = await webdriver(appPort, '/en') + + await browser.eval('window.beforeNav = "hi"') + + await browser + .elementByCss('#to-another') + .click() + .waitForElementByCss('#another') + + expect(await browser.elementByCss('#router-pathname').text()).toBe( + '/another' + ) + expect(await browser.elementByCss('#router-as-path').text()).toBe( + '/another' + ) + expect(await browser.elementByCss('#router-locale').text()).toBe('en') + expect( + JSON.parse(await browser.elementByCss('#router-locales').text()) + ).toEqual(locales) + expect(JSON.parse(await browser.elementByCss('#props').text())).toEqual({ + locale: 'en', + locales, + }) + expect( + JSON.parse(await browser.elementByCss('#router-query').text()) + ).toEqual({}) + expect(await browser.eval('window.beforeNav')).toBe('hi') + + await browser.back().waitForElementByCss('#index') + expect(await browser.eval('window.beforeNav')).toBe('hi') + expect(await browser.elementByCss('#router-pathname').text()).toBe('/') + expect(await browser.elementByCss('#router-as-path').text()).toBe('/') + }) + + it('should navigate to getStaticProps page and back correctly with locale', async () => { + const browser = await webdriver(appPort, '/en') + + await browser.eval('window.beforeNav = "hi"') + + await browser.elementByCss('#to-gsp').click().waitForElementByCss('#gsp') + + expect(await browser.elementByCss('#router-pathname').text()).toBe('/gsp') + expect(await browser.elementByCss('#router-as-path').text()).toBe('/gsp') + expect(await browser.elementByCss('#router-locale').text()).toBe('en') + expect( + JSON.parse(await browser.elementByCss('#router-locales').text()) + ).toEqual(locales) + expect(JSON.parse(await browser.elementByCss('#props').text())).toEqual({ + locale: 'en', + locales, + }) + expect( + JSON.parse(await browser.elementByCss('#router-query').text()) + ).toEqual({}) + expect(await browser.eval('window.beforeNav')).toBe('hi') + + await browser.back().waitForElementByCss('#index') + expect(await browser.eval('window.beforeNav')).toBe('hi') + expect(await browser.elementByCss('#router-pathname').text()).toBe('/') + expect(await browser.elementByCss('#router-as-path').text()).toBe('/') + }) +} describe('i18n Support', () => { - it('builds successfully', async () => { - await nextBuild(appDir) + describe('dev mode', () => { + beforeAll(async () => { + appPort = await findPort() + app = await launchApp(appDir, appPort) + // buildId = 'development' + }) + afterAll(() => killApp(app)) + + runTests() + }) + + describe('production mode', () => { + beforeAll(async () => { + await nextBuild(appDir) + appPort = await findPort() + app = await nextStart(appDir, appPort) + // buildId = await fs.readFile(join(appDir, '.next/BUILD_ID'), 'utf8') + }) + afterAll(() => killApp(app)) + + runTests() }) }) From 32e5f95a337da009f91a42b6608ebb82bb6c7df1 Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Sun, 27 Sep 2020 23:26:25 -0500 Subject: [PATCH 07/12] Update build output test --- test/integration/build-output/test/index.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/integration/build-output/test/index.test.js b/test/integration/build-output/test/index.test.js index 4a394e03cc2ded9..92bf58533a36762 100644 --- a/test/integration/build-output/test/index.test.js +++ b/test/integration/build-output/test/index.test.js @@ -101,7 +101,7 @@ describe('Build Output', () => { expect(parseFloat(err404Size) - 3.5).toBeLessThanOrEqual(0) expect(err404Size.endsWith('kB')).toBe(true) - expect(parseFloat(err404FirstLoad) - 64.1).toBeLessThanOrEqual(0) + expect(parseFloat(err404FirstLoad) - 64.2).toBeLessThanOrEqual(0) expect(err404FirstLoad.endsWith('kB')).toBe(true) expect(parseFloat(sharedByAll) - 60.7).toBeLessThanOrEqual(0) From 4b654e71cd18191ec45b4bf5fb50e853fd702f42 Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Fri, 2 Oct 2020 19:05:53 -0500 Subject: [PATCH 08/12] Add cookie parser for all pages and feature flag more logic --- .../lib/i18n/detect-locale-cookie.ts | 6 +-- .../next/next-server/server/next-server.ts | 37 ++++++++++++++----- 2 files changed, 29 insertions(+), 14 deletions(-) diff --git a/packages/next/next-server/lib/i18n/detect-locale-cookie.ts b/packages/next/next-server/lib/i18n/detect-locale-cookie.ts index 06e78221be80bbf..7358626519012ac 100644 --- a/packages/next/next-server/lib/i18n/detect-locale-cookie.ts +++ b/packages/next/next-server/lib/i18n/detect-locale-cookie.ts @@ -1,14 +1,10 @@ import { IncomingMessage } from 'http' -import cookie from 'next/dist/compiled/cookie' export function detectLocaleCookie(req: IncomingMessage, locales: string[]) { let detectedLocale: string | undefined if (req.headers.cookie && req.headers.cookie.includes('NEXT_LOCALE')) { - const header = req.headers.cookie - const { NEXT_LOCALE } = cookie.parse( - Array.isArray(header) ? header.join(';') : header - ) + const { NEXT_LOCALE } = (req as any).cookies if (locales.some((locale: string) => NEXT_LOCALE === locale)) { detectedLocale = NEXT_LOCALE diff --git a/packages/next/next-server/server/next-server.ts b/packages/next/next-server/server/next-server.ts index c014493179f3edb..83480b0b7005308 100644 --- a/packages/next/next-server/server/next-server.ts +++ b/packages/next/next-server/server/next-server.ts @@ -40,7 +40,13 @@ import { } from '../lib/router/utils' import * as envConfig from '../lib/runtime-config' import { isResSent, NextApiRequest, NextApiResponse } from '../lib/utils' -import { apiResolver, tryGetPreviewData, __ApiPreviewProps } from './api-utils' +import { + apiResolver, + setLazyProp, + getCookieParser, + tryGetPreviewData, + __ApiPreviewProps, +} from './api-utils' import loadConfig, { isTargetLikeServerless } from './config' import pathMatch from '../lib/router/utils/path-match' import { recursiveReadDirSync } from './lib/recursive-readdir-sync' @@ -273,6 +279,8 @@ export default class Server { res: ServerResponse, parsedUrl?: UrlWithParsedQuery ): Promise { + setLazyProp({ req: req as any }, 'cookies', getCookieParser(req)) + // Parse url if parsedUrl not provided if (!parsedUrl || typeof parsedUrl !== 'object') { const url: any = req.url @@ -480,14 +488,21 @@ export default class Server { // re-create page's pathname let pathname = `/${params.path.join('/')}` - const localePathResult = normalizeLocalePath( - pathname, - this.renderOpts.locales - ) + if (this.nextConfig.experimental.i18n) { + const localePathResult = normalizeLocalePath( + pathname, + this.renderOpts.locales + ) + let detectedLocale = detectLocaleCookie( + req, + this.renderOpts.locales! + ) - if (localePathResult.detectedLocale) { - ;(req as any)._nextLocale = localePathResult.detectedLocale - pathname = localePathResult.pathname + if (localePathResult.detectedLocale) { + pathname = localePathResult.pathname + detectedLocale = localePathResult.detectedLocale + } + ;(req as any)._nextLocale = detectedLocale } pathname = getRouteFromAssetPath(pathname, '.json') @@ -1104,7 +1119,11 @@ export default class Server { (path.split(this.buildId).pop() || '/').replace(/\.json$/, '') ) } - return normalizeLocalePath(path, this.renderOpts.locales).pathname + + if (this.nextConfig.experimental.i18n) { + return normalizeLocalePath(path, this.renderOpts.locales).pathname + } + return path } // remove /_next/data prefix from urlPathname so it matches From de940169e1b0079193307be7cef07ab1ff44ff97 Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Fri, 2 Oct 2020 20:29:08 -0500 Subject: [PATCH 09/12] Add more feature flagging, lang setting, and serverless handling --- packages/next/build/entries.ts | 3 + packages/next/build/webpack-config.ts | 3 + .../webpack/loaders/next-serverless-loader.ts | 64 ++++++++++++++++++ packages/next/client/index.tsx | 17 +++-- packages/next/export/worker.ts | 2 + .../next/next-server/lib/router/router.ts | 25 ++++--- packages/next/next-server/lib/utils.ts | 1 + .../next/next-server/server/next-server.ts | 1 + packages/next/next-server/server/render.tsx | 1 + packages/next/pages/_document.tsx | 3 +- test/integration/i18n-support/next.config.js | 1 + .../i18n-support/test/index.test.js | 67 ++++++++++++++++++- test/lib/next-test-utils.js | 4 +- 13 files changed, 174 insertions(+), 18 deletions(-) diff --git a/packages/next/build/entries.ts b/packages/next/build/entries.ts index 4e0b46e64823b60..2964dd6b4cf1ef5 100644 --- a/packages/next/build/entries.ts +++ b/packages/next/build/entries.ts @@ -97,6 +97,9 @@ export function createEntrypoints( loadedEnvFiles: Buffer.from(JSON.stringify(loadedEnvFiles)).toString( 'base64' ), + i18n: config.experimental.i18n + ? JSON.stringify(config.experimental.i18n) + : '', } Object.keys(pages).forEach((page) => { diff --git a/packages/next/build/webpack-config.ts b/packages/next/build/webpack-config.ts index 780677c9f28bbe4..10d7232e30d4f3b 100644 --- a/packages/next/build/webpack-config.ts +++ b/packages/next/build/webpack-config.ts @@ -986,6 +986,9 @@ export default async function getBaseWebpackConfig( ), 'process.env.__NEXT_ROUTER_BASEPATH': JSON.stringify(config.basePath), 'process.env.__NEXT_HAS_REWRITES': JSON.stringify(hasRewrites), + 'process.env.__NEXT_i18n_SUPPORT': JSON.stringify( + !!config.experimental.i18n + ), ...(isServer ? { // Fix bad-actors in the npm ecosystem (e.g. `node-formidable`) diff --git a/packages/next/build/webpack/loaders/next-serverless-loader.ts b/packages/next/build/webpack/loaders/next-serverless-loader.ts index 82e3db7c7d293ce..35f736af40719f5 100644 --- a/packages/next/build/webpack/loaders/next-serverless-loader.ts +++ b/packages/next/build/webpack/loaders/next-serverless-loader.ts @@ -28,6 +28,7 @@ export type ServerlessLoaderQuery = { runtimeConfig: string previewProps: string loadedEnvFiles: string + i18n: string } const vercelHeader = 'x-vercel-id' @@ -49,6 +50,7 @@ const nextServerlessLoader: loader.Loader = function () { runtimeConfig, previewProps, loadedEnvFiles, + i18n, }: ServerlessLoaderQuery = typeof this.query === 'string' ? parse(this.query.substr(1)) : this.query @@ -66,6 +68,8 @@ const nextServerlessLoader: loader.Loader = function () { JSON.parse(previewProps) as __ApiPreviewProps ) + const i18nEnabled = !!i18n + const defaultRouteRegex = pageIsDynamicRoute ? ` const defaultRouteRegex = getRouteRegex("${page}") @@ -207,6 +211,58 @@ const nextServerlessLoader: loader.Loader = function () { ` : '' + const handleLocale = i18nEnabled + ? ` + // get pathname from URL with basePath stripped for locale detection + const i18n = ${i18n} + const accept = require('@hapi/accept') + const { detectLocaleCookie } = require('next/dist/next-server/lib/i18n/detect-locale-cookie') + const { normalizeLocalePath } = require('next/dist/next-server/lib/i18n/normalize-locale-path') + let detectedLocale = detectLocaleCookie(req, i18n.locales) + + if (!detectedLocale) { + detectedLocale = accept.language( + req.headers['accept-language'], + i18n.locales + ) + } + + if ( + !nextStartMode && + i18n.localeDetection !== false && + denormalizePagePath(parsedUrl.pathname || '/') === '/' + ) { + res.setHeader( + 'Location', + formatUrl({ + // make sure to include any query values when redirecting + ...parsedUrl, + pathname: \`/\${detectedLocale}\`, + }) + ) + res.statusCode = 307 + res.end() + } + + // TODO: domain based locales (domain to locale mapping needs to be provided in next.config.js) + const localePathResult = normalizeLocalePath(parsedUrl.pathname, i18n.locales) + + if (localePathResult.detectedLocale) { + detectedLocale = localePathResult.detectedLocale + req.url = formatUrl({ + ...parsedUrl, + pathname: localePathResult.pathname, + }) + parsedUrl.pathname = localePathResult.pathname + } + + detectedLocale = detectedLocale || i18n.defaultLocale + ` + : ` + const i18n = {} + const detectedLocale = undefined + ` + if (page.match(API_ROUTE)) { return ` import initServer from 'next-plugin-loader?middleware=on-init-server!' @@ -300,6 +356,7 @@ const nextServerlessLoader: loader.Loader = function () { const { renderToHTML } = require('next/dist/next-server/server/render'); const { tryGetPreviewData } = require('next/dist/next-server/server/api-utils'); const { denormalizePagePath } = require('next/dist/next-server/server/denormalize-page-path') + const { setLazyProp, getCookieParser } = require('next/dist/next-server/server/api-utils') const {sendPayload} = require('next/dist/next-server/server/send-payload'); const buildManifest = require('${buildManifest}'); const reactLoadableManifest = require('${reactLoadableManifest}'); @@ -333,6 +390,9 @@ const nextServerlessLoader: loader.Loader = function () { export const _app = App export async function renderReqToHTML(req, res, renderMode, _renderOpts, _params) { const fromExport = renderMode === 'export' || renderMode === true; + const nextStartMode = renderMode === 'passthrough' + + setLazyProp({ req }, 'cookies', getCookieParser(req)) const options = { App, @@ -383,12 +443,16 @@ const nextServerlessLoader: loader.Loader = function () { routeNoAssetPath = parsedUrl.pathname } + ${handleLocale} + const renderOpts = Object.assign( { Component, pageConfig: config, nextExport: fromExport, isDataReq: _nextData, + locale: detectedLocale, + locales: i18n.locales, }, options, ) diff --git a/packages/next/client/index.tsx b/packages/next/client/index.tsx index 21e3e2afeedccd1..bd148864714498e 100644 --- a/packages/next/client/index.tsx +++ b/packages/next/client/index.tsx @@ -3,7 +3,6 @@ import '@next/polyfill-module' import React from 'react' import ReactDOM from 'react-dom' import { HeadManagerContext } from '../next-server/lib/head-manager-context' -import { normalizeLocalePath } from '../next-server/lib/i18n/normalize-locale-path' import mitt from '../next-server/lib/mitt' import { RouterContext } from '../next-server/lib/router-context' import type Router from '../next-server/lib/router/router' @@ -90,12 +89,18 @@ if (hasBasePath(asPath)) { asPath = delLocale(asPath, locale) -if (isFallback && locales) { - const localePathResult = normalizeLocalePath(asPath, locales) +if (process.env.__NEXT_i18n_SUPPORT) { + const { + normalizeLocalePath, + } = require('../next-server/lib/i18n/normalize-locale-path') - if (localePathResult.detectedLocale) { - asPath = asPath.substr(localePathResult.detectedLocale.length + 1) - locale = localePathResult.detectedLocale + if (isFallback && locales) { + const localePathResult = normalizeLocalePath(asPath, locales) + + if (localePathResult.detectedLocale) { + asPath = asPath.substr(localePathResult.detectedLocale.length + 1) + locale = localePathResult.detectedLocale + } } } diff --git a/packages/next/export/worker.ts b/packages/next/export/worker.ts index 6e9efdf1a75c876..bc8e8acf05099c8 100644 --- a/packages/next/export/worker.ts +++ b/packages/next/export/worker.ts @@ -239,6 +239,8 @@ export default async function exportPage({ fontManifest: optimizeFonts ? requireFontManifest(distDir, serverless) : null, + locale: renderOpts.locale!, + locales: renderOpts.locales!, }, // @ts-ignore params diff --git a/packages/next/next-server/lib/router/router.ts b/packages/next/next-server/lib/router/router.ts index 0cc9b2d0b4d5b2d..f2614c4545ce378 100644 --- a/packages/next/next-server/lib/router/router.ts +++ b/packages/next/next-server/lib/router/router.ts @@ -56,15 +56,21 @@ function addPathPrefix(path: string, prefix?: string) { } export function addLocale(path: string, locale?: string) { - return locale && !path.startsWith('/' + locale) - ? addPathPrefix(path, '/' + locale) - : path + if (process.env.__NEXT_i18n_SUPPORT) { + return locale && !path.startsWith('/' + locale) + ? addPathPrefix(path, '/' + locale) + : path + } + return path } export function delLocale(path: string, locale?: string) { - return locale && path.startsWith('/' + locale) - ? path.substr(locale.length + 1) || '/' - : path + if (process.env.__NEXT_i18n_SUPPORT) { + return locale && path.startsWith('/' + locale) + ? path.substr(locale.length + 1) || '/' + : path + } + return path } export function hasBasePath(path: string): boolean { @@ -430,8 +436,11 @@ export default class Router implements BaseRouter { this.isSsr = true this.isFallback = isFallback - this.locale = locale - this.locales = locales + + if (process.env.__NEXT_i18n_SUPPORT) { + this.locale = locale + this.locales = locales + } if (typeof window !== 'undefined') { // make sure "as" doesn't start with double slashes or else it can diff --git a/packages/next/next-server/lib/utils.ts b/packages/next/next-server/lib/utils.ts index 78c8e9df1073bec..a65c74eabd1f44c 100644 --- a/packages/next/next-server/lib/utils.ts +++ b/packages/next/next-server/lib/utils.ts @@ -188,6 +188,7 @@ export type DocumentProps = DocumentInitialProps & { headTags: any[] unstable_runtimeJS?: false devOnlyCacheBusterQueryString: string + locale?: string } /** diff --git a/packages/next/next-server/server/next-server.ts b/packages/next/next-server/server/next-server.ts index 83480b0b7005308..ba3055d91c9cc8b 100644 --- a/packages/next/next-server/server/next-server.ts +++ b/packages/next/next-server/server/next-server.ts @@ -1207,6 +1207,7 @@ export default class Server { { fontManifest: this.renderOpts.fontManifest, locale: (req as any)._nextLocale, + locales: this.renderOpts.locales, } ) diff --git a/packages/next/next-server/server/render.tsx b/packages/next/next-server/server/render.tsx index 0ed5f3827396aa9..22d45c812b26e78 100644 --- a/packages/next/next-server/server/render.tsx +++ b/packages/next/next-server/server/render.tsx @@ -281,6 +281,7 @@ function renderDocument( headTags, unstable_runtimeJS, devOnlyCacheBusterQueryString, + locale, ...docProps, })} diff --git a/packages/next/pages/_document.tsx b/packages/next/pages/_document.tsx index 7d3259af7475dfc..715d5b136bc40ea 100644 --- a/packages/next/pages/_document.tsx +++ b/packages/next/pages/_document.tsx @@ -123,7 +123,7 @@ export function Html( HTMLHtmlElement > ) { - const { inAmpMode, docComponentsRendered } = useContext( + const { inAmpMode, docComponentsRendered, locale } = useContext( DocumentComponentContext ) @@ -132,6 +132,7 @@ export function Html( return ( { @@ -84,6 +87,7 @@ function runTests() { }) expect($('#router-locale').text()).toBe('en') expect(JSON.parse($('#router-locales').text())).toEqual(locales) + expect($('html').attr('lang')).toBe('en') }) it('should load getStaticProps fallback non-prerender page correctly', async () => { @@ -107,6 +111,11 @@ function runTests() { expect( JSON.parse(await browser.elementByCss('#router-locales').text()) ).toEqual(locales) + + // TODO: handle updating locale for fallback pages? + // expect( + // await browser.elementByCss('html').getAttribute('lang') + // ).toBe('en-US') }) it('should load getStaticProps fallback non-prerender page another locale correctly', async () => { @@ -153,6 +162,7 @@ function runTests() { expect( JSON.parse(await browser.elementByCss('#router-locales').text()) ).toEqual(locales) + expect(await browser.elementByCss('html').getAttribute('lang')).toBe('en') }) it('should load getStaticProps non-fallback correctly another locale', async () => { @@ -176,6 +186,37 @@ function runTests() { expect( JSON.parse(await browser.elementByCss('#router-locales').text()) ).toEqual(locales) + expect(await browser.elementByCss('html').getAttribute('lang')).toBe( + 'nl-NL' + ) + }) + + it('should load getStaticProps non-fallback correctly another locale via cookie', async () => { + const html = await renderViaHTTP( + appPort, + '/gsp/no-fallback/second', + {}, + { + headers: { + cookie: 'NEXT_LOCALE=nl-NL', + }, + } + ) + const $ = cheerio.load(html) + + expect(JSON.parse($('#props').text())).toEqual({ + locale: 'nl-NL', + locales, + params: { + slug: 'second', + }, + }) + expect(JSON.parse($('#router-query').text())).toEqual({ + slug: 'second', + }) + expect($('#router-locale').text()).toBe('nl-NL') + expect(JSON.parse($('#router-locales').text())).toEqual(locales) + expect($('html').attr('lang')).toBe('nl-NL') }) it('should load getServerSideProps page correctly SSR', async () => { @@ -189,6 +230,7 @@ function runTests() { expect($('#router-locale').text()).toBe('en-US') expect(JSON.parse($('#router-locales').text())).toEqual(locales) expect(JSON.parse($('#router-query').text())).toEqual({}) + expect($('html').attr('lang')).toBe('en-US') const html2 = await renderViaHTTP(appPort, '/nl-NL/gssp') const $2 = cheerio.load(html2) @@ -200,6 +242,7 @@ function runTests() { expect($2('#router-locale').text()).toBe('nl-NL') expect(JSON.parse($2('#router-locales').text())).toEqual(locales) expect(JSON.parse($2('#router-query').text())).toEqual({}) + expect($2('html').attr('lang')).toBe('nl-NL') }) it('should load dynamic getServerSideProps page correctly SSR', async () => { @@ -216,6 +259,7 @@ function runTests() { expect($('#router-locale').text()).toBe('en-US') expect(JSON.parse($('#router-locales').text())).toEqual(locales) expect(JSON.parse($('#router-query').text())).toEqual({ slug: 'first' }) + expect($('html').attr('lang')).toBe('en-US') const html2 = await renderViaHTTP(appPort, '/nl-NL/gssp/first') const $2 = cheerio.load(html2) @@ -230,6 +274,7 @@ function runTests() { expect($2('#router-locale').text()).toBe('nl-NL') expect(JSON.parse($2('#router-locales').text())).toEqual(locales) expect(JSON.parse($2('#router-query').text())).toEqual({ slug: 'first' }) + expect($2('html').attr('lang')).toBe('nl-NL') }) it('should navigate to another page and back correctly with locale', async () => { @@ -299,6 +344,7 @@ function runTests() { describe('i18n Support', () => { describe('dev mode', () => { beforeAll(async () => { + await fs.remove(join(appDir, '.next')) appPort = await findPort() app = await launchApp(appDir, appPort) // buildId = 'development' @@ -310,6 +356,7 @@ describe('i18n Support', () => { describe('production mode', () => { beforeAll(async () => { + await fs.remove(join(appDir, '.next')) await nextBuild(appDir) appPort = await findPort() app = await nextStart(appDir, appPort) @@ -319,4 +366,22 @@ describe('i18n Support', () => { runTests() }) + + describe('serverless mode', () => { + beforeAll(async () => { + await fs.remove(join(appDir, '.next')) + nextConfig.replace('// target', 'target') + + await nextBuild(appDir) + appPort = await findPort() + app = await nextStart(appDir, appPort) + // buildId = await fs.readFile(join(appDir, '.next/BUILD_ID'), 'utf8') + }) + afterAll(async () => { + nextConfig.restore() + await killApp(app) + }) + + runTests() + }) }) diff --git a/test/lib/next-test-utils.js b/test/lib/next-test-utils.js index 7bbe4832d1566c0..8a3fbeffa1b7dd7 100644 --- a/test/lib/next-test-utils.js +++ b/test/lib/next-test-utils.js @@ -70,8 +70,8 @@ export function renderViaAPI(app, pathname, query) { return app.renderToHTML({ url }, {}, pathname, query) } -export function renderViaHTTP(appPort, pathname, query) { - return fetchViaHTTP(appPort, pathname, query).then((res) => res.text()) +export function renderViaHTTP(appPort, pathname, query, opts) { + return fetchViaHTTP(appPort, pathname, query, opts).then((res) => res.text()) } export function fetchViaHTTP(appPort, pathname, query, opts) { From 5a7e29a022978a1748a45dff99493dbc74e8e134 Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Mon, 5 Oct 2020 09:36:16 -0500 Subject: [PATCH 10/12] Ensure default locale is redirected to without accept-language --- .../webpack/loaders/next-serverless-loader.ts | 2 +- .../next/next-server/server/next-server.ts | 7 +++--- .../i18n-support/test/index.test.js | 25 +++++++++++++++++++ 3 files changed, 29 insertions(+), 5 deletions(-) diff --git a/packages/next/build/webpack/loaders/next-serverless-loader.ts b/packages/next/build/webpack/loaders/next-serverless-loader.ts index 7ef24ad2f66ee7c..d531cf46cd1124c 100644 --- a/packages/next/build/webpack/loaders/next-serverless-loader.ts +++ b/packages/next/build/webpack/loaders/next-serverless-loader.ts @@ -229,7 +229,7 @@ const nextServerlessLoader: loader.Loader = function () { detectedLocale = accept.language( req.headers['accept-language'], i18n.locales - ) + ) || i18n.defaultLocale } if ( diff --git a/packages/next/next-server/server/next-server.ts b/packages/next/next-server/server/next-server.ts index ba3055d91c9cc8b..b920d7fc45957b0 100644 --- a/packages/next/next-server/server/next-server.ts +++ b/packages/next/next-server/server/next-server.ts @@ -308,10 +308,9 @@ export default class Server { let detectedLocale = detectLocaleCookie(req, i18n.locales) if (!detectedLocale) { - detectedLocale = accept.language( - req.headers['accept-language'], - i18n.locales - ) + detectedLocale = + accept.language(req.headers['accept-language'], i18n.locales) || + i18n.defaultLocale } if ( diff --git a/test/integration/i18n-support/test/index.test.js b/test/integration/i18n-support/test/index.test.js index 822f85ffdfe4fa0..a09d7aaea3f326a 100644 --- a/test/integration/i18n-support/test/index.test.js +++ b/test/integration/i18n-support/test/index.test.js @@ -58,6 +58,31 @@ function runTests() { expect(parsedUrl2.query).toEqual({ hello: 'world' }) }) + it('should redirect to default locale route for / without accept-language', async () => { + const res = await fetchViaHTTP(appPort, '/', undefined, { + redirect: 'manual', + }) + expect(res.status).toBe(307) + + const parsedUrl = url.parse(res.headers.get('location'), true) + expect(parsedUrl.pathname).toBe('/en') + expect(parsedUrl.query).toEqual({}) + + const res2 = await fetchViaHTTP( + appPort, + '/', + { hello: 'world' }, + { + redirect: 'manual', + } + ) + expect(res2.status).toBe(307) + + const parsedUrl2 = url.parse(res2.headers.get('location'), true) + expect(parsedUrl2.pathname).toBe('/en') + expect(parsedUrl2.query).toEqual({ hello: 'world' }) + }) + it('should load getStaticProps page correctly SSR', async () => { const html = await renderViaHTTP(appPort, '/en-US/gsp') const $ = cheerio.load(html) From 902703124a15c151f0c4cf51c88510b915dde67e Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Tue, 6 Oct 2020 21:38:20 -0500 Subject: [PATCH 11/12] Update to not redirect when default locale matched on root --- .../webpack/loaders/next-serverless-loader.ts | 16 +++- .../lib/i18n/normalize-locale-path.ts | 8 +- packages/next/next-server/server/config.ts | 23 +++++ .../next/next-server/server/next-server.ts | 39 ++++---- .../i18n-support/test/index.test.js | 91 ++++++++++++++++++- 5 files changed, 155 insertions(+), 22 deletions(-) diff --git a/packages/next/build/webpack/loaders/next-serverless-loader.ts b/packages/next/build/webpack/loaders/next-serverless-loader.ts index d531cf46cd1124c..4683e74cc57d692 100644 --- a/packages/next/build/webpack/loaders/next-serverless-loader.ts +++ b/packages/next/build/webpack/loaders/next-serverless-loader.ts @@ -229,24 +229,34 @@ const nextServerlessLoader: loader.Loader = function () { detectedLocale = accept.language( req.headers['accept-language'], i18n.locales - ) || i18n.defaultLocale + ) } + const denormalizedPagePath = denormalizePagePath(parsedUrl.pathname || '/') + const detectedDefaultLocale = detectedLocale === i18n.defaultLocale + const shouldStripDefaultLocale = + detectedDefaultLocale && + denormalizedPagePath === \`/\${i18n.defaultLocale}\` + const shouldAddLocalePrefix = + !detectedDefaultLocale && denormalizedPagePath === '/' + detectedLocale = detectedLocale || i18n.defaultLocale + if ( !nextStartMode && i18n.localeDetection !== false && - denormalizePagePath(parsedUrl.pathname || '/') === '/' + (shouldAddLocalePrefix || shouldStripDefaultLocale) ) { res.setHeader( 'Location', formatUrl({ // make sure to include any query values when redirecting ...parsedUrl, - pathname: \`/\${detectedLocale}\`, + pathname: shouldStripDefaultLocale ? '/' : \`/\${detectedLocale}\`, }) ) res.statusCode = 307 res.end() + return } // TODO: domain based locales (domain to locale mapping needs to be provided in next.config.js) diff --git a/packages/next/next-server/lib/i18n/normalize-locale-path.ts b/packages/next/next-server/lib/i18n/normalize-locale-path.ts index ca88a7277c04daa..a46bb4733407364 100644 --- a/packages/next/next-server/lib/i18n/normalize-locale-path.ts +++ b/packages/next/next-server/lib/i18n/normalize-locale-path.ts @@ -6,10 +6,14 @@ export function normalizeLocalePath( pathname: string } { let detectedLocale: string | undefined + // first item will be empty string from splitting at first char + const pathnameParts = pathname.split('/') + ;(locales || []).some((locale) => { - if (pathname.startsWith(`/${locale}`)) { + if (pathnameParts[1] === locale) { detectedLocale = locale - pathname = pathname.replace(new RegExp(`^/${locale}`), '') || '/' + pathnameParts.splice(1, 1) + pathname = pathnameParts.join('/') || '/' return true } return false diff --git a/packages/next/next-server/server/config.ts b/packages/next/next-server/server/config.ts index 90d8659dbd54669..867433dfbd160e5 100644 --- a/packages/next/next-server/server/config.ts +++ b/packages/next/next-server/server/config.ts @@ -227,12 +227,35 @@ function assignDefaults(userConfig: { [key: string]: any }) { throw new Error(`Specified i18n.defaultLocale should be a string`) } + if (!Array.isArray(i18n.locales)) { + throw new Error( + `Specified i18n.locales must be an array of locale strings e.g. ["en-US", "nl-NL"] received ${typeof i18n.locales}` + ) + } + + const invalidLocales = i18n.locales.filter( + (locale: any) => typeof locale !== 'string' + ) + + if (invalidLocales.length > 0) { + throw new Error( + `Specified i18n.locales contains invalid values, locales must be valid locale tags provided as strings e.g. "en-US".\n` + + `See here for list of valid language sub-tags: http://www.iana.org/assignments/language-subtag-registry/language-subtag-registry` + ) + } + if (!i18n.locales.includes(i18n.defaultLocale)) { throw new Error( `Specified i18n.defaultLocale should be included in i18n.locales` ) } + // make sure default Locale is at the front + i18n.locales = [ + i18n.defaultLocale, + ...i18n.locales.filter((locale: string) => locale !== i18n.defaultLocale), + ] + const localeDetectionType = typeof i18n.locales.localeDetection if ( diff --git a/packages/next/next-server/server/next-server.ts b/packages/next/next-server/server/next-server.ts index b920d7fc45957b0..df23dbc4a90489f 100644 --- a/packages/next/next-server/server/next-server.ts +++ b/packages/next/next-server/server/next-server.ts @@ -302,31 +302,42 @@ export default class Server { req.url = req.url!.replace(basePath, '') || '/' } - if (i18n) { + if (i18n && !parsedUrl.pathname?.startsWith('/_next')) { // get pathname from URL with basePath stripped for locale detection const { pathname, ...parsed } = parseUrl(req.url || '/') let detectedLocale = detectLocaleCookie(req, i18n.locales) if (!detectedLocale) { - detectedLocale = - accept.language(req.headers['accept-language'], i18n.locales) || - i18n.defaultLocale + detectedLocale = accept.language( + req.headers['accept-language'], + i18n.locales + ) } + const denormalizedPagePath = denormalizePagePath(pathname || '/') + const detectedDefaultLocale = detectedLocale === i18n.defaultLocale + const shouldStripDefaultLocale = + detectedDefaultLocale && + denormalizedPagePath === `/${i18n.defaultLocale}` + const shouldAddLocalePrefix = + !detectedDefaultLocale && denormalizedPagePath === '/' + detectedLocale = detectedLocale || i18n.defaultLocale + if ( i18n.localeDetection !== false && - denormalizePagePath(pathname || '/') === '/' + (shouldAddLocalePrefix || shouldStripDefaultLocale) ) { res.setHeader( 'Location', formatUrl({ // make sure to include any query values when redirecting ...parsed, - pathname: `/${detectedLocale}`, + pathname: shouldStripDefaultLocale ? '/' : `/${detectedLocale}`, }) ) res.statusCode = 307 res.end() + return } // TODO: domain based locales (domain to locale mapping needs to be provided in next.config.js) @@ -487,21 +498,17 @@ export default class Server { // re-create page's pathname let pathname = `/${params.path.join('/')}` - if (this.nextConfig.experimental.i18n) { - const localePathResult = normalizeLocalePath( - pathname, - this.renderOpts.locales - ) - let detectedLocale = detectLocaleCookie( - req, - this.renderOpts.locales! - ) + const { i18n } = this.nextConfig.experimental + + if (i18n) { + const localePathResult = normalizeLocalePath(pathname, i18n.locales) + let detectedLocale = detectLocaleCookie(req, i18n.locales) if (localePathResult.detectedLocale) { pathname = localePathResult.pathname detectedLocale = localePathResult.detectedLocale } - ;(req as any)._nextLocale = detectedLocale + ;(req as any)._nextLocale = detectedLocale || i18n.defaultLocale } pathname = getRouteFromAssetPath(pathname, '.json') diff --git a/test/integration/i18n-support/test/index.test.js b/test/integration/i18n-support/test/index.test.js index a09d7aaea3f326a..42b249413e9c713 100644 --- a/test/integration/i18n-support/test/index.test.js +++ b/test/integration/i18n-support/test/index.test.js @@ -24,9 +24,82 @@ let app let appPort // let buildId -const locales = ['nl-NL', 'nl-BE', 'nl', 'en-US', 'en'] +const locales = ['en', 'nl-NL', 'nl-BE', 'nl', 'en-US'] function runTests() { + it('should remove un-necessary locale prefix for default locale', async () => { + const res = await fetchViaHTTP(appPort, '/en', undefined, { + redirect: 'manual', + headers: { + 'Accept-Language': 'en;q=0.9', + }, + }) + + expect(res.status).toBe(307) + + const parsedUrl = url.parse(res.headers.get('location'), true) + + expect(parsedUrl.pathname).toBe('/') + expect(parsedUrl.query).toEqual({}) + }) + + it('should load getStaticProps page correctly SSR (default locale no prefix)', async () => { + const html = await renderViaHTTP(appPort, '/gsp') + const $ = cheerio.load(html) + + expect(JSON.parse($('#props').text())).toEqual({ + locale: 'en', + locales, + }) + expect($('#router-locale').text()).toBe('en') + expect(JSON.parse($('#router-locales').text())).toEqual(locales) + expect($('html').attr('lang')).toBe('en') + }) + + it('should load getStaticProps fallback prerender page correctly SSR (default locale no prefix)', async () => { + const html = await renderViaHTTP(appPort, '/gsp/fallback/first') + const $ = cheerio.load(html) + + expect(JSON.parse($('#props').text())).toEqual({ + locale: 'en', + locales, + params: { + slug: 'first', + }, + }) + expect(JSON.parse($('#router-query').text())).toEqual({ + slug: 'first', + }) + expect($('#router-locale').text()).toBe('en') + expect(JSON.parse($('#router-locales').text())).toEqual(locales) + expect($('html').attr('lang')).toBe('en') + }) + + it('should load getStaticProps fallback non-prerender page correctly (default locale no prefix', async () => { + const browser = await webdriver(appPort, '/gsp/fallback/another') + + await browser.waitForElementByCss('#props') + + expect(JSON.parse(await browser.elementByCss('#props').text())).toEqual({ + locale: 'en', + locales, + params: { + slug: 'another', + }, + }) + expect( + JSON.parse(await browser.elementByCss('#router-query').text()) + ).toEqual({ + slug: 'another', + }) + // TODO: this will be fixed after the fallback is generated for all locales + // instead of delaying populating the locale on the client + // expect(await browser.elementByCss('#router-locale').text()).toBe('en') + expect( + JSON.parse(await browser.elementByCss('#router-locales').text()) + ).toEqual(locales) + }) + it('should redirect to locale prefixed route for /', async () => { const res = await fetchViaHTTP(appPort, '/', undefined, { redirect: 'manual', @@ -143,6 +216,22 @@ function runTests() { // ).toBe('en-US') }) + it('should load getServerSideProps page correctly SSR (default locale no prefix)', async () => { + const html = await renderViaHTTP(appPort, '/gssp') + const $ = cheerio.load(html) + + expect(JSON.parse($('#props').text())).toEqual({ + locale: 'en', + locales, + }) + expect($('#router-locale').text()).toBe('en') + expect(JSON.parse($('#router-locales').text())).toEqual(locales) + expect(JSON.parse($('#router-query').text())).toEqual({}) + expect($('html').attr('lang')).toBe('en') + }) + + // TODO: client navigation tests for default locale with no prefix + it('should load getStaticProps fallback non-prerender page another locale correctly', async () => { const browser = await webdriver(appPort, '/nl-NL/gsp/fallback/another') From 552fc6d863179d9554c37582dd3a200be6cd7cb4 Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Wed, 7 Oct 2020 19:54:11 -0500 Subject: [PATCH 12/12] Make sure default locale isn't prefixed on the client --- .../webpack/loaders/next-serverless-loader.ts | 1 + packages/next/client/index.tsx | 2 + packages/next/client/link.tsx | 4 +- packages/next/client/page-loader.ts | 29 +++- packages/next/client/router.ts | 1 + packages/next/export/index.ts | 7 +- .../next/next-server/lib/router/router.ts | 32 +++- packages/next/next-server/lib/utils.ts | 1 + .../next/next-server/server/next-server.ts | 2 + packages/next/next-server/server/render.tsx | 11 +- test/integration/i18n-support/next.config.js | 2 +- .../i18n-support/test/index.test.js | 140 ++++++++++++++---- test/lib/next-webdriver.js | 1 + 13 files changed, 190 insertions(+), 43 deletions(-) diff --git a/packages/next/build/webpack/loaders/next-serverless-loader.ts b/packages/next/build/webpack/loaders/next-serverless-loader.ts index 4683e74cc57d692..2621f4100528516 100644 --- a/packages/next/build/webpack/loaders/next-serverless-loader.ts +++ b/packages/next/build/webpack/loaders/next-serverless-loader.ts @@ -468,6 +468,7 @@ const nextServerlessLoader: loader.Loader = function () { isDataReq: _nextData, locale: detectedLocale, locales: i18n.locales, + defaultLocale: i18n.defaultLocale, }, options, ) diff --git a/packages/next/client/index.tsx b/packages/next/client/index.tsx index bd148864714498e..e511d8abb294940 100644 --- a/packages/next/client/index.tsx +++ b/packages/next/client/index.tsx @@ -65,6 +65,7 @@ const { isFallback, head: initialHeadData, locales, + defaultLocale, } = data let { locale } = data @@ -317,6 +318,7 @@ export default async (opts: { webpackHMR?: any } = {}) => { render({ App, Component, styleSheets, props, err }), locale, locales, + defaultLocale, }) // call init-client middleware diff --git a/packages/next/client/link.tsx b/packages/next/client/link.tsx index 76b59444dde7081..56da5f68c4c3c38 100644 --- a/packages/next/client/link.tsx +++ b/packages/next/client/link.tsx @@ -332,7 +332,9 @@ function Link(props: React.PropsWithChildren) { // If child is an tag and doesn't have a href attribute, or if the 'passHref' property is // defined, we specify the current 'href', so that repetition is not needed by the user if (props.passHref || (child.type === 'a' && !('href' in child.props))) { - childProps.href = addBasePath(addLocale(as, router && router.locale)) + childProps.href = addBasePath( + addLocale(as, router && router.locale, router && router.defaultLocale) + ) } return React.cloneElement(child, childProps) diff --git a/packages/next/client/page-loader.ts b/packages/next/client/page-loader.ts index 57e8c3374bfb72d..f53dc9a1a61713e 100644 --- a/packages/next/client/page-loader.ts +++ b/packages/next/client/page-loader.ts @@ -203,13 +203,23 @@ export default class PageLoader { * @param {string} href the route href (file-system path) * @param {string} asPath the URL as shown in browser (virtual path); used for dynamic routes */ - getDataHref(href: string, asPath: string, ssg: boolean, locale?: string) { + getDataHref( + href: string, + asPath: string, + ssg: boolean, + locale?: string, + defaultLocale?: string + ) { const { pathname: hrefPathname, query, search } = parseRelativeUrl(href) const { pathname: asPathname } = parseRelativeUrl(asPath) const route = normalizeRoute(hrefPathname) const getHrefForSlug = (path: string) => { - const dataRoute = addLocale(getAssetPathFromRoute(path, '.json'), locale) + const dataRoute = addLocale( + getAssetPathFromRoute(path, '.json'), + locale, + defaultLocale + ) return addBasePath( `/_next/data/${this.buildId}${dataRoute}${ssg ? '' : search}` ) @@ -229,7 +239,12 @@ export default class PageLoader { * @param {string} href the route href (file-system path) * @param {string} asPath the URL as shown in browser (virtual path); used for dynamic routes */ - prefetchData(href: string, asPath: string) { + prefetchData( + href: string, + asPath: string, + locale?: string, + defaultLocale?: string + ) { const { pathname: hrefPathname } = parseRelativeUrl(href) const route = normalizeRoute(hrefPathname) return this.promisedSsgManifest!.then( @@ -237,7 +252,13 @@ export default class PageLoader { // Check if the route requires a data file s.has(route) && // Try to generate data href, noop when falsy - (_dataHref = this.getDataHref(href, asPath, true)) && + (_dataHref = this.getDataHref( + href, + asPath, + true, + locale, + defaultLocale + )) && // noop when data has already been prefetched (dedupe) !document.querySelector( `link[rel="${relPrefetch}"][href^="${_dataHref}"]` diff --git a/packages/next/client/router.ts b/packages/next/client/router.ts index 54a3f65b378f7fa..cff79525bd50f37 100644 --- a/packages/next/client/router.ts +++ b/packages/next/client/router.ts @@ -39,6 +39,7 @@ const urlPropertyFields = [ 'basePath', 'locale', 'locales', + 'defaultLocale', ] const routerEvents = [ 'routeChangeStart', diff --git a/packages/next/export/index.ts b/packages/next/export/index.ts index 056f86c5fb8ee10..cc5f14a7a5ffc00 100644 --- a/packages/next/export/index.ts +++ b/packages/next/export/index.ts @@ -283,6 +283,8 @@ export default async function exportApp( } } + const { i18n } = nextConfig.experimental + // Start the rendering process const renderOpts = { dir, @@ -298,8 +300,9 @@ export default async function exportApp( ampValidatorPath: nextConfig.experimental.amp?.validator || undefined, ampSkipValidation: nextConfig.experimental.amp?.skipValidation || false, ampOptimizerConfig: nextConfig.experimental.amp?.optimizer || undefined, - locales: nextConfig.experimental.i18n?.locales, - locale: nextConfig.experimental.i18n?.defaultLocale, + locales: i18n?.locales, + locale: i18n.defaultLocale, + defaultLocale: i18n.defaultLocale, } const { serverRuntimeConfig, publicRuntimeConfig } = nextConfig diff --git a/packages/next/next-server/lib/router/router.ts b/packages/next/next-server/lib/router/router.ts index f2614c4545ce378..c6d0916aedd2f06 100644 --- a/packages/next/next-server/lib/router/router.ts +++ b/packages/next/next-server/lib/router/router.ts @@ -55,9 +55,13 @@ function addPathPrefix(path: string, prefix?: string) { : path } -export function addLocale(path: string, locale?: string) { +export function addLocale( + path: string, + locale?: string, + defaultLocale?: string +) { if (process.env.__NEXT_i18n_SUPPORT) { - return locale && !path.startsWith('/' + locale) + return locale && locale !== defaultLocale && !path.startsWith('/' + locale) ? addPathPrefix(path, '/' + locale) : path } @@ -246,6 +250,7 @@ export type BaseRouter = { basePath: string locale?: string locales?: string[] + defaultLocale?: string } export type NextRouter = BaseRouter & @@ -356,6 +361,7 @@ export default class Router implements BaseRouter { _shallow?: boolean locale?: string locales?: string[] + defaultLocale?: string static events: MittEmitter = mitt() @@ -375,6 +381,7 @@ export default class Router implements BaseRouter { isFallback, locale, locales, + defaultLocale, }: { subscription: Subscription initialProps: any @@ -387,6 +394,7 @@ export default class Router implements BaseRouter { isFallback: boolean locale?: string locales?: string[] + defaultLocale?: string } ) { // represents the current component key @@ -440,6 +448,7 @@ export default class Router implements BaseRouter { if (process.env.__NEXT_i18n_SUPPORT) { this.locale = locale this.locales = locales + this.defaultLocale = defaultLocale } if (typeof window !== 'undefined') { @@ -596,7 +605,7 @@ export default class Router implements BaseRouter { this.abortComponentLoad(this._inFlightRoute) } - as = addLocale(as, this.locale) + as = addLocale(as, this.locale, this.defaultLocale) const cleanedAs = delLocale( hasBasePath(as) ? delBasePath(as) : as, this.locale @@ -790,7 +799,12 @@ export default class Router implements BaseRouter { } Router.events.emit('beforeHistoryChange', as) - this.changeState(method, url, addLocale(as, this.locale), options) + this.changeState( + method, + url, + addLocale(as, this.locale, this.defaultLocale), + options + ) if (process.env.NODE_ENV !== 'production') { const appComp: any = this.components['/_app'].Component @@ -960,7 +974,8 @@ export default class Router implements BaseRouter { formatWithValidation({ pathname, query }), delBasePath(as), __N_SSG, - this.locale + this.locale, + this.defaultLocale ) } @@ -1117,7 +1132,12 @@ export default class Router implements BaseRouter { const route = removePathTrailingSlash(pathname) await Promise.all([ - this.pageLoader.prefetchData(url, asPath), + this.pageLoader.prefetchData( + url, + asPath, + this.locale, + this.defaultLocale + ), this.pageLoader[options.priority ? 'loadPage' : 'prefetch'](route), ]) } diff --git a/packages/next/next-server/lib/utils.ts b/packages/next/next-server/lib/utils.ts index a65c74eabd1f44c..057188def881c5d 100644 --- a/packages/next/next-server/lib/utils.ts +++ b/packages/next/next-server/lib/utils.ts @@ -103,6 +103,7 @@ export type NEXT_DATA = { head: HeadEntry[] locale?: string locales?: string[] + defaultLocale?: string } /** diff --git a/packages/next/next-server/server/next-server.ts b/packages/next/next-server/server/next-server.ts index df23dbc4a90489f..03e5c8f3bd76fd9 100644 --- a/packages/next/next-server/server/next-server.ts +++ b/packages/next/next-server/server/next-server.ts @@ -140,6 +140,7 @@ export default class Server { optimizeImages: boolean locale?: string locales?: string[] + defaultLocale?: string } private compression?: Middleware private onErrorMiddleware?: ({ err }: { err: Error }) => Promise @@ -193,6 +194,7 @@ export default class Server { : null, optimizeImages: this.nextConfig.experimental.optimizeImages, locales: this.nextConfig.experimental.i18n?.locales, + defaultLocale: this.nextConfig.experimental.i18n?.defaultLocale, } // Only the `publicRuntimeConfig` key is exposed to the client side diff --git a/packages/next/next-server/server/render.tsx b/packages/next/next-server/server/render.tsx index 22d45c812b26e78..b8092979093187e 100644 --- a/packages/next/next-server/server/render.tsx +++ b/packages/next/next-server/server/render.tsx @@ -69,6 +69,7 @@ class ServerRouter implements NextRouter { isFallback: boolean locale?: string locales?: string[] + defaultLocale?: string // TODO: Remove in the next major version, as this would mean the user is adding event listeners in server-side `render` method static events: MittEmitter = mitt() @@ -79,7 +80,8 @@ class ServerRouter implements NextRouter { { isFallback }: { isFallback: boolean }, basePath: string, locale?: string, - locales?: string[] + locales?: string[], + defaultLocale?: string ) { this.route = pathname.replace(/\/$/, '') || '/' this.pathname = pathname @@ -89,6 +91,7 @@ class ServerRouter implements NextRouter { this.basePath = basePath this.locale = locale this.locales = locales + this.defaultLocale = defaultLocale } push(): any { noRouter() @@ -164,6 +167,7 @@ export type RenderOptsPartial = { resolvedAsPath?: string locale?: string locales?: string[] + defaultLocale?: string } export type RenderOpts = LoadComponentsReturnType & RenderOptsPartial @@ -203,6 +207,7 @@ function renderDocument( devOnlyCacheBusterQueryString, locale, locales, + defaultLocale, }: RenderOpts & { props: any docComponentsRendered: DocumentProps['docComponentsRendered'] @@ -251,6 +256,7 @@ function renderDocument( appGip, // whether the _app has getInitialProps locale, locales, + defaultLocale, head: React.Children.toArray(docProps.head || []) .map((elem) => { const { children } = elem?.props @@ -517,7 +523,8 @@ export async function renderToHTML( }, basePath, renderOpts.locale, - renderOpts.locales + renderOpts.locales, + renderOpts.defaultLocale ) const ctx = { err, diff --git a/test/integration/i18n-support/next.config.js b/test/integration/i18n-support/next.config.js index 1fb0f8120d470a0..d759a91263b9fb8 100644 --- a/test/integration/i18n-support/next.config.js +++ b/test/integration/i18n-support/next.config.js @@ -3,7 +3,7 @@ module.exports = { experimental: { i18n: { locales: ['nl-NL', 'nl-BE', 'nl', 'en-US', 'en'], - defaultLocale: 'en', + defaultLocale: 'en-US', }, }, } diff --git a/test/integration/i18n-support/test/index.test.js b/test/integration/i18n-support/test/index.test.js index 42b249413e9c713..dff7a80ad0ddf2b 100644 --- a/test/integration/i18n-support/test/index.test.js +++ b/test/integration/i18n-support/test/index.test.js @@ -24,14 +24,14 @@ let app let appPort // let buildId -const locales = ['en', 'nl-NL', 'nl-BE', 'nl', 'en-US'] +const locales = ['en-US', 'nl-NL', 'nl-BE', 'nl', 'en'] function runTests() { it('should remove un-necessary locale prefix for default locale', async () => { - const res = await fetchViaHTTP(appPort, '/en', undefined, { + const res = await fetchViaHTTP(appPort, '/en-US', undefined, { redirect: 'manual', headers: { - 'Accept-Language': 'en;q=0.9', + 'Accept-Language': 'en-US;q=0.9', }, }) @@ -48,12 +48,12 @@ function runTests() { const $ = cheerio.load(html) expect(JSON.parse($('#props').text())).toEqual({ - locale: 'en', + locale: 'en-US', locales, }) - expect($('#router-locale').text()).toBe('en') + expect($('#router-locale').text()).toBe('en-US') expect(JSON.parse($('#router-locales').text())).toEqual(locales) - expect($('html').attr('lang')).toBe('en') + expect($('html').attr('lang')).toBe('en-US') }) it('should load getStaticProps fallback prerender page correctly SSR (default locale no prefix)', async () => { @@ -61,7 +61,7 @@ function runTests() { const $ = cheerio.load(html) expect(JSON.parse($('#props').text())).toEqual({ - locale: 'en', + locale: 'en-US', locales, params: { slug: 'first', @@ -70,9 +70,9 @@ function runTests() { expect(JSON.parse($('#router-query').text())).toEqual({ slug: 'first', }) - expect($('#router-locale').text()).toBe('en') + expect($('#router-locale').text()).toBe('en-US') expect(JSON.parse($('#router-locales').text())).toEqual(locales) - expect($('html').attr('lang')).toBe('en') + expect($('html').attr('lang')).toBe('en-US') }) it('should load getStaticProps fallback non-prerender page correctly (default locale no prefix', async () => { @@ -81,7 +81,7 @@ function runTests() { await browser.waitForElementByCss('#props') expect(JSON.parse(await browser.elementByCss('#props').text())).toEqual({ - locale: 'en', + locale: 'en-US', locales, params: { slug: 'another', @@ -120,14 +120,14 @@ function runTests() { { redirect: 'manual', headers: { - 'Accept-Language': 'en-US,en;q=0.9', + 'Accept-Language': 'en;q=0.9', }, } ) expect(res2.status).toBe(307) const parsedUrl2 = url.parse(res2.headers.get('location'), true) - expect(parsedUrl2.pathname).toBe('/en-US') + expect(parsedUrl2.pathname).toBe('/en') expect(parsedUrl2.query).toEqual({ hello: 'world' }) }) @@ -138,7 +138,7 @@ function runTests() { expect(res.status).toBe(307) const parsedUrl = url.parse(res.headers.get('location'), true) - expect(parsedUrl.pathname).toBe('/en') + expect(parsedUrl.pathname).toBe('/en-US') expect(parsedUrl.query).toEqual({}) const res2 = await fetchViaHTTP( @@ -152,7 +152,7 @@ function runTests() { expect(res2.status).toBe(307) const parsedUrl2 = url.parse(res2.headers.get('location'), true) - expect(parsedUrl2.pathname).toBe('/en') + expect(parsedUrl2.pathname).toBe('/en-US') expect(parsedUrl2.query).toEqual({ hello: 'world' }) }) @@ -170,11 +170,11 @@ function runTests() { }) it('should load getStaticProps fallback prerender page correctly SSR', async () => { - const html = await renderViaHTTP(appPort, '/en/gsp/fallback/first') + const html = await renderViaHTTP(appPort, '/en-US/gsp/fallback/first') const $ = cheerio.load(html) expect(JSON.parse($('#props').text())).toEqual({ - locale: 'en', + locale: 'en-US', locales, params: { slug: 'first', @@ -183,9 +183,9 @@ function runTests() { expect(JSON.parse($('#router-query').text())).toEqual({ slug: 'first', }) - expect($('#router-locale').text()).toBe('en') + expect($('#router-locale').text()).toBe('en-US') expect(JSON.parse($('#router-locales').text())).toEqual(locales) - expect($('html').attr('lang')).toBe('en') + expect($('html').attr('lang')).toBe('en-US') }) it('should load getStaticProps fallback non-prerender page correctly', async () => { @@ -210,7 +210,8 @@ function runTests() { JSON.parse(await browser.elementByCss('#router-locales').text()) ).toEqual(locales) - // TODO: handle updating locale for fallback pages? + // TODO: this will be fixed after fallback pages are generated + // for all locales // expect( // await browser.elementByCss('html').getAttribute('lang') // ).toBe('en-US') @@ -221,16 +222,99 @@ function runTests() { const $ = cheerio.load(html) expect(JSON.parse($('#props').text())).toEqual({ - locale: 'en', + locale: 'en-US', locales, }) - expect($('#router-locale').text()).toBe('en') + expect($('#router-locale').text()).toBe('en-US') expect(JSON.parse($('#router-locales').text())).toEqual(locales) expect(JSON.parse($('#router-query').text())).toEqual({}) - expect($('html').attr('lang')).toBe('en') + expect($('html').attr('lang')).toBe('en-US') }) - // TODO: client navigation tests for default locale with no prefix + it('should navigate client side for default locale with no prefix', async () => { + const browser = await webdriver(appPort, '/') + // make sure default locale is used in case browser isn't set to + // favor en-US by default + await browser.manage().addCookie({ name: 'NEXT_LOCALE', value: 'en-US' }) + await browser.get(browser.initUrl) + + const checkIndexValues = async () => { + expect(JSON.parse(await browser.elementByCss('#props').text())).toEqual({ + locale: 'en-US', + locales, + }) + expect(await browser.elementByCss('#router-locale').text()).toBe('en-US') + expect( + JSON.parse(await browser.elementByCss('#router-locales').text()) + ).toEqual(locales) + expect( + JSON.parse(await browser.elementByCss('#router-query').text()) + ).toEqual({}) + expect(await browser.elementByCss('#router-pathname').text()).toBe('/') + expect(await browser.elementByCss('#router-as-path').text()).toBe('/') + expect( + url.parse(await browser.eval(() => window.location.href)).pathname + ).toBe('/') + } + + await checkIndexValues() + + await browser.elementByCss('#to-another').click() + await browser.waitForElementByCss('#another') + + expect(JSON.parse(await browser.elementByCss('#props').text())).toEqual({ + locale: 'en-US', + locales, + }) + expect(await browser.elementByCss('#router-locale').text()).toBe('en-US') + expect( + JSON.parse(await browser.elementByCss('#router-locales').text()) + ).toEqual(locales) + expect( + JSON.parse(await browser.elementByCss('#router-query').text()) + ).toEqual({}) + expect(await browser.elementByCss('#router-pathname').text()).toBe( + '/another' + ) + expect(await browser.elementByCss('#router-as-path').text()).toBe( + '/another' + ) + expect( + url.parse(await browser.eval(() => window.location.href)).pathname + ).toBe('/another') + + await browser.elementByCss('#to-index').click() + await browser.waitForElementByCss('#index') + + await checkIndexValues() + + await browser.elementByCss('#to-gsp').click() + await browser.waitForElementByCss('#gsp') + + expect(JSON.parse(await browser.elementByCss('#props').text())).toEqual({ + locale: 'en-US', + locales, + }) + expect(await browser.elementByCss('#router-locale').text()).toBe('en-US') + expect( + JSON.parse(await browser.elementByCss('#router-locales').text()) + ).toEqual(locales) + expect( + JSON.parse(await browser.elementByCss('#router-query').text()) + ).toEqual({}) + expect(await browser.elementByCss('#router-pathname').text()).toBe('/gsp') + expect(await browser.elementByCss('#router-as-path').text()).toBe('/gsp') + expect( + url.parse(await browser.eval(() => window.location.href)).pathname + ).toBe('/gsp') + + await browser.elementByCss('#to-index').click() + await browser.waitForElementByCss('#index') + + await checkIndexValues() + + await browser.manage().deleteCookie('NEXT_LOCALE') + }) it('should load getStaticProps fallback non-prerender page another locale correctly', async () => { const browser = await webdriver(appPort, '/nl-NL/gsp/fallback/another') @@ -256,12 +340,12 @@ function runTests() { }) it('should load getStaticProps non-fallback correctly', async () => { - const browser = await webdriver(appPort, '/en/gsp/no-fallback/first') + const browser = await webdriver(appPort, '/en-US/gsp/no-fallback/first') await browser.waitForElementByCss('#props') expect(JSON.parse(await browser.elementByCss('#props').text())).toEqual({ - locale: 'en', + locale: 'en-US', locales, params: { slug: 'first', @@ -272,11 +356,13 @@ function runTests() { ).toEqual({ slug: 'first', }) - expect(await browser.elementByCss('#router-locale').text()).toBe('en') + expect(await browser.elementByCss('#router-locale').text()).toBe('en-US') expect( JSON.parse(await browser.elementByCss('#router-locales').text()) ).toEqual(locales) - expect(await browser.elementByCss('html').getAttribute('lang')).toBe('en') + expect(await browser.elementByCss('html').getAttribute('lang')).toBe( + 'en-US' + ) }) it('should load getStaticProps non-fallback correctly another locale', async () => { diff --git a/test/lib/next-webdriver.js b/test/lib/next-webdriver.js index e3b8e6ed6c2f2b0..9ad87b8b2237532 100644 --- a/test/lib/next-webdriver.js +++ b/test/lib/next-webdriver.js @@ -167,6 +167,7 @@ export default async (appPort, path, waitHydration = true) => { } const url = `http://${deviceIP}:${appPort}${path}` + browser.initUrl = url console.log(`\n> Loading browser with ${url}\n`) await browser.get(url)