diff --git a/cypress/config/static-root.json b/cypress/config/static-root.json new file mode 100644 index 0000000000..b0d00721b7 --- /dev/null +++ b/cypress/config/static-root.json @@ -0,0 +1,8 @@ +{ + "baseUrl": "http://localhost:3000", + "integrationFolder": "../../cypress/integration/static-root", + "pluginsFile": "../../cypress/plugins", + "screenshotsFolder": "../../cypress/screenshots", + "supportFile": "../../cypress/support/index.js", + "videoFolder": "../../cypress/videos" +} diff --git a/cypress/integration/static-root/i18n.spec.ts b/cypress/integration/static-root/i18n.spec.ts new file mode 100644 index 0000000000..e6b387a7f9 --- /dev/null +++ b/cypress/integration/static-root/i18n.spec.ts @@ -0,0 +1,48 @@ +describe('Localization', () => { + it('should use sub routing to determine current locale', () => { + cy.visit('/') + + cy.findByText('The current locale is en') + + cy.visit('/fr') + cy.findByText('The current locale is fr') + }) + + it('should use the NEXT_LOCALE cookie to determine the default locale', () => { + cy.setCookie('NEXT_LOCALE', 'fr') + cy.visit('/') + + cy.url().should('eq', `${Cypress.config().baseUrl}/fr/`) + cy.findByText('The current locale is fr') + }) + + it('should use the nf_lang cookie to determine the default locale', () => { + cy.setCookie('nf_lang', 'fr') + cy.visit('/') + + cy.url().should('eq', `${Cypress.config().baseUrl}/fr/`) + cy.findByText('The current locale is fr') + }) + + it('should use Accept-Language to choose a locale', () => { + cy.visit('/', { + headers: { + 'Accept-Language': 'fr-FR,fr;q=0.5', + }, + }) + cy.url().should('eq', `${Cypress.config().baseUrl}/fr/`) + cy.findByText('The current locale is fr') + }) + + it('should use the NEXT_LOCALE cookie over Accept-Language header to determine the default locale', () => { + cy.setCookie('NEXT_LOCALE', 'en') + cy.visit({ + url: '/', + headers: { + 'Accept-Language': 'fr-FR,fr;q=0.5', + }, + }) + cy.url().should('eq', `${Cypress.config().baseUrl}/`) + cy.findByText('The current locale is en') + }) +}) diff --git a/cypress/integration/static-root/rewrites-redirects.spec.ts b/cypress/integration/static-root/rewrites-redirects.spec.ts new file mode 100644 index 0000000000..a55e8affea --- /dev/null +++ b/cypress/integration/static-root/rewrites-redirects.spec.ts @@ -0,0 +1,13 @@ +describe('Rewrites and Redirects', () => { + it('rewrites: points /old to /', () => { + // preview mode is off by default + cy.visit('/old/another/') + cy.findByText('Another page') + cy.url().should('eq', `${Cypress.config().baseUrl}/old/another/`) + }) + + it('redirects: redirects /redirectme to /', () => { + cy.visit('/redirectme') + cy.url().should('eq', `${Cypress.config().baseUrl}/`) + }) +}) diff --git a/demos/default/netlify.toml b/demos/default/netlify.toml index 1cc78b1fc9..b465745ebb 100644 --- a/demos/default/netlify.toml +++ b/demos/default/netlify.toml @@ -1,7 +1,7 @@ [build] command = "next build" publish = ".next" -ignore = "git diff --quiet $CACHED_COMMIT_REF $COMMIT_REF . ../" +ignore = "git diff --quiet $CACHED_COMMIT_REF $COMMIT_REF . ../../" [build.environment] # cache Cypress binary in local "node_modules" folder diff --git a/demos/nx-next-monorepo-demo/netlify.toml b/demos/nx-next-monorepo-demo/netlify.toml index 42d53f708d..fc7702daf0 100644 --- a/demos/nx-next-monorepo-demo/netlify.toml +++ b/demos/nx-next-monorepo-demo/netlify.toml @@ -1,12 +1,14 @@ [build] - command = "npm run build" - publish = "dist/apps/demo-monorepo/.next" +command = "npm run build" +publish = "dist/apps/demo-monorepo/.next" +ignore = "git diff --quiet $CACHED_COMMIT_REF $COMMIT_REF . ../../" + [dev] - command = "npm run start" - targetPort = 4200 - +command = "npm run start" +targetPort = 4200 + [[plugins]] - package = "./local-plugin" +package = "./local-plugin" [build.environment] # cache Cypress binary in local "node_modules" folder diff --git a/demos/static-root/.eslintrc b/demos/static-root/.eslintrc new file mode 100644 index 0000000000..abd5579b49 --- /dev/null +++ b/demos/static-root/.eslintrc @@ -0,0 +1,4 @@ +{ + "extends": "next", + "root": true +} diff --git a/demos/static-root/.gitignore b/demos/static-root/.gitignore new file mode 100644 index 0000000000..1437c53f70 --- /dev/null +++ b/demos/static-root/.gitignore @@ -0,0 +1,34 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env.local +.env.development.local +.env.test.local +.env.production.local + +# vercel +.vercel diff --git a/demos/static-root/README.md b/demos/static-root/README.md new file mode 100644 index 0000000000..b12f3e33e7 --- /dev/null +++ b/demos/static-root/README.md @@ -0,0 +1,34 @@ +This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `pages/index.js`. The page auto-updates as you edit the file. + +[API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.js`. + +The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. diff --git a/demos/static-root/local-plugin/index.js b/demos/static-root/local-plugin/index.js new file mode 100644 index 0000000000..9e852e382e --- /dev/null +++ b/demos/static-root/local-plugin/index.js @@ -0,0 +1 @@ +module.exports = require('../../../lib') diff --git a/demos/static-root/local-plugin/manifest.yml b/demos/static-root/local-plugin/manifest.yml new file mode 100644 index 0000000000..7091f91411 --- /dev/null +++ b/demos/static-root/local-plugin/manifest.yml @@ -0,0 +1 @@ +name: '@netlify/plugin-nextjs-local' diff --git a/demos/static-root/local-plugin/package-lock.json b/demos/static-root/local-plugin/package-lock.json new file mode 100644 index 0000000000..9bd7b537cc --- /dev/null +++ b/demos/static-root/local-plugin/package-lock.json @@ -0,0 +1,14 @@ +{ + "name": "local-plugin", + "version": "1.0.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "local-plugin", + "version": "1.0.0", + "hasInstallScript": true, + "license": "ISC" + } + } +} diff --git a/demos/static-root/local-plugin/package.json b/demos/static-root/local-plugin/package.json new file mode 100644 index 0000000000..d692119a5b --- /dev/null +++ b/demos/static-root/local-plugin/package.json @@ -0,0 +1,11 @@ +{ + "name": "local-plugin", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "preinstall": "cd ../../.. && npm i" + }, + "author": "", + "license": "ISC" +} diff --git a/demos/static-root/netlify.toml b/demos/static-root/netlify.toml new file mode 100644 index 0000000000..69e5827ba4 --- /dev/null +++ b/demos/static-root/netlify.toml @@ -0,0 +1,24 @@ +[build] +command = "next build" +publish = ".next" +ignore = "git diff --quiet $CACHED_COMMIT_REF $COMMIT_REF . ../../" + +[build.environment] +# cache Cypress binary in local "node_modules" folder +# so Netlify caches it +CYPRESS_CACHE_FOLDER = "../node_modules/.CypressBinary" + +[dev] +framework = "#static" + +[[plugins]] +package = "./local-plugin" + +[[plugins]] +package = "@netlify/plugin-local-install-core" + +[[context.deploy-preview.plugins]] +package = "netlify-plugin-cypress" + +[context.deploy-preview.plugins.inputs] +configFile = "../../cypress/config/static-root.json" diff --git a/demos/static-root/next.config.js b/demos/static-root/next.config.js new file mode 100644 index 0000000000..59274ee24d --- /dev/null +++ b/demos/static-root/next.config.js @@ -0,0 +1,46 @@ +module.exports = { + // Configurable site features we support: + // distDir: 'build', + generateBuildId: () => 'build-id', + i18n: { + defaultLocale: 'en', + locales: ['en', 'es', 'fr'], + }, + async headers() { + return [ + { + source: '/', + headers: [ + { + key: 'x-custom-header', + value: 'my custom header value', + }, + ], + }, + ] + }, + trailingSlash: true, + // Configurable site features _to_ support: + // basePath: '/docs', + // Rewrites allow you to map an incoming request path to a different destination path. + async rewrites() { + return { + beforeFiles: [ + { + source: '/old/:path*', + destination: '/:path*', + }, + ], + } + }, + // Redirects allow you to redirect an incoming request path to a different destination path. + async redirects() { + return [ + { + source: '/redirectme', + destination: '/', + permanent: true, + }, + ] + }, +} diff --git a/demos/static-root/pages/_app.js b/demos/static-root/pages/_app.js new file mode 100644 index 0000000000..1e1cec9242 --- /dev/null +++ b/demos/static-root/pages/_app.js @@ -0,0 +1,7 @@ +import '../styles/globals.css' + +function MyApp({ Component, pageProps }) { + return +} + +export default MyApp diff --git a/demos/static-root/pages/another.js b/demos/static-root/pages/another.js new file mode 100644 index 0000000000..e9cebb8bfd --- /dev/null +++ b/demos/static-root/pages/another.js @@ -0,0 +1,22 @@ +import Head from 'next/head' +import { useRouter } from 'next/router' +import styles from '../styles/Home.module.css' + +export default function Home() { + const { locale } = useRouter() + return ( +
+ + Create Next App + + + + +
+

Another page

+
+ + +
+ ) +} diff --git a/demos/static-root/pages/api/hello.js b/demos/static-root/pages/api/hello.js new file mode 100644 index 0000000000..9987aff4c3 --- /dev/null +++ b/demos/static-root/pages/api/hello.js @@ -0,0 +1,5 @@ +// Next.js API route support: https://nextjs.org/docs/api-routes/introduction + +export default (req, res) => { + res.status(200).json({ name: 'John Doe' }) +} diff --git a/demos/static-root/pages/index.js b/demos/static-root/pages/index.js new file mode 100644 index 0000000000..fcb38d888a --- /dev/null +++ b/demos/static-root/pages/index.js @@ -0,0 +1,52 @@ +import Head from 'next/head' +import Image from 'next/image' +import { useRouter } from 'next/router' +import styles from '../styles/Home.module.css' + +export default function Home() { + const { locale } = useRouter() + return ( +
+ + Create Next App + + + + +
+

+ Welcome to Next.js! +

+ +

The current locale is {locale}

+ +

+ Get started by editing{' '} + pages/index.js +

+ +
+ +

Documentation →

+

Find in-depth information about Next.js features and API.

+
+ + +

Learn →

+

Learn about Next.js in an interactive course with quizzes!

+
+ + +

Examples →

+

Discover and deploy boilerplate example Next.js projects.

+
+
+
+ + +
+ ) +} diff --git a/demos/static-root/public/favicon.ico b/demos/static-root/public/favicon.ico new file mode 100644 index 0000000000..4965832f2c Binary files /dev/null and b/demos/static-root/public/favicon.ico differ diff --git a/demos/static-root/styles/Home.module.css b/demos/static-root/styles/Home.module.css new file mode 100644 index 0000000000..35454bb748 --- /dev/null +++ b/demos/static-root/styles/Home.module.css @@ -0,0 +1,121 @@ +.container { + min-height: 100vh; + padding: 0 0.5rem; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + height: 100vh; +} + +.main { + padding: 5rem 0; + flex: 1; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +} + +.footer { + width: 100%; + height: 100px; + border-top: 1px solid #eaeaea; + display: flex; + justify-content: center; + align-items: center; +} + +.footer a { + display: flex; + justify-content: center; + align-items: center; + flex-grow: 1; +} + +.title a { + color: #0070f3; + text-decoration: none; +} + +.title a:hover, +.title a:focus, +.title a:active { + text-decoration: underline; +} + +.title { + margin: 0; + line-height: 1.15; + font-size: 4rem; +} + +.title, +.description { + text-align: center; +} + +.description { + line-height: 1.5; + font-size: 1.5rem; +} + +.code { + background: #fafafa; + border-radius: 5px; + padding: 0.75rem; + font-size: 1.1rem; + font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono, + Bitstream Vera Sans Mono, Courier New, monospace; +} + +.grid { + display: flex; + align-items: center; + justify-content: center; + flex-wrap: wrap; + max-width: 800px; + margin-top: 3rem; +} + +.card { + margin: 1rem; + padding: 1.5rem; + text-align: left; + color: inherit; + text-decoration: none; + border: 1px solid #eaeaea; + border-radius: 10px; + transition: color 0.15s ease, border-color 0.15s ease; + width: 45%; +} + +.card:hover, +.card:focus, +.card:active { + color: #0070f3; + border-color: #0070f3; +} + +.card h2 { + margin: 0 0 1rem 0; + font-size: 1.5rem; +} + +.card p { + margin: 0; + font-size: 1.25rem; + line-height: 1.5; +} + +.logo { + height: 1em; + margin-left: 0.5rem; +} + +@media (max-width: 600px) { + .grid { + width: 100%; + flex-direction: column; + } +} diff --git a/demos/static-root/styles/globals.css b/demos/static-root/styles/globals.css new file mode 100644 index 0000000000..e5e2dcc23b --- /dev/null +++ b/demos/static-root/styles/globals.css @@ -0,0 +1,16 @@ +html, +body { + padding: 0; + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, + Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; +} + +a { + color: inherit; + text-decoration: none; +} + +* { + box-sizing: border-box; +} diff --git a/src/constants.ts b/src/constants.ts index 5058fd7257..58ee77f39e 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -15,9 +15,9 @@ export const HIDDEN_PATHS = [ '/BUILD_ID', ] -module.exports = { - HIDDEN_PATHS, - IMAGE_FUNCTION_NAME, - HANDLER_FUNCTION_NAME, - ODB_FUNCTION_NAME, -} +export const ODB_FUNCTION_PATH = `/.netlify/builders/${ODB_FUNCTION_NAME}` +export const HANDLER_FUNCTION_PATH = `/.netlify/functions/${HANDLER_FUNCTION_NAME}` + +export const CATCH_ALL_REGEX = /\/\[\.{3}(.*)](.json)?$/ +export const OPTIONAL_CATCH_ALL_REGEX = /\/\[{2}\.{3}(.*)]{2}(.json)?$/ +export const DYNAMIC_PARAMETER_REGEX = /\/\[(.*?)]/g diff --git a/src/helpers/config.ts b/src/helpers/config.ts index b7c5a19a16..9438610a24 100644 --- a/src/helpers/config.ts +++ b/src/helpers/config.ts @@ -1,173 +1,15 @@ -/* eslint-disable max-lines */ - -import { NetlifyConfig } from '@netlify/build' -import { yellowBright } from 'chalk' import { readJSON } from 'fs-extra' -import { PrerenderManifest } from 'next/dist/build' -import { outdent } from 'outdent' import { join, dirname, relative } from 'pathe' import slash from 'slash' -import { HANDLER_FUNCTION_NAME, ODB_FUNCTION_NAME, HIDDEN_PATHS } from '../constants' +import { HANDLER_FUNCTION_NAME, ODB_FUNCTION_NAME } from '../constants' import { RequiredServerFiles } from './requiredServerFilesType' -const defaultFailBuild = (message: string, { error } ): never => { +const defaultFailBuild = (message: string, { error }): never => { throw new Error(`${message}\n${error && error.stack}`) } -const ODB_FUNCTION_PATH = `/.netlify/builders/${ODB_FUNCTION_NAME}` -const HANDLER_FUNCTION_PATH = `/.netlify/functions/${HANDLER_FUNCTION_NAME}` - -const CATCH_ALL_REGEX = /\/\[\.{3}(.*)](.json)?$/ -const OPTIONAL_CATCH_ALL_REGEX = /\/\[{2}\.{3}(.*)]{2}(.json)?$/ -const DYNAMIC_PARAMETER_REGEX = /\/\[(.*?)]/g - -const getNetlifyRoutes = (nextRoute: string): Array => { - let netlifyRoutes = [nextRoute] - - // If the route is an optional catch-all route, we need to add a second - // Netlify route for the base path (when no parameters are present). - // The file ending must be present! - if (OPTIONAL_CATCH_ALL_REGEX.test(nextRoute)) { - let netlifyRoute = nextRoute.replace(OPTIONAL_CATCH_ALL_REGEX, '$2') - - // When optional catch-all route is at top-level, the regex on line 19 will - // create an empty string, but actually needs to be a forward slash - if (netlifyRoute === '') netlifyRoute = '/' - - // When optional catch-all route is at top-level, the regex on line 19 will - // create an incorrect route for the data route. For example, it creates - // /_next/data/%BUILDID%.json, but NextJS looks for - // /_next/data/%BUILDID%/index.json - netlifyRoute = netlifyRoute.replace(/(\/_next\/data\/[^/]+).json/, '$1/index.json') - - // Add second route to the front of the array - netlifyRoutes.unshift(netlifyRoute) - } - - // Replace catch-all, e.g., [...slug] - netlifyRoutes = netlifyRoutes.map((route) => route.replace(CATCH_ALL_REGEX, '/:$1/*')) - - // Replace optional catch-all, e.g., [[...slug]] - netlifyRoutes = netlifyRoutes.map((route) => route.replace(OPTIONAL_CATCH_ALL_REGEX, '/*')) - - // Replace dynamic parameters, e.g., [id] - netlifyRoutes = netlifyRoutes.map((route) => route.replace(DYNAMIC_PARAMETER_REGEX, '/:$1')) - - return netlifyRoutes -} - -export const generateRedirects = async ({ netlifyConfig, basePath, i18n }: { - netlifyConfig: NetlifyConfig, - basePath: string, - i18n -}) => { - const { dynamicRoutes, routes: staticRoutes }: PrerenderManifest = await readJSON( - join(netlifyConfig.build.publish, 'prerender-manifest.json'), - ) - - netlifyConfig.redirects.push( - ...HIDDEN_PATHS.map((path) => ({ - from: `${basePath}${path}`, - to: '/404.html', - status: 404, - force: true, - })), - ) - - const dataRedirects = [] - const pageRedirects = [] - const isrRedirects = [] - let hasIsr = false - - const dynamicRouteEntries = Object.entries(dynamicRoutes) - - const staticRouteEntries = Object.entries(staticRoutes) - - staticRouteEntries.forEach(([route, { dataRoute, initialRevalidateSeconds }]) => { - // Only look for revalidate as we need to rewrite these to SSR rather than ODB - if (initialRevalidateSeconds === false) { - // These can be ignored, as they're static files handled by the CDN - return - } - if (i18n?.defaultLocale && route.startsWith(`/${i18n.defaultLocale}/`)) { - route = route.slice(i18n.defaultLocale.length + 1) - } - hasIsr = true - isrRedirects.push(...getNetlifyRoutes(dataRoute), ...getNetlifyRoutes(route)) - }) - - dynamicRouteEntries.forEach(([route, { dataRoute, fallback }]) => { - // Add redirects if fallback is "null" (aka blocking) or true/a string - if (fallback === false) { - return - } - pageRedirects.push(...getNetlifyRoutes(route)) - dataRedirects.push(...getNetlifyRoutes(dataRoute)) - }) - - if (i18n) { - netlifyConfig.redirects.push({ from: `${basePath}/:locale/_next/static/*`, to: `/static/:splat`, status: 200 }) - } - - // This is only used in prod, so dev uses `next dev` directly - netlifyConfig.redirects.push( - // Static files are in `static` - { from: `${basePath}/_next/static/*`, to: `/static/:splat`, status: 200 }, - // API routes always need to be served from the regular function - { - from: `${basePath}/api`, - to: HANDLER_FUNCTION_PATH, - status: 200, - }, - { - from: `${basePath}/api/*`, - to: HANDLER_FUNCTION_PATH, - status: 200, - }, - // Preview mode gets forced to the function, to bypess pre-rendered pages - { - from: `${basePath}/*`, - to: HANDLER_FUNCTION_PATH, - status: 200, - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - conditions: { Cookie: ['__prerender_bypass', '__next_preview_data'] }, - force: true, - }, - // ISR redirects are handled by the regular function. Forced to avoid pre-rendered pages - ...isrRedirects.map((redirect) => ({ - from: `${basePath}${redirect}`, - to: process.env.EXPERIMENTAL_ODB_TTL ? ODB_FUNCTION_PATH : HANDLER_FUNCTION_PATH, - status: 200, - force: true, - })), - // These are pages with fallback set, which need an ODB - // Data redirects go first, to avoid conflict with splat redirects - ...dataRedirects.map((redirect) => ({ - from: `${basePath}${redirect}`, - to: ODB_FUNCTION_PATH, - status: 200, - })), - // ...then all the other fallback pages - ...pageRedirects.map((redirect) => ({ - from: `${basePath}${redirect}`, - to: ODB_FUNCTION_PATH, - status: 200, - })), - // Everything else is handled by the regular function - { from: `${basePath}/*`, to: HANDLER_FUNCTION_PATH, status: 200 }, - ) - if (hasIsr) { - console.log( - yellowBright(outdent` - You have some pages that use ISR (pages that use getStaticProps with revalidate set), which is not currently fully-supported by this plugin. Be aware that results may be unreliable. - `), - ) - } -} - export const getNextConfig = async function getNextConfig({ publish, failBuild = defaultFailBuild }) { try { const { config, appDir, ignore }: RequiredServerFiles = await readJSON(join(publish, 'required-server-files.json')) @@ -234,4 +76,3 @@ export const configureHandlerFunctions = ({ netlifyConfig, publish, ignore = [] }) }) } -/* eslint-enable max-lines */ diff --git a/src/helpers/files.js b/src/helpers/files.js index db9135d94d..2efd5546c5 100644 --- a/src/helpers/files.js +++ b/src/helpers/files.js @@ -235,6 +235,18 @@ exports.moveStaticPages = async ({ netlifyConfig, target, i18n }) => { if (existsSync(defaultLocaleDir)) { await copy(defaultLocaleDir, `${netlifyConfig.build.publish}/`) } + const defaultLocaleIndex = join(netlifyConfig.build.publish, `${i18n.defaultLocale}.html`) + const indexHtml = join(netlifyConfig.build.publish, 'index.html') + if (existsSync(defaultLocaleIndex) && !existsSync(indexHtml)) { + try { + await copy(defaultLocaleIndex, indexHtml, { overwrite: false }) + await copy( + join(netlifyConfig.build.publish, `${i18n.defaultLocale}.json`), + join(netlifyConfig.build.publish, 'index.json'), + { overwrite: false }, + ) + } catch {} + } } } diff --git a/src/helpers/redirects.ts b/src/helpers/redirects.ts new file mode 100644 index 0000000000..2e0020707d --- /dev/null +++ b/src/helpers/redirects.ts @@ -0,0 +1,160 @@ +import { NetlifyConfig } from '@netlify/build' +import { yellowBright } from 'chalk' +import { readJSON } from 'fs-extra' +import { NextConfig } from 'next' +import { PrerenderManifest } from 'next/dist/build' +import { outdent } from 'outdent' +import { join } from 'pathe' + +import { HANDLER_FUNCTION_PATH, HIDDEN_PATHS, ODB_FUNCTION_PATH } from '../constants' + +import { netlifyRoutesForNextRoute } from './utils' + +const generateLocaleRedirects = ({ + i18n, + basePath, + trailingSlash, +}: Pick): NetlifyConfig['redirects'] => { + const redirects: NetlifyConfig['redirects'] = [] + // If the cookie is set, we need to redirect at the origin + redirects.push({ + from: `${basePath}${trailingSlash ? '/' : ''}`, + to: HANDLER_FUNCTION_PATH, + status: 200, + force: true, + conditions: { + Cookie: ['NEXT_LOCALE'], + }, + }) + i18n.locales.forEach((locale) => { + if (locale === i18n.defaultLocale) { + return + } + redirects.push({ + from: `${basePath}/`, + to: `${basePath}/${locale}/`, + status: 301, + conditions: { + Language: [locale], + }, + force: true, + }) + }) + return redirects +} + +export const generateRedirects = async ({ + netlifyConfig, + nextConfig: { i18n, basePath, trailingSlash }, +}: { + netlifyConfig: NetlifyConfig + nextConfig: Pick +}) => { + const { dynamicRoutes, routes: staticRoutes }: PrerenderManifest = await readJSON( + join(netlifyConfig.build.publish, 'prerender-manifest.json'), + ) + + netlifyConfig.redirects.push( + ...HIDDEN_PATHS.map((path) => ({ + from: `${basePath}${path}`, + to: '/404.html', + status: 404, + force: true, + })), + ) + + if (i18n && i18n.localeDetection !== false) { + netlifyConfig.redirects.push(...generateLocaleRedirects({ i18n, basePath, trailingSlash })) + } + + const dataRedirects = [] + const pageRedirects = [] + const isrRedirects = [] + let hasIsr = false + + const dynamicRouteEntries = Object.entries(dynamicRoutes) + + const staticRouteEntries = Object.entries(staticRoutes) + + staticRouteEntries.forEach(([route, { dataRoute, initialRevalidateSeconds }]) => { + // Only look for revalidate as we need to rewrite these to SSR rather than ODB + if (initialRevalidateSeconds === false) { + // These can be ignored, as they're static files handled by the CDN + return + } + if (i18n?.defaultLocale && route.startsWith(`/${i18n.defaultLocale}/`)) { + route = route.slice(i18n.defaultLocale.length + 1) + } + hasIsr = true + isrRedirects.push(...netlifyRoutesForNextRoute(dataRoute), ...netlifyRoutesForNextRoute(route)) + }) + + dynamicRouteEntries.forEach(([route, { dataRoute, fallback }]) => { + // Add redirects if fallback is "null" (aka blocking) or true/a string + if (fallback === false) { + return + } + pageRedirects.push(...netlifyRoutesForNextRoute(route)) + dataRedirects.push(...netlifyRoutesForNextRoute(dataRoute)) + }) + + if (i18n) { + netlifyConfig.redirects.push({ from: `${basePath}/:locale/_next/static/*`, to: `/static/:splat`, status: 200 }) + } + + // This is only used in prod, so dev uses `next dev` directly + netlifyConfig.redirects.push( + // Static files are in `static` + { from: `${basePath}/_next/static/*`, to: `/static/:splat`, status: 200 }, + // API routes always need to be served from the regular function + { + from: `${basePath}/api`, + to: HANDLER_FUNCTION_PATH, + status: 200, + }, + { + from: `${basePath}/api/*`, + to: HANDLER_FUNCTION_PATH, + status: 200, + }, + // Preview mode gets forced to the function, to bypess pre-rendered pages + { + from: `${basePath}/*`, + to: HANDLER_FUNCTION_PATH, + status: 200, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore The conditions type is incorrect + conditions: { Cookie: ['__prerender_bypass', '__next_preview_data'] }, + force: true, + }, + // ISR redirects are handled by the regular function. Forced to avoid pre-rendered pages + ...isrRedirects.map((redirect) => ({ + from: `${basePath}${redirect}`, + to: process.env.EXPERIMENTAL_ODB_TTL ? ODB_FUNCTION_PATH : HANDLER_FUNCTION_PATH, + status: 200, + force: true, + })), + // These are pages with fallback set, which need an ODB + // Data redirects go first, to avoid conflict with splat redirects + ...dataRedirects.map((redirect) => ({ + from: `${basePath}${redirect}`, + to: ODB_FUNCTION_PATH, + status: 200, + })), + // ...then all the other fallback pages + ...pageRedirects.map((redirect) => ({ + from: `${basePath}${redirect}`, + to: ODB_FUNCTION_PATH, + status: 200, + })), + // Everything else is handled by the regular function + { from: `${basePath}/*`, to: HANDLER_FUNCTION_PATH, status: 200 }, + ) + if (hasIsr) { + console.log( + yellowBright(outdent` + You have some pages that use ISR (pages that use getStaticProps with revalidate set), which is not currently fully-supported by this plugin. Be aware that results may be unreliable. + `), + ) + } +} diff --git a/src/helpers/utils.ts b/src/helpers/utils.ts new file mode 100644 index 0000000000..e50ff9073e --- /dev/null +++ b/src/helpers/utils.ts @@ -0,0 +1,35 @@ +import { OPTIONAL_CATCH_ALL_REGEX, CATCH_ALL_REGEX, DYNAMIC_PARAMETER_REGEX } from '../constants' + +export const netlifyRoutesForNextRoute = (nextRoute: string): Array => { + const netlifyRoutes = [nextRoute] + + // If the route is an optional catch-all route, we need to add a second + // Netlify route for the base path (when no parameters are present). + // The file ending must be present! + if (OPTIONAL_CATCH_ALL_REGEX.test(nextRoute)) { + let netlifyRoute = nextRoute.replace(OPTIONAL_CATCH_ALL_REGEX, '$2') + + // create an empty string, but actually needs to be a forward slash + if (netlifyRoute === '') { + netlifyRoute = '/' + } + // When optional catch-all route is at top-level, the regex on line 19 will + // create an incorrect route for the data route. For example, it creates + // /_next/data/%BUILDID%.json, but NextJS looks for + // /_next/data/%BUILDID%/index.json + netlifyRoute = netlifyRoute.replace(/(\/_next\/data\/[^/]+).json/, '$1/index.json') + + // Add second route to the front of the array + netlifyRoutes.unshift(netlifyRoute) + } + + return netlifyRoutes.map((route) => + route + // Replace catch-all, e.g., [...slug] + .replace(CATCH_ALL_REGEX, '/:$1/*') + // Replace optional catch-all, e.g., [[...slug]] + .replace(OPTIONAL_CATCH_ALL_REGEX, '/*') + // Replace dynamic parameters, e.g., [id] + .replace(DYNAMIC_PARAMETER_REGEX, '/:$1'), + ) +} diff --git a/src/index.js b/src/index.js index 6d0e4b6ece..25aa9e1117 100644 --- a/src/index.js +++ b/src/index.js @@ -2,9 +2,10 @@ const { join, relative } = require('path') const { ODB_FUNCTION_NAME } = require('./constants') const { restoreCache, saveCache } = require('./helpers/cache') -const { getNextConfig, configureHandlerFunctions, generateRedirects } = require('./helpers/config') +const { getNextConfig, configureHandlerFunctions } = require('./helpers/config') const { moveStaticPages, movePublicFiles, patchNextFiles, unpatchNextFiles } = require('./helpers/files') const { generateFunctions, setupImageFunction, generatePagesResolver } = require('./helpers/functions') +const { generateRedirects } = require('./helpers/redirects') const { verifyNetlifyBuildVersion, checkNextSiteHasBuilt, @@ -47,7 +48,10 @@ module.exports = { checkNextSiteHasBuilt({ publish, failBuild }) - const { appDir, basePath, i18n, images, target, ignore } = await getNextConfig({ publish, failBuild }) + const { appDir, basePath, i18n, images, target, ignore, trailingSlash } = await getNextConfig({ + publish, + failBuild, + }) configureHandlerFunctions({ netlifyConfig, ignore, publish: relative(process.cwd(), publish) }) @@ -74,8 +78,7 @@ module.exports = { await generateRedirects({ netlifyConfig, - basePath, - i18n, + nextConfig: { basePath, i18n, trailingSlash }, }) }, diff --git a/test/__snapshots__/index.js.snap b/test/__snapshots__/index.js.snap index a3ea98ccb1..1fab6955b3 100644 --- a/test/__snapshots__/index.js.snap +++ b/test/__snapshots__/index.js.snap @@ -237,6 +237,39 @@ Array [ exports[`onBuild() writes correct redirects to netlifyConfig 1`] = ` Array [ + Object { + "conditions": Object { + "Cookie": Array [ + "NEXT_LOCALE", + ], + }, + "force": true, + "from": "/", + "status": 200, + "to": "/.netlify/functions/___netlify-handler", + }, + Object { + "conditions": Object { + "Language": Array [ + "es", + ], + }, + "force": true, + "from": "/", + "status": 301, + "to": "/es/", + }, + Object { + "conditions": Object { + "Language": Array [ + "fr", + ], + }, + "force": true, + "from": "/", + "status": 301, + "to": "/fr/", + }, Object { "from": "/_ipx/*", "status": 200,