diff --git a/packages/next/build/entries.ts b/packages/next/build/entries.ts index 4e0b46e64823b..2964dd6b4cf1e 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/index.ts b/packages/next/build/index.ts index 50bb0cba59d8b..2f8d819c2a53a 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 6c5e894c59ac4..be53e423c8cd9 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,17 @@ 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) + } else if (defaultLocale) { + entry = `/${defaultLocale}${entry}` + } + + const result = _routeMatcher(cleanedEntry) if (!result) { throw new Error( `The provided path \`${entry}\` does not match the page: \`${page}\`.` @@ -607,7 +620,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 +673,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 +690,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 +780,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/build/webpack-config.ts b/packages/next/build/webpack-config.ts index 780677c9f28bb..10d7232e30d4f 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 e2b67a9cce03d..d531cf46cd112 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}") @@ -212,6 +216,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 + ) || i18n.defaultLocale + } + + 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!' @@ -305,6 +361,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}'); @@ -338,6 +395,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, @@ -388,12 +448,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 7739cb72afffc..bd14886471449 100644 --- a/packages/next/client/index.tsx +++ b/packages/next/client/index.tsx @@ -11,7 +11,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 +64,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 +87,23 @@ if (hasBasePath(asPath)) { asPath = delBasePath(asPath) } +asPath = delLocale(asPath, locale) + +if (process.env.__NEXT_i18n_SUPPORT) { + const { + normalizeLocalePath, + } = require('../next-server/lib/i18n/normalize-locale-path') + + 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 +315,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 c0bd24bb73aba..76b59444dde70 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 && router.locale)) } return React.cloneElement(child, childProps) diff --git a/packages/next/client/page-loader.ts b/packages/next/client/page-loader.ts index 96c1d7f1199c5..57e8c3374bfb7 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 81f10d960936f..54a3f65b378f7 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 9bd7d5bc9c8f3..056f86c5fb8ee 100644 --- a/packages/next/export/index.ts +++ b/packages/next/export/index.ts @@ -298,6 +298,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 eb8734f07119f..bc8e8acf05099 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 @@ -229,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/i18n/detect-locale-cookie.ts b/packages/next/next-server/lib/i18n/detect-locale-cookie.ts new file mode 100644 index 0000000000000..7358626519012 --- /dev/null +++ b/packages/next/next-server/lib/i18n/detect-locale-cookie.ts @@ -0,0 +1,15 @@ +import { IncomingMessage } from 'http' + +export function detectLocaleCookie(req: IncomingMessage, locales: string[]) { + let detectedLocale: string | undefined + + if (req.headers.cookie && req.headers.cookie.includes('NEXT_LOCALE')) { + const { NEXT_LOCALE } = (req as any).cookies + + 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 0000000000000..ca88a7277c04d --- /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 0ef8fde67aa97..f2614c4545ce3 100644 --- a/packages/next/next-server/lib/router/router.ts +++ b/packages/next/next-server/lib/router/router.ts @@ -47,17 +47,39 @@ 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) { + if (process.env.__NEXT_i18n_SUPPORT) { + return locale && !path.startsWith('/' + locale) + ? addPathPrefix(path, '/' + locale) + : path + } + return path +} + +export function delLocale(path: string, locale?: string) { + 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 { return path === basePath || path.startsWith(basePath + '/') } 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 { @@ -222,6 +244,8 @@ export type BaseRouter = { query: ParsedUrlQuery asPath: string basePath: string + locale?: string + locales?: string[] } export type NextRouter = BaseRouter & @@ -330,6 +354,8 @@ export default class Router implements BaseRouter { isFallback: boolean _inFlightRoute?: string _shallow?: boolean + locale?: string + locales?: string[] static events: MittEmitter = mitt() @@ -347,6 +373,8 @@ export default class Router implements BaseRouter { err, subscription, isFallback, + locale, + locales, }: { subscription: Subscription initialProps: any @@ -357,6 +385,8 @@ export default class Router implements BaseRouter { wrapApp: (App: AppComponent) => any err?: Error isFallback: boolean + locale?: string + locales?: string[] } ) { // represents the current component key @@ -407,6 +437,11 @@ export default class Router implements BaseRouter { this.isFallback = isFallback + 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 // throw an error as it's considered invalid @@ -561,7 +596,11 @@ export default class Router implements BaseRouter { this.abortComponentLoad(this._inFlightRoute) } - const cleanedAs = hasBasePath(as) ? delBasePath(as) : as + as = addLocale(as, this.locale) + const cleanedAs = delLocale( + hasBasePath(as) ? delBasePath(as) : as, + this.locale + ) this._inFlightRoute = as // If the url change is only related to a hash change @@ -650,7 +689,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 +790,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 +959,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 87d5bd01d2d44..a65c74eabd1f4 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[] } /** @@ -186,6 +188,7 @@ export type DocumentProps = DocumentInitialProps & { headTags: any[] unstable_runtimeJS?: false devOnlyCacheBusterQueryString: string + locale?: string } /** diff --git a/packages/next/next-server/server/config.ts b/packages/next/next-server/server/config.ts index 0f0ef8d5fab4a..90d8659dbd546 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,44 @@ 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 0cae9e9040b76..b920d7fc45957 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' @@ -69,6 +75,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' import * as Log from '../../build/output/log' const getCustomRouteMatcher = pathMatch(true) @@ -129,6 +138,8 @@ export default class Server { optimizeFonts: boolean fontManifest: FontManifest optimizeImages: boolean + locale?: string + locales?: string[] } private compression?: Middleware private onErrorMiddleware?: ({ err }: { err: Error }) => Promise @@ -181,6 +192,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 @@ -267,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 @@ -279,6 +293,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 @@ -287,6 +302,48 @@ 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) || + i18n.defaultLocale + } + + 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) @@ -428,10 +485,25 @@ export default class Server { } // re-create page's pathname - const pathname = getRouteFromAssetPath( - `/${params.path.join('/')}`, - '.json' - ) + let pathname = `/${params.path.join('/')}` + + if (this.nextConfig.experimental.i18n) { + const localePathResult = normalizeLocalePath( + pathname, + this.renderOpts.locales + ) + let detectedLocale = detectLocaleCookie( + req, + this.renderOpts.locales! + ) + + if (localePathResult.detectedLocale) { + pathname = localePathResult.pathname + detectedLocale = localePathResult.detectedLocale + } + ;(req as any)._nextLocale = detectedLocale + } + pathname = getRouteFromAssetPath(pathname, '.json') const parsedUrl = parseUrl(pathname, true) @@ -1046,6 +1118,10 @@ export default class Server { (path.split(this.buildId).pop() || '/').replace(/\.json$/, '') ) } + + if (this.nextConfig.experimental.i18n) { + return normalizeLocalePath(path, this.renderOpts.locales).pathname + } return path } @@ -1056,10 +1132,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 @@ -1125,6 +1205,8 @@ export default class Server { 'passthrough', { fontManifest: this.renderOpts.fontManifest, + locale: (req as any)._nextLocale, + locales: this.renderOpts.locales, } ) @@ -1144,6 +1226,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 @@ -1208,7 +1291,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/next-server/server/render.tsx b/packages/next/next-server/server/render.tsx index 6cefc51b078bf..22d45c812b26e 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 @@ -269,6 +281,7 @@ function renderDocument( headTags, unstable_runtimeJS, devOnlyCacheBusterQueryString, + locale, ...docProps, })} @@ -487,6 +500,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 +515,9 @@ export async function renderToHTML( { isFallback: isFallback, }, - basePath + basePath, + renderOpts.locale, + renderOpts.locales ) const ctx = { err, @@ -581,6 +599,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 +716,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 f2537ef58b04d..97faad5ba6ddd 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/env": "9.5.4", "@next/polyfill-module": "9.5.4", "@next/react-dev-overlay": "9.5.4", diff --git a/packages/next/pages/_document.tsx b/packages/next/pages/_document.tsx index 7d3259af7475d..715d5b136bc40 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 ( { 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 77da28a2c4b38..ffd2e02866c01 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/packages/next/types/index.d.ts b/packages/next/types/index.d.ts index 7416c841dd3f6..3b26f7f10b386 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/build-output/test/index.test.js b/test/integration/build-output/test/index.test.js index b8970b78e32a8..92bf58533a367 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.2).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')) { diff --git a/test/integration/i18n-support/next.config.js b/test/integration/i18n-support/next.config.js new file mode 100644 index 0000000000000..1fb0f8120d470 --- /dev/null +++ b/test/integration/i18n-support/next.config.js @@ -0,0 +1,9 @@ +module.exports = { + // target: 'experimental-serverless-trace', + 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 0000000000000..0713a50be1bbb --- /dev/null +++ b/test/integration/i18n-support/pages/another.js @@ -0,0 +1,31 @@ +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)}

+

{router.pathname}

+

{router.asPath}

+ +
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 0000000000000..ec236891864f7 --- /dev/null +++ b/test/integration/i18n-support/pages/gsp/fallback/[slug].js @@ -0,0 +1,44 @@ +import Link from 'next/link' +import { useRouter } from 'next/router' + +export default function Page(props) { + const router = useRouter() + + if (router.isFallback) return 'Loading...' + + return ( + <> +

gsp page

+

{JSON.stringify(props)}

+

{router.locale}

+

{JSON.stringify(router.locales)}

+

{JSON.stringify(router.query)}

+

{router.pathname}

+

{router.asPath}

+ + to / + +
+ + ) +} + +export const getStaticProps = ({ params, locale, locales }) => { + return { + props: { + 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 }, + })), + 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 0000000000000..8c573d748dcc0 --- /dev/null +++ b/test/integration/i18n-support/pages/gsp/index.js @@ -0,0 +1,32 @@ +import Link from 'next/link' +import { useRouter } from 'next/router' + +export default function Page(props) { + const router = useRouter() + + return ( + <> +

gsp page

+

{JSON.stringify(props)}

+

{router.locale}

+

{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: { + 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 0000000000000..2df6728803f7a --- /dev/null +++ b/test/integration/i18n-support/pages/gsp/no-fallback/[slug].js @@ -0,0 +1,46 @@ +import Link from 'next/link' +import { useRouter } from 'next/router' + +export default function Page(props) { + const router = useRouter() + + if (router.isFallback) return 'Loading...' + + return ( + <> +

gsp page

+

{JSON.stringify(props)}

+

{router.locale}

+

{JSON.stringify(router.locales)}

+

{JSON.stringify(router.query)}

+

{router.pathname}

+

{router.asPath}

+ + to / + +
+ + ) +} + +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 0000000000000..759937cc84c8e --- /dev/null +++ b/test/integration/i18n-support/pages/gssp/[slug].js @@ -0,0 +1,32 @@ +import Link from 'next/link' +import { useRouter } from 'next/router' + +export default function Page(props) { + const router = useRouter() + + return ( + <> +

gssp page

+

{JSON.stringify(props)}

+

{router.locale}

+

{JSON.stringify(router.locales)}

+

{JSON.stringify(router.query)}

+

{router.pathname}

+

{router.asPath}

+ + to / + +
+ + ) +} + +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 0000000000000..6919f3548f7cb --- /dev/null +++ b/test/integration/i18n-support/pages/gssp/index.js @@ -0,0 +1,31 @@ +import Link from 'next/link' +import { useRouter } from 'next/router' + +export default function Page(props) { + const router = useRouter() + + return ( + <> +

gssp page

+

{JSON.stringify(props)}

+

{router.locale}

+

{JSON.stringify(router.locales)}

+

{JSON.stringify(router.query)}

+

{router.pathname}

+

{router.asPath}

+ + to / + +
+ + ) +} + +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 0000000000000..649d86f6a3d91 --- /dev/null +++ b/test/integration/i18n-support/pages/index.js @@ -0,0 +1,55 @@ +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)}

+

{router.pathname}

+

{router.asPath}

+ + to /another + +
+ + to /gsp + +
+ + 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 0000000000000..a09d7aaea3f32 --- /dev/null +++ b/test/integration/i18n-support/test/index.test.js @@ -0,0 +1,412 @@ +/* eslint-env jest */ + +import url from 'url' +import fs from 'fs-extra' +import cheerio from 'cheerio' +import { join } from 'path' +import webdriver from 'next-webdriver' +import { + fetchViaHTTP, + findPort, + killApp, + launchApp, + nextBuild, + nextStart, + renderViaHTTP, + File, +} from 'next-test-utils' + +jest.setTimeout(1000 * 60 * 2) + +const appDir = join(__dirname, '../') +const nextConfig = new File(join(appDir, 'next.config.js')) +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 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) + + 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($('html').attr('lang')).toBe('en-US') + }) + + 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) + expect($('html').attr('lang')).toBe('en') + }) + + 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) + + // 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 () => { + 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) + expect(await browser.elementByCss('html').getAttribute('lang')).toBe('en') + }) + + 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) + 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 () => { + 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({}) + expect($('html').attr('lang')).toBe('en-US') + + 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({}) + expect($2('html').attr('lang')).toBe('nl-NL') + }) + + 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' }) + expect($('html').attr('lang')).toBe('en-US') + + 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' }) + expect($2('html').attr('lang')).toBe('nl-NL') + }) + + 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', () => { + describe('dev mode', () => { + beforeAll(async () => { + await fs.remove(join(appDir, '.next')) + appPort = await findPort() + app = await launchApp(appDir, appPort) + // buildId = 'development' + }) + afterAll(() => killApp(app)) + + runTests() + }) + + describe('production mode', () => { + beforeAll(async () => { + await fs.remove(join(appDir, '.next')) + 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() + }) + + 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/integration/size-limit/test/index.test.js b/test/integration/size-limit/test/index.test.js index 69d1d60c16753..02b215d6ad01b 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 }) diff --git a/test/lib/next-test-utils.js b/test/lib/next-test-utils.js index 7bbe4832d1566..8a3fbeffa1b7d 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) { diff --git a/yarn.lock b/yarn.lock index 30c492a6a51f7..2ac55160d99d7 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"