From 2170dfd1e3826c2f3ece4ccf06013f1ce48fb84b Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Fri, 9 Oct 2020 04:13:05 -0500 Subject: [PATCH] Update to generate auto static pages with all locales (#17730) Follow-up PR to #17370 this adds generating auto-export, non-dynamic SSG, and fallback pages with all locales. Dynamic SSG pages still control which locales the pages are generated with using `getStaticPaths`. To further control which locales non-dynamic SSG pages will be prerendered with a follow-up PR adding handling for 404 behavior from `getStaticProps` will be needed. x-ref: https://github.com/vercel/next.js/issues/17110 --- packages/next/build/index.ts | 109 ++++++++++++++++-- .../webpack/loaders/next-serverless-loader.ts | 1 + packages/next/export/worker.ts | 26 +++-- .../next/next-server/server/next-server.ts | 36 ++++-- packages/next/next-server/server/render.tsx | 4 +- .../i18n-support/pages/auto-export/index.js | 21 ++++ test/integration/i18n-support/pages/index.js | 9 -- .../i18n-support/test/index.test.js | 67 ++++++++--- 8 files changed, 219 insertions(+), 54 deletions(-) create mode 100644 test/integration/i18n-support/pages/auto-export/index.js diff --git a/packages/next/build/index.ts b/packages/next/build/index.ts index 2f8d819c2a53a..e826708786e43 100644 --- a/packages/next/build/index.ts +++ b/packages/next/build/index.ts @@ -725,6 +725,8 @@ export default async function build( // n.b. we cannot handle this above in combinedPages because the dynamic // page must be in the `pages` array, but not in the mapping. exportPathMap: (defaultMap: any) => { + const { i18n } = config.experimental + // Dynamically routed pages should be prerendered to be used as // a client-side skeleton (fallback) while data is being fetched. // This ensures the end-user never sees a 500 or slow response from the @@ -738,7 +740,14 @@ export default async function build( if (ssgStaticFallbackPages.has(page)) { // Override the rendering for the dynamic page to be treated as a // fallback render. - defaultMap[page] = { page, query: { __nextFallback: true } } + if (i18n) { + defaultMap[`/${i18n.defaultLocale}${page}`] = { + page, + query: { __nextFallback: true }, + } + } else { + defaultMap[page] = { page, query: { __nextFallback: true } } + } } else { // Remove dynamically routed pages from the default path map when // fallback behavior is disabled. @@ -760,6 +769,39 @@ export default async function build( } } + if (i18n) { + for (const page of [ + ...staticPages, + ...ssgPages, + ...(useStatic404 ? ['/404'] : []), + ]) { + const isSsg = ssgPages.has(page) + const isDynamic = isDynamicRoute(page) + const isFallback = isSsg && ssgStaticFallbackPages.has(page) + + for (const locale of i18n.locales) { + if (!isSsg && locale === i18n.defaultLocale) continue + // skip fallback generation for SSG pages without fallback mode + if (isSsg && isDynamic && !isFallback) continue + const outputPath = `/${locale}${page === '/' ? '' : page}` + + defaultMap[outputPath] = { + page: defaultMap[page].page, + query: { __nextLocale: locale }, + } + + if (isFallback) { + defaultMap[outputPath].query.__nextFallback = true + } + } + + if (isSsg && !isFallback) { + // remove non-locale prefixed variant from defaultMap + delete defaultMap[page] + } + } + } + return defaultMap }, trailingSlash: false, @@ -786,7 +828,8 @@ export default async function build( page: string, file: string, isSsg: boolean, - ext: 'html' | 'json' + ext: 'html' | 'json', + additionalSsgFile = false ) => { file = `${file}.${ext}` const orig = path.join(exportOptions.outdir, file) @@ -820,8 +863,58 @@ export default async function build( if (!isSsg) { pagesManifest[page] = relativeDest } - await promises.mkdir(path.dirname(dest), { recursive: true }) - await promises.rename(orig, dest) + + const { i18n } = config.experimental + + // for SSG files with i18n the non-prerendered variants are + // output with the locale prefixed so don't attempt moving + // without the prefix + if (!i18n || !isSsg || additionalSsgFile) { + await promises.mkdir(path.dirname(dest), { recursive: true }) + await promises.rename(orig, dest) + } + + if (i18n) { + if (additionalSsgFile) return + + for (const locale of i18n.locales) { + // auto-export default locale files exist at root + // TODO: should these always be prefixed with locale + // similar to SSG prerender/fallback files? + if (!isSsg && locale === i18n.defaultLocale) { + continue + } + + const localeExt = page === '/' ? path.extname(file) : '' + const relativeDestNoPages = relativeDest.substr('pages/'.length) + + const updatedRelativeDest = path.join( + 'pages', + locale + localeExt, + // if it's the top-most index page we want it to be locale.EXT + // instead of locale/index.html + page === '/' ? '' : relativeDestNoPages + ) + const updatedOrig = path.join( + exportOptions.outdir, + locale + localeExt, + page === '/' ? '' : file + ) + const updatedDest = path.join( + distDir, + isLikeServerless ? SERVERLESS_DIRECTORY : SERVER_DIRECTORY, + updatedRelativeDest + ) + + if (!isSsg) { + pagesManifest[ + `/${locale}${page === '/' ? '' : page}` + ] = updatedRelativeDest + } + await promises.mkdir(path.dirname(updatedDest), { recursive: true }) + await promises.rename(updatedOrig, updatedDest) + } + } } // Only move /404 to /404 when there is no custom 404 as in that case we don't know about the 404 page @@ -877,13 +970,13 @@ export default async function build( const extraRoutes = additionalSsgPaths.get(page) || [] for (const route of extraRoutes) { const pageFile = normalizePagePath(route) - await moveExportedPage(page, route, pageFile, true, 'html') - await moveExportedPage(page, route, pageFile, true, 'json') + await moveExportedPage(page, route, pageFile, true, 'html', true) + await moveExportedPage(page, route, pageFile, true, 'json', true) if (hasAmp) { const ampPage = `${pageFile}.amp` - await moveExportedPage(page, ampPage, ampPage, true, 'html') - await moveExportedPage(page, ampPage, ampPage, true, 'json') + await moveExportedPage(page, ampPage, ampPage, true, 'html', true) + await moveExportedPage(page, ampPage, ampPage, true, 'json', true) } finalPrerenderRoutes[route] = { diff --git a/packages/next/build/webpack/loaders/next-serverless-loader.ts b/packages/next/build/webpack/loaders/next-serverless-loader.ts index 2621f41005285..587bb631bf02a 100644 --- a/packages/next/build/webpack/loaders/next-serverless-loader.ts +++ b/packages/next/build/webpack/loaders/next-serverless-loader.ts @@ -242,6 +242,7 @@ const nextServerlessLoader: loader.Loader = function () { detectedLocale = detectedLocale || i18n.defaultLocale if ( + !fromExport && !nextStartMode && i18n.localeDetection !== false && (shouldAddLocalePrefix || shouldStripDefaultLocale) diff --git a/packages/next/export/worker.ts b/packages/next/export/worker.ts index bc8e8acf05099..adc7a93d10c61 100644 --- a/packages/next/export/worker.ts +++ b/packages/next/export/worker.ts @@ -100,14 +100,21 @@ export default async function exportPage({ const { page } = pathMap const filePath = normalizePagePath(path) const ampPath = `${filePath}.amp` + const isDynamic = isDynamicRoute(page) let query = { ...originalQuery } let params: { [key: string]: string | string[] } | undefined - const localePathResult = normalizeLocalePath(path, renderOpts.locales) + let updatedPath = path + let locale = query.__nextLocale || renderOpts.locale + delete query.__nextLocale - if (localePathResult.detectedLocale) { - path = localePathResult.pathname - renderOpts.locale = localePathResult.detectedLocale + if (renderOpts.locale) { + const localePathResult = normalizeLocalePath(path, renderOpts.locales) + + if (localePathResult.detectedLocale) { + updatedPath = localePathResult.pathname + locale = localePathResult.detectedLocale + } } // We need to show a warning if they try to provide query values @@ -122,8 +129,8 @@ export default async function exportPage({ } // Check if the page is a specified dynamic route - if (isDynamicRoute(page) && page !== path) { - params = getRouteMatcher(getRouteRegex(page))(path) || undefined + if (isDynamic && page !== path) { + params = getRouteMatcher(getRouteRegex(page))(updatedPath) || undefined if (params) { // we have to pass these separately for serverless if (!serverless) { @@ -134,7 +141,7 @@ export default async function exportPage({ } } else { throw new Error( - `The provided export path '${path}' doesn't match the '${page}' page.\nRead more: https://err.sh/vercel/next.js/export-path-mismatch` + `The provided export path '${updatedPath}' doesn't match the '${page}' page.\nRead more: https://err.sh/vercel/next.js/export-path-mismatch` ) } } @@ -149,7 +156,7 @@ export default async function exportPage({ } const req = ({ - url: path, + url: updatedPath, ...headerMocks, } as unknown) as IncomingMessage const res = ({ @@ -239,7 +246,7 @@ export default async function exportPage({ fontManifest: optimizeFonts ? requireFontManifest(distDir, serverless) : null, - locale: renderOpts.locale!, + locale: locale!, locales: renderOpts.locales!, }, // @ts-ignore @@ -298,6 +305,7 @@ export default async function exportPage({ fontManifest: optimizeFonts ? requireFontManifest(distDir, serverless) : null, + locale: locale as string, } // @ts-ignore html = await renderMethod(req, res, page, query, curRenderOpts) diff --git a/packages/next/next-server/server/next-server.ts b/packages/next/next-server/server/next-server.ts index 03e5c8f3bd76f..dfdba9b637a32 100644 --- a/packages/next/next-server/server/next-server.ts +++ b/packages/next/next-server/server/next-server.ts @@ -354,7 +354,7 @@ export default class Server { parsedUrl.pathname = localePathResult.pathname } - ;(req as any)._nextLocale = detectedLocale || i18n.defaultLocale + parsedUrl.query.__nextLocale = detectedLocale || i18n.defaultLocale } res.statusCode = 200 @@ -510,7 +510,7 @@ export default class Server { pathname = localePathResult.pathname detectedLocale = localePathResult.detectedLocale } - ;(req as any)._nextLocale = detectedLocale || i18n.defaultLocale + _parsedUrl.query.__nextLocale = detectedLocale || i18n.defaultLocale } pathname = getRouteFromAssetPath(pathname, '.json') @@ -1015,11 +1015,21 @@ export default class Server { query: ParsedUrlQuery = {}, params: Params | null = null ): Promise { - const paths = [ + let paths = [ // try serving a static AMP version first query.amp ? normalizePagePath(pathname) + '.amp' : null, pathname, ].filter(Boolean) + + if (query.__nextLocale) { + paths = [ + ...paths.map( + (path) => `/${query.__nextLocale}${path === '/' ? '' : path}` + ), + ...paths, + ] + } + for (const pagePath of paths) { try { const components = await loadComponents( @@ -1031,7 +1041,11 @@ export default class Server { components, query: { ...(components.getStaticProps - ? { _nextDataReq: query._nextDataReq, amp: query.amp } + ? { + amp: query.amp, + _nextDataReq: query._nextDataReq, + __nextLocale: query.__nextLocale, + } : query), ...(params || {}), }, @@ -1141,7 +1155,8 @@ export default class Server { urlPathname = stripNextDataPath(urlPathname) } - const locale = (req as any)._nextLocale + const locale = query.__nextLocale as string + delete query.__nextLocale const ssgCacheKey = isPreviewMode || !isSSG @@ -1214,7 +1229,7 @@ export default class Server { 'passthrough', { fontManifest: this.renderOpts.fontManifest, - locale: (req as any)._nextLocale, + locale, locales: this.renderOpts.locales, } ) @@ -1235,7 +1250,7 @@ export default class Server { ...opts, isDataReq, resolvedUrl, - locale: (req as any)._nextLocale, + locale, // For getServerSideProps we need to ensure we use the original URL // and not the resolved URL to prevent a hydration mismatch on // asPath @@ -1321,7 +1336,9 @@ export default class Server { // Production already emitted the fallback as static HTML. if (isProduction) { - html = await this.incrementalCache.getFallback(pathname) + html = await this.incrementalCache.getFallback( + locale ? `/${locale}${pathname}` : pathname + ) } // We need to generate the fallback on-demand for development. else { @@ -1442,7 +1459,6 @@ export default class Server { res.statusCode = 500 return await this.renderErrorToHTML(err, req, res, pathname, query) } - res.statusCode = 404 return await this.renderErrorToHTML(null, req, res, pathname, query) } @@ -1488,7 +1504,7 @@ export default class Server { // use static 404 page if available and is 404 response if (is404) { - result = await this.findPageComponents('/404') + result = await this.findPageComponents('/404', query) using404Page = result !== null } diff --git a/packages/next/next-server/server/render.tsx b/packages/next/next-server/server/render.tsx index b809297909318..332823e52612a 100644 --- a/packages/next/next-server/server/render.tsx +++ b/packages/next/next-server/server/render.tsx @@ -413,6 +413,7 @@ export async function renderToHTML( const isFallback = !!query.__nextFallback delete query.__nextFallback + delete query.__nextLocale const isSSG = !!getStaticProps const isBuildTimeSSG = isSSG && renderOpts.nextExport @@ -506,9 +507,6 @@ 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 diff --git a/test/integration/i18n-support/pages/auto-export/index.js b/test/integration/i18n-support/pages/auto-export/index.js new file mode 100644 index 0000000000000..1cfb4bf3cb216 --- /dev/null +++ b/test/integration/i18n-support/pages/auto-export/index.js @@ -0,0 +1,21 @@ +import Link from 'next/link' +import { useRouter } from 'next/router' + +export default function Page(props) { + const router = useRouter() + + return ( + <> +

auto-export page

+

{JSON.stringify(props)}

+

{router.locale}

+

{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 649d86f6a3d91..97bb43f1520ad 100644 --- a/test/integration/i18n-support/pages/index.js +++ b/test/integration/i18n-support/pages/index.js @@ -44,12 +44,3 @@ export default function Page(props) { ) } - -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 index dff7a80ad0ddf..117fb7bc9ffcd 100644 --- a/test/integration/i18n-support/test/index.test.js +++ b/test/integration/i18n-support/test/index.test.js @@ -27,6 +27,52 @@ let appPort const locales = ['en-US', 'nl-NL', 'nl-BE', 'nl', 'en'] function runTests() { + it('should generate fallbacks with all locales', async () => { + for (const locale of locales) { + const html = await renderViaHTTP( + appPort, + `/${locale}/gsp/fallback/${Math.random()}` + ) + const $ = cheerio.load(html) + expect($('html').attr('lang')).toBe(locale) + } + }) + + it('should generate auto-export page with all locales', async () => { + for (const locale of locales) { + const html = await renderViaHTTP(appPort, `/${locale}`) + const $ = cheerio.load(html) + expect($('html').attr('lang')).toBe(locale) + expect($('#router-locale').text()).toBe(locale) + expect($('#router-as-path').text()).toBe('/') + expect($('#router-pathname').text()).toBe('/') + expect(JSON.parse($('#router-locales').text())).toEqual(locales) + + const html2 = await renderViaHTTP(appPort, `/${locale}/auto-export`) + const $2 = cheerio.load(html2) + expect($2('html').attr('lang')).toBe(locale) + expect($2('#router-locale').text()).toBe(locale) + expect($2('#router-as-path').text()).toBe('/auto-export') + expect($2('#router-pathname').text()).toBe('/auto-export') + expect(JSON.parse($2('#router-locales').text())).toEqual(locales) + } + }) + + it('should generate non-dynamic SSG page with all locales', async () => { + for (const locale of locales) { + const html = await renderViaHTTP(appPort, `/${locale}/gsp`) + const $ = cheerio.load(html) + expect($('html').attr('lang')).toBe(locale) + expect($('#router-locale').text()).toBe(locale) + expect($('#router-as-path').text()).toBe('/gsp') + expect($('#router-pathname').text()).toBe('/gsp') + expect(JSON.parse($('#router-locales').text())).toEqual(locales) + } + }) + + // TODO: SSG 404 behavior to opt-out of generating specific locale + // for non-dynamic SSG pages + it('should remove un-necessary locale prefix for default locale', async () => { const res = await fetchViaHTTP(appPort, '/en-US', undefined, { redirect: 'manual', @@ -92,9 +138,7 @@ function runTests() { ).toEqual({ slug: 'another', }) - // TODO: this will be fixed after the fallback is generated for all locales - // instead of delaying populating the locale on the client - // expect(await browser.elementByCss('#router-locale').text()).toBe('en') + expect(await browser.elementByCss('#router-locale').text()).toBe('en-US') expect( JSON.parse(await browser.elementByCss('#router-locales').text()) ).toEqual(locales) @@ -189,12 +233,12 @@ function runTests() { }) it('should load getStaticProps fallback non-prerender page correctly', async () => { - const browser = await webdriver(appPort, '/en-US/gsp/fallback/another') + const browser = await webdriver(appPort, '/en/gsp/fallback/another') await browser.waitForElementByCss('#props') expect(JSON.parse(await browser.elementByCss('#props').text())).toEqual({ - locale: 'en-US', + locale: 'en', locales, params: { slug: 'another', @@ -205,16 +249,12 @@ function runTests() { ).toEqual({ slug: 'another', }) - expect(await browser.elementByCss('#router-locale').text()).toBe('en-US') + expect(await browser.elementByCss('#router-locale').text()).toBe('en') expect( JSON.parse(await browser.elementByCss('#router-locales').text()) ).toEqual(locales) - // TODO: this will be fixed after fallback pages are generated - // for all locales - // expect( - // await browser.elementByCss('html').getAttribute('lang') - // ).toBe('en-US') + expect(await browser.elementByCss('html').getAttribute('lang')).toBe('en') }) it('should load getServerSideProps page correctly SSR (default locale no prefix)', async () => { @@ -239,10 +279,6 @@ function runTests() { await browser.get(browser.initUrl) const checkIndexValues = async () => { - expect(JSON.parse(await browser.elementByCss('#props').text())).toEqual({ - locale: 'en-US', - locales, - }) expect(await browser.elementByCss('#router-locale').text()).toBe('en-US') expect( JSON.parse(await browser.elementByCss('#router-locales').text()) @@ -542,6 +578,7 @@ function runTests() { } describe('i18n Support', () => { + // TODO: test with next export? describe('dev mode', () => { beforeAll(async () => { await fs.remove(join(appDir, '.next'))