From de940169e1b0079193307be7cef07ab1ff44ff97 Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Fri, 2 Oct 2020 20:29:08 -0500 Subject: [PATCH] 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 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/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 82e3db7c7d293..35f736af40719 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 21e3e2afeedcc..bd14886471449 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 6e9efdf1a75c8..bc8e8acf05099 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 0cc9b2d0b4d5b..f2614c4545ce3 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 78c8e9df1073b..a65c74eabd1f4 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 83480b0b70053..ba3055d91c9cc 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 0ed5f3827396a..22d45c812b26e 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 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 ( { @@ -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 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) {