From 7559d95d408745f6176b2802d6a238976ae25f54 Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Sat, 26 Sep 2020 00:02:49 -0500 Subject: [PATCH 01/10] 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 60d8b35d0339..8b105b65d324 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 6c5e894c59ac..eda0bde16d05 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 7739cb72afff..21e3e2afeedc 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 c0bd24bb73ab..3924a1874feb 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 96c1d7f1199c..57e8c3374bfb 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 81f10d960936..54a3f65b378f 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 0ad982377828..69d8417e205e 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 eb8734f07119..6e9efdf1a75c 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 000000000000..06e78221be80 --- /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 000000000000..ca88a7277c04 --- /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 0ef8fde67aa9..139a996625ce 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 9d2770362f3f..dc41662a903b 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 3de166a54890..987851913ed1 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 268a3c9f11d2..9d9d329ff46b 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 6cefc51b078b..0ed5f3827396 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 b7cb5a038bc5..339e1b32a4b0 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 7416c841dd3f..3b26f7f10b38 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 000000000000..0762a5e45ebb --- /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 000000000000..05d544c99934 --- /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 000000000000..e1605de88d31 --- /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 000000000000..0d53b8f0ca72 --- /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 000000000000..7ee262c96478 --- /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 000000000000..cc2274a913b6 --- /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 000000000000..e7f80f291727 --- /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 000000000000..3706a9a21cde --- /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 000000000000..5b0d53598347 --- /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 30c492a6a51f..2ac55160d99d 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/10] 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 139a996625ce..bbb7562ebe3f 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/10] 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 69d1d60c1675..02b215d6ad01 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/10] 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 b8970b78e32a..4a394e03cc2d 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/10] 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 3924a1874feb..76b59444dde7 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 bbb7562ebe3f..181a4a801b2d 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/10] 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 eda0bde16d05..be53e423c8cd 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 181a4a801b2d..0cc9b2d0b4d5 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 b28ad94a77ad..c014493179f3 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 8bddcb130f02..d2b70721c3c0 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 77da28a2c4b3..ffd2e02866c0 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 05d544c99934..0713a50be1bb 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 e1605de88d31..ec236891864f 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 0d53b8f0ca72..8c573d748dcc 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 7ee262c96478..2df6728803f7 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 cc2274a913b6..759937cc84c8 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 e7f80f291727..6919f3548f7c 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 3706a9a21cde..649d86f6a3d9 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 5b0d53598347..dd390acec6d5 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/10] 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 4a394e03cc2d..92bf58533a36 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/10] 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 06e78221be80..735862651901 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 c014493179f3..83480b0b7005 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/10] 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 4e0b46e64823..2964dd6b4cf1 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 780677c9f28b..10d7232e30d4 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 82e3db7c7d29..35f736af4071 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 21e3e2afeedc..bd1488647144 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 6e9efdf1a75c..bc8e8acf0509 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 0cc9b2d0b4d5..f2614c4545ce 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 78c8e9df1073..a65c74eabd1f 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 83480b0b7005..ba3055d91c9c 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 0ed5f3827396..22d45c812b26 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 7d3259af7475..715d5b136bc4 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 7bbe4832d156..8a3fbeffa1b7 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/10] 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 7ef24ad2f66e..d531cf46cd11 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 ba3055d91c9c..b920d7fc4595 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 822f85ffdfe4..a09d7aaea3f3 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)