diff --git a/.circleci/config.yml b/.circleci/config.yml
index 92828fcaff252..1e14aca2fe145 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -467,6 +467,19 @@ jobs:
- store_test_results:
path: e2e-tests/contentful/cypress/results
+ e2e_tests_trailing-slash:
+ <<: *e2e-executor
+ environment:
+ <<: *e2e-executor-env
+ CYPRESS_PROJECT_ID: ofxgw8
+ CYPRESS_RECORD_KEY: 29c32742-6b85-40e0-9b45-a4c722749d52
+ steps:
+ - e2e-test:
+ test_path: e2e-tests/trailing-slash
+ test_command: yarn test
+ - store_test_results:
+ path: e2e-tests/trailing-slash/cypress/results
+
starters_validate:
executor: node
steps:
@@ -669,6 +682,8 @@ workflows:
<<: *e2e-test-workflow
- e2e_tests_contentful:
<<: *e2e-test-workflow
+ - e2e_tests_trailing-slash:
+ <<: *e2e-test-workflow
- e2e_tests_development_runtime:
<<: *e2e-test-workflow
- e2e_tests_production_runtime:
diff --git a/.eslintrc.js b/.eslintrc.js
index 712eab01cb7ba..9f6ba1abac646 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -38,6 +38,7 @@ module.exports = {
__ASSET_PREFIX__: true,
_CFLAGS_: true,
__GATSBY: true,
+ __TRAILING_SLASH__: true,
},
rules: {
"@babel/no-unused-expressions": [
diff --git a/docs/docs/reference/config-files/gatsby-config.md b/docs/docs/reference/config-files/gatsby-config.md
index 79b372d0852c1..fa20d4d64ac58 100644
--- a/docs/docs/reference/config-files/gatsby-config.md
+++ b/docs/docs/reference/config-files/gatsby-config.md
@@ -42,13 +42,16 @@ module.exports = {
Options available to set within `gatsby-config.js` include:
1. [siteMetadata](#sitemetadata) (object)
-2. [plugins](#plugins) (array)
-3. [flags](#flags) (object)
-4. [pathPrefix](#pathprefix) (string)
-5. [polyfill](#polyfill) (boolean)
-6. [mapping](#mapping-node-types) (object)
-7. [proxy](#proxy) (object)
-8. [developMiddleware](#advanced-proxying-with-developmiddleware) (function)
+1. [plugins](#plugins) (array)
+1. [flags](#flags) (object)
+1. [pathPrefix](#pathprefix) (string)
+1. [trailingSlash](#trailingslash) (string)
+1. [polyfill](#polyfill) (boolean)
+1. [mapping](#mapping-node-types) (object)
+1. [proxy](#proxy) (object)
+1. [developMiddleware](#advanced-proxying-with-developmiddleware) (function)
+1. [jsxRuntime](#jsxruntime) (string)
+1. [jsxImportSource](#jsximportsource) (string)
## siteMetadata
@@ -68,7 +71,7 @@ This way you can store it in one place, and pull it whenever you need it. If you
See a full description and sample usage in [Gatsby.js Tutorial Part Four](/docs/tutorial/part-4/#data-in-gatsby).
-## Plugins
+## plugins
Plugins are Node.js packages that implement Gatsby APIs. The config file accepts an array of plugins. Some plugins may need only to be listed by name, while others may take options (see the docs for individual plugins).
@@ -135,7 +138,7 @@ module.exports = {
See more about [Plugins](/docs/plugins/) for more on utilizing plugins, and to see available official and community plugins.
-## Flags
+## flags
Flags let sites enable experimental or upcoming changes that are still in testing or waiting for the next major release.
@@ -161,7 +164,17 @@ module.exports = {
See more about [Adding a Path Prefix](/docs/how-to/previews-deploys-hosting/path-prefix/).
-## Polyfill
+## trailingSlash
+
+Configures the creation of URLs and whether to remove, append, or ignore trailing slashes.
+
+- `always`: Always add trailing slashes to each URL, e.g. `/x` to `/x/`.
+- `never`: Remove all trailing slashes on each URL, e.g. `/x/` to `/x`.
+- `ignore`: Don't automatically modify the URL
+
+Until Gatsby v4 it'll be set to `legacy` by default, in Gatsby v5 the default mode will be `always`. Gatsby Cloud automatically handles and supports the `trailingSlash` option, any other hosting provider (or if you're managing this on your own) should follow the "Redirects, and expected behavior from the hosting provider" section on the [initial RFC](https://github.com/gatsbyjs/gatsby/discussions/34205).
+
+## polyfill
Gatsby uses the ES6 Promise API. Because some browsers don't support this, Gatsby includes a Promise polyfill by default.
diff --git a/e2e-tests/trailing-slash/.gitignore b/e2e-tests/trailing-slash/.gitignore
new file mode 100644
index 0000000000000..52c8ffaeb94bc
--- /dev/null
+++ b/e2e-tests/trailing-slash/.gitignore
@@ -0,0 +1,13 @@
+# Project dependencies
+.cache
+node_modules
+yarn-error.log
+
+# Build assets
+/public
+.DS_Store
+/assets
+
+# Cypress output
+cypress/videos/
+cypress/screenshots/
diff --git a/e2e-tests/trailing-slash/README.md b/e2e-tests/trailing-slash/README.md
new file mode 100644
index 0000000000000..b1627f34c11ef
--- /dev/null
+++ b/e2e-tests/trailing-slash/README.md
@@ -0,0 +1,15 @@
+# trailing-slash E2E Test
+
+This Cypress suite tests the `trailingSlash` option inside `gatsby-config` and its various different settings it takes. When you want to work on it, start watching packages inside the `packages` and start `gatsby-dev-cli` in this E2E test suite.
+
+Locally you can run for development:
+
+```shell
+TRAILING_SLASH=your-option yarn debug:develop
+```
+
+And for a build + serve:
+
+```shell
+TRAILING_SLASH=your-option yarn build && yarn debug:build
+```
diff --git a/e2e-tests/trailing-slash/cypress-always.json b/e2e-tests/trailing-slash/cypress-always.json
new file mode 100644
index 0000000000000..937b58765b5a1
--- /dev/null
+++ b/e2e-tests/trailing-slash/cypress-always.json
@@ -0,0 +1,5 @@
+{
+ "videoUploadOnPasses": false,
+ "chromeWebSecurity": false,
+ "testFiles": ["always.js", "functions.js", "static.js"]
+}
diff --git a/e2e-tests/trailing-slash/cypress-ignore.json b/e2e-tests/trailing-slash/cypress-ignore.json
new file mode 100644
index 0000000000000..607c1c7475fed
--- /dev/null
+++ b/e2e-tests/trailing-slash/cypress-ignore.json
@@ -0,0 +1,5 @@
+{
+ "videoUploadOnPasses": false,
+ "chromeWebSecurity": false,
+ "testFiles": ["ignore.js", "functions.js", "static.js"]
+}
diff --git a/e2e-tests/trailing-slash/cypress-legacy.json b/e2e-tests/trailing-slash/cypress-legacy.json
new file mode 100644
index 0000000000000..d4d72f3ae04e6
--- /dev/null
+++ b/e2e-tests/trailing-slash/cypress-legacy.json
@@ -0,0 +1,5 @@
+{
+ "videoUploadOnPasses": false,
+ "chromeWebSecurity": false,
+ "testFiles": ["legacy.js", "functions.js", "static.js"]
+}
diff --git a/e2e-tests/trailing-slash/cypress-never.json b/e2e-tests/trailing-slash/cypress-never.json
new file mode 100644
index 0000000000000..d5aaf9cf2df54
--- /dev/null
+++ b/e2e-tests/trailing-slash/cypress-never.json
@@ -0,0 +1,5 @@
+{
+ "videoUploadOnPasses": false,
+ "chromeWebSecurity": false,
+ "testFiles": ["never.js", "functions.js", "static.js"]
+}
diff --git a/e2e-tests/trailing-slash/cypress.json b/e2e-tests/trailing-slash/cypress.json
new file mode 100644
index 0000000000000..4c8aa3a9cac67
--- /dev/null
+++ b/e2e-tests/trailing-slash/cypress.json
@@ -0,0 +1,4 @@
+{
+ "videoUploadOnPasses": false,
+ "chromeWebSecurity": false
+}
diff --git a/e2e-tests/trailing-slash/cypress/fixtures/example.json b/e2e-tests/trailing-slash/cypress/fixtures/example.json
new file mode 100644
index 0000000000000..02e4254378e97
--- /dev/null
+++ b/e2e-tests/trailing-slash/cypress/fixtures/example.json
@@ -0,0 +1,5 @@
+{
+ "name": "Using fixtures to represent data",
+ "email": "hello@cypress.io",
+ "body": "Fixtures are a great way to mock data for responses to routes"
+}
diff --git a/e2e-tests/trailing-slash/cypress/integration/always.js b/e2e-tests/trailing-slash/cypress/integration/always.js
new file mode 100644
index 0000000000000..722a4471982e9
--- /dev/null
+++ b/e2e-tests/trailing-slash/cypress/integration/always.js
@@ -0,0 +1,240 @@
+import { assertPageVisits } from "../support/utils/trailing-slash"
+
+describe(`always`, () => {
+ beforeEach(() => {
+ cy.visit(`/`).waitForRouteChange()
+ })
+ it(`page-creator without slash`, () => {
+ cy.getTestElement(`page-creator-without`).click()
+ cy.waitForRouteChange().assertRoute(`/page-2/`)
+ })
+ it(`page-creator with slash`, () => {
+ cy.getTestElement(`page-creator-with`).click()
+ cy.waitForRouteChange().assertRoute(`/page-2/`)
+ })
+ it(`create-page with slash`, () => {
+ cy.getTestElement(`create-page-with`).click()
+ cy.waitForRouteChange().assertRoute(`/create-page/with/`)
+ })
+ it(`create-page without slash`, () => {
+ cy.getTestElement(`create-page-without`).click()
+ cy.waitForRouteChange().assertRoute(`/create-page/without/`)
+ })
+ it(`fs-api with slash`, () => {
+ cy.getTestElement(`fs-api-with`).click()
+ cy.waitForRouteChange().assertRoute(`/fs-api/with/`)
+ })
+ it(`fs-api without slash`, () => {
+ cy.getTestElement(`fs-api-without`).click()
+ cy.waitForRouteChange().assertRoute(`/fs-api/without/`)
+ })
+ it(`fs-api client only splat without slash`, () => {
+ cy.getTestElement(`fs-api-client-only-without`).click()
+ cy.waitForRouteChange().assertRoute(`/fs-api/without/without/`)
+ cy.getTestElement(`title`).should(`have.text`, `without`)
+ })
+ it(`fs-api client only splat with slash`, () => {
+ cy.getTestElement(`fs-api-client-only-with`).click()
+ cy.waitForRouteChange().assertRoute(`/fs-api/with/with/`)
+ cy.getTestElement(`title`).should(`have.text`, `with`)
+ })
+ it(`fs-api-simple with slash`, () => {
+ cy.getTestElement(`fs-api-simple-with`).click()
+ cy.waitForRouteChange().assertRoute(`/fs-api-simple/with/`)
+ })
+ it(`fs-api-simple without slash`, () => {
+ cy.getTestElement(`fs-api-simple-without`).click()
+ cy.waitForRouteChange().assertRoute(`/fs-api-simple/without/`)
+ })
+ it(`gatsbyPath works`, () => {
+ cy.getTestElement(`gatsby-path-1`).should(
+ "have.attr",
+ "href",
+ "/fs-api-simple/with/"
+ )
+ cy.getTestElement(`gatsby-path-2`).should(
+ "have.attr",
+ "href",
+ "/fs-api-simple/without/"
+ )
+ })
+ it(`hash`, () => {
+ cy.getTestElement(`hash`).click()
+ cy.waitForRouteChange().assertRoute(`/page-2/#anchor`)
+ })
+ it(`hash trailing`, () => {
+ cy.getTestElement(`hash-trailing`).click()
+ cy.waitForRouteChange().assertRoute(`/page-2/#anchor`)
+ })
+ it(`query-param`, () => {
+ cy.getTestElement(`query-param`).click()
+ cy.waitForRouteChange().assertRoute(`/page-2/?query_param=hello`)
+ })
+ it(`query-param-hash`, () => {
+ cy.getTestElement(`query-param-hash`).click()
+ cy.waitForRouteChange().assertRoute(`/page-2/?query_param=hello#anchor`)
+ })
+ it(`client-only without slash`, () => {
+ cy.getTestElement(`client-only-simple-without`).click()
+ cy.waitForRouteChange().assertRoute(`/client-only/without/`)
+ cy.getTestElement(`title`).should(`have.text`, `without`)
+ })
+ it(`client-only with slash`, () => {
+ cy.getTestElement(`client-only-simple-with`).click()
+ cy.waitForRouteChange().assertRoute(`/client-only/with/`)
+ cy.getTestElement(`title`).should(`have.text`, `with`)
+ })
+ it(`client-only-splat without slash`, () => {
+ cy.getTestElement(`client-only-splat-without`).click()
+ cy.waitForRouteChange().assertRoute(`/client-only-splat/without/without/`)
+ cy.getTestElement(`title`).should(`have.text`, `without/without`)
+ })
+ it(`client-only-splat with slash`, () => {
+ cy.getTestElement(`client-only-splat-with`).click()
+ cy.waitForRouteChange().assertRoute(`/client-only-splat/with/with/`)
+ cy.getTestElement(`title`).should(`have.text`, `with/with`)
+ })
+})
+
+describe(`always (direct visits)`, () => {
+ beforeEach(() => {
+ cy.visit(`/`).waitForRouteChange()
+ })
+
+ it(`page-creator`, () => {
+ assertPageVisits([
+ { path: "/page-2/", status: 200 },
+ { path: "/page-2", status: 301, destinationPath: "/page-2/" },
+ ])
+
+ cy.visit(`/page-2`).waitForRouteChange().assertRoute(`/page-2/`)
+ })
+
+ it(`create-page with`, () => {
+ assertPageVisits([{ path: "/create-page/with/", status: 200 }])
+
+ cy.visit(`/create-page/with/`)
+ .waitForRouteChange()
+ .assertRoute(`/create-page/with/`)
+ })
+
+ it(`create-page without`, () => {
+ assertPageVisits([
+ {
+ path: "/create-page/without",
+ status: 301,
+ destinationPath: "/create-page/without",
+ },
+ ])
+
+ cy.visit(`/create-page/without`)
+ .waitForRouteChange()
+ .assertRoute(`/create-page/without/`)
+ })
+
+ it(`fs-api-simple with`, () => {
+ assertPageVisits([{ path: "/fs-api-simple/with/", status: 200 }])
+
+ cy.visit(`/fs-api-simple/with/`)
+ .waitForRouteChange()
+ .assertRoute(`/fs-api-simple/with/`)
+ })
+
+ it(`fs-api-simple without`, () => {
+ assertPageVisits([
+ {
+ path: "/fs-api-simple/without",
+ status: 301,
+ destinationPath: "/fs-api-simple/without/",
+ },
+ ])
+
+ cy.visit(`/fs-api-simple/without`)
+ .waitForRouteChange()
+ .assertRoute(`/fs-api-simple/without/`)
+ })
+
+ it(`fs-api client only splat with`, () => {
+ assertPageVisits([{ path: "/fs-api/with/with/", status: 200 }])
+
+ cy.visit(`/fs-api/with/with/`)
+ .waitForRouteChange()
+ .assertRoute(`/fs-api/with/with/`)
+ })
+
+ it(`fs-api client only splat without`, () => {
+ assertPageVisits([
+ {
+ path: "`/fs-api/without/without",
+ status: 301,
+ destinationPath: "`/fs-api/without/without/",
+ },
+ ])
+
+ cy.visit(`/fs-api/without/without`)
+ .waitForRouteChange()
+ .assertRoute(`/fs-api/without/without/`)
+ })
+
+ it(`client-only with`, () => {
+ assertPageVisits([{ path: "/create-page/with/", status: 200 }])
+
+ cy.visit(`/client-only/with/`)
+ .waitForRouteChange()
+ .assertRoute(`/client-only/with/`)
+ })
+
+ it(`client-only without`, () => {
+ assertPageVisits([
+ {
+ path: "/client-only/without",
+ status: 301,
+ destinationPath: "/client-only/without",
+ },
+ ])
+
+ cy.visit(`/client-only/without`)
+ .waitForRouteChange()
+ .assertRoute(`/client-only/without/`)
+ })
+
+ it(`client-only-splat with`, () => {
+ assertPageVisits([{ path: "/client-only-splat/with/with/", status: 200 }])
+
+ cy.visit(`/client-only-splat/with/with/`)
+ .waitForRouteChange()
+ .assertRoute(`/client-only-splat/with/with/`)
+ })
+
+ it(`client-only-splat without`, () => {
+ assertPageVisits([
+ {
+ path: "`/client-only-splat/without/without",
+ status: 301,
+ destinationPath: "`/client-only-splat/without/without/",
+ },
+ ])
+
+ cy.visit(`/client-only-splat/without/without`)
+ .waitForRouteChange()
+ .assertRoute(`/client-only-splat/without/without/`)
+ })
+
+ it(`query-param-hash with`, () => {
+ assertPageVisits([
+ { path: "/page-2/?query_param=hello#anchor", status: 200 },
+ ])
+
+ cy.visit(`/page-2/?query_param=hello#anchor`)
+ .waitForRouteChange()
+ .assertRoute(`/page-2/?query_param=hello#anchor`)
+ })
+
+ it(`query-param-hash without`, () => {
+ assertPageVisits([{ path: "/page-2?query_param=hello#anchor", status: 200 }])
+
+ cy.visit(`/page-2?query_param=hello#anchor`)
+ .waitForRouteChange()
+ .assertRoute(`/page-2/?query_param=hello#anchor`)
+ })
+})
diff --git a/e2e-tests/trailing-slash/cypress/integration/functions.js b/e2e-tests/trailing-slash/cypress/integration/functions.js
new file mode 100644
index 0000000000000..bef063b0429b8
--- /dev/null
+++ b/e2e-tests/trailing-slash/cypress/integration/functions.js
@@ -0,0 +1,35 @@
+import { assertPageVisits } from "../support/utils/trailing-slash"
+
+describe(`functions`, () => {
+ describe(`src/api/test.js`, () => {
+ it(`functions are always accessible without trailing slash`, () => {
+ assertPageVisits([{ path: "/api/test", status: 200 }])
+
+ cy.visit(`/api/test`).assertRoute(`/api/test`)
+ })
+
+ it(`functions 404 with trailing slash`, () => {
+ assertPageVisits([{ path: "/api/test/", status: 404 }])
+
+ cy.visit(`/api/test/`, { failOnStatusCode: false }).assertRoute(
+ `/api/test/`
+ )
+ })
+ })
+
+ describe(`src/api/nested/index.js`, () => {
+ it(`functions are always accessible without trailing slash`, () => {
+ assertPageVisits([{ path: "/api/nested", status: 200 }])
+
+ cy.visit(`/api/nested`).assertRoute(`/api/nested`)
+ })
+
+ it(`functions 404 with trailing slash`, () => {
+ assertPageVisits([{ path: "/api/nested/", status: 404 }])
+
+ cy.visit(`/api/nested/`, { failOnStatusCode: false }).assertRoute(
+ `/api/nested/`
+ )
+ })
+ })
+})
diff --git a/e2e-tests/trailing-slash/cypress/integration/ignore.js b/e2e-tests/trailing-slash/cypress/integration/ignore.js
new file mode 100644
index 0000000000000..7a9497a23efb6
--- /dev/null
+++ b/e2e-tests/trailing-slash/cypress/integration/ignore.js
@@ -0,0 +1,224 @@
+import { assertPageVisits } from "../support/utils/trailing-slash"
+
+describe(`ignore`, () => {
+ beforeEach(() => {
+ cy.visit(`/`).waitForRouteChange()
+ })
+ it(`page-creator without slash`, () => {
+ cy.getTestElement(`page-creator-without`).click()
+ cy.waitForRouteChange().assertRoute(`/page-2`)
+ })
+ it(`page-creator with slash`, () => {
+ cy.getTestElement(`page-creator-with`).click()
+ cy.waitForRouteChange().assertRoute(`/page-2/`)
+ })
+ it(`create-page with slash`, () => {
+ cy.getTestElement(`create-page-with`).click()
+ cy.waitForRouteChange().assertRoute(`/create-page/with/`)
+ })
+ it(`create-page without slash`, () => {
+ cy.getTestElement(`create-page-without`).click()
+ cy.waitForRouteChange().assertRoute(`/create-page/without`)
+ })
+ it(`fs-api with slash`, () => {
+ cy.getTestElement(`fs-api-with`).click()
+ cy.waitForRouteChange().assertRoute(`/fs-api/with/`)
+ })
+ it(`fs-api without slash`, () => {
+ cy.getTestElement(`fs-api-without`).click()
+ cy.waitForRouteChange().assertRoute(`/fs-api/without`)
+ })
+ it(`fs-api client only splat without slash`, () => {
+ cy.getTestElement(`fs-api-client-only-without`).click()
+ cy.waitForRouteChange().assertRoute(`/fs-api/without/without`)
+ cy.getTestElement(`title`).should(`have.text`, `without`)
+ })
+ it(`fs-api client only splat with slash`, () => {
+ cy.getTestElement(`fs-api-client-only-with`).click()
+ cy.waitForRouteChange().assertRoute(`/fs-api/with/with/`)
+ cy.getTestElement(`title`).should(`have.text`, `with`)
+ })
+ it(`fs-api-simple with slash`, () => {
+ cy.getTestElement(`fs-api-simple-with`).click()
+ cy.waitForRouteChange().assertRoute(`/fs-api-simple/with/`)
+ })
+ it(`fs-api-simple without slash`, () => {
+ cy.getTestElement(`fs-api-simple-without`).click()
+ cy.waitForRouteChange().assertRoute(`/fs-api-simple/without`)
+ })
+ it(`gatsbyPath works`, () => {
+ cy.getTestElement(`gatsby-path-1`).should(
+ "have.attr",
+ "href",
+ "/fs-api-simple/with/"
+ )
+ cy.getTestElement(`gatsby-path-2`).should(
+ "have.attr",
+ "href",
+ "/fs-api-simple/without"
+ )
+ })
+ it(`hash`, () => {
+ cy.getTestElement(`hash`).click()
+ cy.waitForRouteChange().assertRoute(`/page-2#anchor`)
+ })
+ it(`hash trailing`, () => {
+ cy.getTestElement(`hash-trailing`).click()
+ cy.waitForRouteChange().assertRoute(`/page-2/#anchor`)
+ })
+ it(`query-param`, () => {
+ cy.getTestElement(`query-param`).click()
+ cy.waitForRouteChange().assertRoute(`/page-2?query_param=hello`)
+ })
+ it(`query-param-hash`, () => {
+ cy.getTestElement(`query-param-hash`).click()
+ cy.waitForRouteChange().assertRoute(`/page-2?query_param=hello#anchor`)
+ })
+ it(`client-only without slash`, () => {
+ cy.getTestElement(`client-only-simple-without`).click()
+ cy.waitForRouteChange().assertRoute(`/client-only/without`)
+ cy.getTestElement(`title`).should(`have.text`, `without`)
+ })
+ it(`client-only with slash`, () => {
+ cy.getTestElement(`client-only-simple-with`).click()
+ cy.waitForRouteChange().assertRoute(`/client-only/with/`)
+ cy.getTestElement(`title`).should(`have.text`, `with`)
+ })
+ it(`client-only-splat without slash`, () => {
+ cy.getTestElement(`client-only-splat-without`).click()
+ cy.waitForRouteChange().assertRoute(`/client-only-splat/without/without`)
+ cy.getTestElement(`title`).should(`have.text`, `without/without`)
+ })
+ it(`client-only-splat with slash`, () => {
+ cy.getTestElement(`client-only-splat-with`).click()
+ cy.waitForRouteChange().assertRoute(`/client-only-splat/with/with/`)
+ cy.getTestElement(`title`).should(`have.text`, `with/with`)
+ })
+})
+
+const IS_BUILD = Cypress.env(`IS_BUILD`)
+
+describe(`ignore (direct visits)`, () => {
+ beforeEach(() => {
+ cy.visit(`/`).waitForRouteChange()
+ })
+
+ //Fix
+ it(`page-creator`, () => {
+ assertPageVisits([
+ {
+ path: "/page-2",
+ destinationPath: IS_BUILD ? `/page-2/` : false,
+ status: IS_BUILD ? 301 : 200,
+ },
+ ])
+
+ cy.visit(`/page-2`)
+ .waitForRouteChange()
+ // TODO(v5): Should behave like "always"
+ .assertRoute(IS_BUILD ? `/page-2/` : `/page-2`)
+ })
+
+ it(`create-page with`, () => {
+ assertPageVisits([{ path: "/create-page/with/", status: 200 }])
+
+ cy.visit(`/create-page/with/`)
+ .waitForRouteChange()
+ .assertRoute(`/create-page/with/`)
+ })
+
+ it(`create-page without`, () => {
+ assertPageVisits([{ path: "/create-page/without", status: 200 }])
+
+ cy.visit(`/create-page/without`)
+ .waitForRouteChange()
+ .assertRoute(`/create-page/without`)
+ })
+
+ it(`fs-api-simple with`, () => {
+ assertPageVisits([{ path: "/fs-api-simple/with/", status: 200 }])
+
+ cy.visit(`/fs-api-simple/with/`)
+ .waitForRouteChange()
+ .assertRoute(`/fs-api-simple/with/`)
+ })
+
+ it(`fs-api-simple without`, () => {
+ assertPageVisits([{ path: "/fs-api-simple/without", status: 200 }])
+
+ cy.visit(`/fs-api-simple/without`)
+ .waitForRouteChange()
+ .assertRoute(`/fs-api-simple/without`)
+ })
+
+ it(`fs-api client only splat with`, () => {
+ assertPageVisits([{ path: "/fs-api/with/with/", status: 200 }])
+
+ cy.visit(`/fs-api/with/with/`)
+ .waitForRouteChange()
+ .assertRoute(`/fs-api/with/with/`)
+ })
+
+ it(`fs-api client only splat without`, () => {
+ assertPageVisits([{ path: "/fs-api/without/without", status: 200 }])
+
+ cy.visit(`/fs-api/without/without`)
+ .waitForRouteChange()
+ .assertRoute(`/fs-api/without/without`)
+ })
+
+ it(`client-only with`, () => {
+ assertPageVisits([{ path: "/client-only/with/", status: 200 }])
+
+ cy.visit(`/client-only/with/`)
+ .waitForRouteChange()
+ .assertRoute(`/client-only/with/`)
+ })
+ it(`client-only without`, () => {
+ assertPageVisits([{ path: "/create-page/without", status: 200 }])
+
+ cy.visit(`/client-only/without`)
+ .waitForRouteChange()
+ .assertRoute(`/client-only/without`)
+ })
+ it(`client-only-splat with`, () => {
+ assertPageVisits([{ path: "/create-page/without", status: 200 }])
+
+ cy.visit(`/client-only-splat/with/with/`)
+ .waitForRouteChange()
+ .assertRoute(`/client-only-splat/with/with/`)
+ })
+ it(`client-only-splat without`, () => {
+ assertPageVisits([{ path: "/create-page/without", status: 200 }])
+
+ cy.visit(`/client-only-splat/without/without`)
+ .waitForRouteChange()
+ .assertRoute(`/client-only-splat/without/without`)
+ })
+ it(`query-param-hash with`, () => {
+ assertPageVisits([{ path: "/create-page/without", status: 200 }])
+
+ cy.visit(`/page-2/?query_param=hello#anchor`)
+ .waitForRouteChange()
+ .assertRoute(`/page-2/?query_param=hello#anchor`)
+ })
+ it(`query-param-hash without`, () => {
+ assertPageVisits([
+ {
+ path: "/page-2?query_param=hello#anchor",
+ status: IS_BUILD ? 301 : 200,
+ destinationPath: IS_BUILD
+ ? "/page-2/?query_param=hello#anchor"
+ : false,
+ },
+ ])
+
+ cy.visit(`/page-2?query_param=hello#anchor`)
+ .waitForRouteChange()
+ .assertRoute(
+ IS_BUILD
+ ? `/page-2/?query_param=hello#anchor`
+ : `/page-2?query_param=hello#anchor`
+ )
+ })
+})
diff --git a/e2e-tests/trailing-slash/cypress/integration/legacy.js b/e2e-tests/trailing-slash/cypress/integration/legacy.js
new file mode 100644
index 0000000000000..1a9090fc43af4
--- /dev/null
+++ b/e2e-tests/trailing-slash/cypress/integration/legacy.js
@@ -0,0 +1,274 @@
+import { assertPageVisits } from "../support/utils/trailing-slash"
+
+describe(`legacy`, () => {
+ beforeEach(() => {
+ cy.visit(`/`).waitForRouteChange()
+ })
+ it(`page-creator without slash`, () => {
+ cy.getTestElement(`page-creator-without`).click()
+ cy.waitForRouteChange().assertRoute(`/page-2`)
+ })
+ it(`page-creator with slash`, () => {
+ cy.getTestElement(`page-creator-with`).click()
+ cy.waitForRouteChange().assertRoute(`/page-2/`)
+ })
+ it(`create-page with slash`, () => {
+ cy.getTestElement(`create-page-with`).click()
+ cy.waitForRouteChange().assertRoute(`/create-page/with/`)
+ })
+ it(`create-page without slash`, () => {
+ cy.getTestElement(`create-page-without`).click()
+ cy.waitForRouteChange().assertRoute(`/create-page/without`)
+ })
+ it(`fs-api with slash`, () => {
+ cy.getTestElement(`fs-api-with`).click()
+ cy.waitForRouteChange().assertRoute(`/fs-api/with/`)
+ })
+ it(`fs-api without slash`, () => {
+ cy.getTestElement(`fs-api-without`).click()
+ cy.waitForRouteChange().assertRoute(`/fs-api/without`)
+ })
+ it(`fs-api client only splat without slash`, () => {
+ cy.getTestElement(`fs-api-client-only-without`).click()
+ cy.waitForRouteChange().assertRoute(`/fs-api/without/without`)
+ cy.getTestElement(`title`).should(`have.text`, `without`)
+ })
+ it(`fs-api client only splat with slash`, () => {
+ cy.getTestElement(`fs-api-client-only-with`).click()
+ cy.waitForRouteChange().assertRoute(`/fs-api/with/with/`)
+ cy.getTestElement(`title`).should(`have.text`, `with`)
+ })
+ it(`fs-api-simple with slash`, () => {
+ cy.getTestElement(`fs-api-simple-with`).click()
+ cy.waitForRouteChange().assertRoute(`/fs-api-simple/with/`)
+ })
+ it(`fs-api-simple without slash`, () => {
+ cy.getTestElement(`fs-api-simple-without`).click()
+ cy.waitForRouteChange().assertRoute(`/fs-api-simple/without`)
+ })
+ it(`gatsbyPath works`, () => {
+ cy.getTestElement(`gatsby-path-1`).should(
+ "have.attr",
+ "href",
+ "/fs-api-simple/with/"
+ )
+ cy.getTestElement(`gatsby-path-2`).should(
+ "have.attr",
+ "href",
+ "/fs-api-simple/without/"
+ )
+ })
+ it(`hash`, () => {
+ cy.getTestElement(`hash`).click()
+ cy.waitForRouteChange().assertRoute(`/page-2#anchor`)
+ })
+ it(`hash trailing`, () => {
+ cy.getTestElement(`hash-trailing`).click()
+ cy.waitForRouteChange().assertRoute(`/page-2/#anchor`)
+ })
+ it(`query-param`, () => {
+ cy.getTestElement(`query-param`).click()
+ cy.waitForRouteChange().assertRoute(`/page-2?query_param=hello`)
+ })
+ it(`query-param-hash`, () => {
+ cy.getTestElement(`query-param-hash`).click()
+ cy.waitForRouteChange().assertRoute(`/page-2?query_param=hello#anchor`)
+ })
+ it(`client-only without slash`, () => {
+ cy.getTestElement(`client-only-simple-without`).click()
+ cy.waitForRouteChange().assertRoute(`/client-only/without`)
+ cy.getTestElement(`title`).should(`have.text`, `without`)
+ })
+ it(`client-only with slash`, () => {
+ cy.getTestElement(`client-only-simple-with`).click()
+ cy.waitForRouteChange().assertRoute(`/client-only/with/`)
+ cy.getTestElement(`title`).should(`have.text`, `with`)
+ })
+ it(`client-only-splat without slash`, () => {
+ cy.getTestElement(`client-only-splat-without`).click()
+ cy.waitForRouteChange().assertRoute(`/client-only-splat/without/without`)
+ cy.getTestElement(`title`).should(`have.text`, `without/without`)
+ })
+ it(`client-only-splat with slash`, () => {
+ cy.getTestElement(`client-only-splat-with`).click()
+ cy.waitForRouteChange().assertRoute(`/client-only-splat/with/with/`)
+ cy.getTestElement(`title`).should(`have.text`, `with/with`)
+ })
+})
+
+const IS_BUILD = Cypress.env(`IS_BUILD`)
+
+describe(`legacy (direct visits)`, () => {
+ beforeEach(() => {
+ cy.visit(`/`).waitForRouteChange()
+ })
+
+ it(`page-creator`, () => {
+ assertPageVisits([
+ {
+ path: "/page-2/",
+ destinationPath: IS_BUILD ? false : `/page-2`,
+ status: IS_BUILD ? 200 : 301,
+ },
+ ])
+
+ cy.visit(`/page-2`)
+ .waitForRouteChange()
+ .assertRoute(IS_BUILD ? `/page-2/` : `/page-2`)
+ })
+
+ it(`create-page with`, () => {
+ assertPageVisits([
+ {
+ path: "/create-page/with/",
+ status: 200,
+ },
+ ])
+
+ cy.visit(`/create-page/with/`)
+ .waitForRouteChange()
+ .assertRoute(`/create-page/with/`)
+ })
+
+ it(`create-page without`, () => {
+ assertPageVisits([
+ {
+ path: "/create-page/without",
+ status: 200,
+ },
+ ])
+
+ cy.visit(`/create-page/without`)
+ .waitForRouteChange()
+ .assertRoute(`/create-page/without`)
+ })
+ it(`fs-api-simple with`, () => {
+ assertPageVisits([
+ {
+ path: "/fs-api-simple/with/",
+ status: 200,
+ },
+ ])
+
+ cy.visit(`/fs-api-simple/with/`)
+ .waitForRouteChange()
+ .assertRoute(`/fs-api-simple/with/`)
+ })
+ it(`fs-api-simple without`, () => {
+ assertPageVisits([
+ {
+ path: "/fs-api-simple/without",
+ status: IS_BUILD ? 301 : 200,
+ destinationPath: IS_BUILD ? `/fs-api-simple/without/` : false,
+ },
+ ])
+
+ cy.visit(`/fs-api-simple/without`)
+ .waitForRouteChange()
+ .assertRoute(
+ IS_BUILD ? `/fs-api-simple/without/` : `/fs-api-simple/without`
+ )
+ })
+ it(`fs-api client only splat with`, () => {
+ assertPageVisits([
+ {
+ path: "/fs-api/with/with/",
+ status: 200,
+ },
+ ])
+
+ cy.visit(`/fs-api/with/with/`)
+ .waitForRouteChange()
+ .assertRoute(`/fs-api/with/with/`)
+ })
+ it(`fs-api client only splat without`, () => {
+ assertPageVisits([
+ {
+ path: "/fs-api/without/without",
+ status: 200,
+ },
+ ])
+
+ cy.visit(`/fs-api/without/without`)
+ .waitForRouteChange()
+ .assertRoute(`/fs-api/without/without`)
+ })
+ it(`client-only with`, () => {
+ assertPageVisits([
+ {
+ path: "/client-only/with/",
+ status: 200,
+ },
+ ])
+
+ cy.visit(`/client-only/with/`)
+ .waitForRouteChange()
+ .assertRoute(`/client-only/with/`)
+ })
+ it(`client-only without`, () => {
+ assertPageVisits([
+ {
+ path: "/client-only/without",
+ status: 200,
+ },
+ ])
+
+ cy.visit(`/client-only/without`)
+ .waitForRouteChange()
+ .assertRoute(`/client-only/without`)
+ })
+ it(`client-only-splat with`, () => {
+ assertPageVisits([
+ {
+ path: `/client-only-splat/with/with/`,
+ status: 200,
+ },
+ ])
+
+ cy.visit(`/client-only-splat/with/with/`)
+ .waitForRouteChange()
+ .assertRoute(`/client-only-splat/with/with/`)
+ })
+ it(`client-only-splat without`, () => {
+ assertPageVisits([
+ {
+ path: "/client-only-splat/without/without",
+ status: 200,
+ },
+ ])
+
+ cy.visit(`/client-only-splat/without/without`)
+ .waitForRouteChange()
+ .assertRoute(`/client-only-splat/without/without`)
+ })
+ it(`query-param-hash with`, () => {
+ assertPageVisits([
+ {
+ path: "/page-2/?query_param=hello#anchor",
+ status: 200,
+ },
+ ])
+
+ cy.visit(`/page-2/?query_param=hello#anchor`)
+ .waitForRouteChange()
+ .assertRoute(`/page-2/?query_param=hello#anchor`)
+ })
+
+ it(`query-param-hash without`, () => {
+ assertPageVisits([
+ {
+ path: "/page-2?query_param=hello#anchor",
+ status: IS_BUILD ? 301 : 200,
+ destinationPath: IS_BUILD ? `/page-2?query_param=hello#anchor` : false,
+ },
+ ])
+
+ cy.visit(`/page-2?query_param=hello#anchor`)
+ .waitForRouteChange()
+ .assertRoute(
+ IS_BUILD
+ ? `/page-2/?query_param=hello#anchor`
+ : `/page-2?query_param=hello#anchor`
+ )
+ })
+})
diff --git a/e2e-tests/trailing-slash/cypress/integration/never.js b/e2e-tests/trailing-slash/cypress/integration/never.js
new file mode 100644
index 0000000000000..77caf305947cf
--- /dev/null
+++ b/e2e-tests/trailing-slash/cypress/integration/never.js
@@ -0,0 +1,284 @@
+import { assertPageVisits } from "../support/utils/trailing-slash"
+
+describe(`never`, () => {
+ beforeEach(() => {
+ cy.visit(`/`).waitForRouteChange()
+ })
+ it(`page-creator without slash`, () => {
+ cy.getTestElement(`page-creator-without`).click()
+ cy.waitForRouteChange().assertRoute(`/page-2`)
+ })
+ it(`page-creator with slash`, () => {
+ cy.getTestElement(`page-creator-with`).click()
+ cy.waitForRouteChange().assertRoute(`/page-2`)
+ })
+ it(`create-page with slash`, () => {
+ cy.getTestElement(`create-page-with`).click()
+ cy.waitForRouteChange().assertRoute(`/create-page/with`)
+ })
+ it(`create-page without slash`, () => {
+ cy.getTestElement(`create-page-without`).click()
+ cy.waitForRouteChange().assertRoute(`/create-page/without`)
+ })
+ it(`fs-api with slash`, () => {
+ cy.getTestElement(`fs-api-with`).click()
+ cy.waitForRouteChange().assertRoute(`/fs-api/with`)
+ })
+ it(`fs-api without slash`, () => {
+ cy.getTestElement(`fs-api-without`).click()
+ cy.waitForRouteChange().assertRoute(`/fs-api/without`)
+ })
+ it(`fs-api client only splat without slash`, () => {
+ cy.getTestElement(`fs-api-client-only-without`).click()
+ cy.waitForRouteChange().assertRoute(`/fs-api/without/without`)
+ cy.getTestElement(`title`).should(`have.text`, `without`)
+ })
+ it(`fs-api client only splat with slash`, () => {
+ cy.getTestElement(`fs-api-client-only-with`).click()
+ cy.waitForRouteChange().assertRoute(`/fs-api/with/with`)
+ cy.getTestElement(`title`).should(`have.text`, `with`)
+ })
+ it(`fs-api-simple with slash`, () => {
+ cy.getTestElement(`fs-api-simple-with`).click()
+ cy.waitForRouteChange().assertRoute(`/fs-api-simple/with`)
+ })
+ it(`fs-api-simple without slash`, () => {
+ cy.getTestElement(`fs-api-simple-without`).click()
+ cy.waitForRouteChange().assertRoute(`/fs-api-simple/without`)
+ })
+ it(`gatsbyPath works`, () => {
+ cy.getTestElement(`gatsby-path-1`).should(
+ "have.attr",
+ "href",
+ "/fs-api-simple/with"
+ )
+ cy.getTestElement(`gatsby-path-2`).should(
+ "have.attr",
+ "href",
+ "/fs-api-simple/without"
+ )
+ })
+ it(`hash`, () => {
+ cy.getTestElement(`hash`).click()
+ cy.waitForRouteChange().assertRoute(`/page-2#anchor`)
+ })
+ it(`hash trailing`, () => {
+ cy.getTestElement(`hash-trailing`).click()
+ cy.waitForRouteChange().assertRoute(`/page-2#anchor`)
+ })
+ it(`query-param`, () => {
+ cy.getTestElement(`query-param`).click()
+ cy.waitForRouteChange().assertRoute(`/page-2?query_param=hello`)
+ })
+ it(`query-param-hash`, () => {
+ cy.getTestElement(`query-param-hash`).click()
+ cy.waitForRouteChange().assertRoute(`/page-2?query_param=hello#anchor`)
+ })
+ it(`client-only without slash`, () => {
+ cy.getTestElement(`client-only-simple-without`).click()
+ cy.waitForRouteChange().assertRoute(`/client-only/without`)
+ cy.getTestElement(`title`).should(`have.text`, `without`)
+ })
+ it(`client-only with slash`, () => {
+ cy.getTestElement(`client-only-simple-with`).click()
+ cy.waitForRouteChange().assertRoute(`/client-only/with`)
+ cy.getTestElement(`title`).should(`have.text`, `with`)
+ })
+ it(`client-only-splat without slash`, () => {
+ cy.getTestElement(`client-only-splat-without`).click()
+ cy.waitForRouteChange().assertRoute(`/client-only-splat/without/without`)
+ cy.getTestElement(`title`).should(`have.text`, `without/without`)
+ })
+ it(`client-only-splat with slash`, () => {
+ cy.getTestElement(`client-only-splat-with`).click()
+ cy.waitForRouteChange().assertRoute(`/client-only-splat/with/with`)
+ cy.getTestElement(`title`).should(`have.text`, `with/with`)
+ })
+})
+
+describe(`never (direct visits)`, () => {
+ beforeEach(() => {
+ cy.visit(`/`).waitForRouteChange()
+ })
+
+ it(`page-creator`, () => {
+ assertPageVisits([
+ {
+ path: "/page-2",
+ status: 200,
+ },
+ ])
+
+ cy.visit(`/page-2`).waitForRouteChange().assertRoute(`/page-2`)
+ })
+
+ it(`create-page with`, () => {
+ assertPageVisits([
+ {
+ path: "/create-page/with/",
+ status: 301,
+ destinationPath: "/create-page/with",
+ },
+ ])
+
+ cy.visit(`/create-page/with/`)
+ .waitForRouteChange()
+ .assertRoute(`/create-page/with`)
+ })
+
+ it(`create-page without`, () => {
+ assertPageVisits([
+ {
+ path: "/create-page/without",
+ status: 200,
+ },
+ ])
+
+ cy.visit(`/create-page/without`)
+ .waitForRouteChange()
+ .assertRoute(`/create-page/without`)
+ })
+
+ it(`fs-api-simple with`, () => {
+ assertPageVisits([
+ {
+ path: "/fs-api-simple/with/",
+ status: 301,
+ destinationPath: "/fs-api-simple/with",
+ },
+ {
+ path: "/fs-api-simple/without",
+ status: 301,
+ destinationPath: "/fs-api-simple/without",
+ },
+ ])
+
+ cy.visit(`/fs-api-simple/with/`)
+ .waitForRouteChange()
+ .assertRoute(`/fs-api-simple/with`)
+ cy.visit(`/fs-api-simple/without`)
+ .waitForRouteChange()
+ .assertRoute(`/fs-api-simple/without`)
+ })
+
+ it(`fs-api-simple without`, () => {
+ assertPageVisits([
+ {
+ path: "/fs-api-simple/without",
+ status: 200,
+ },
+ ])
+
+ cy.visit(`/fs-api-simple/without`)
+ .waitForRouteChange()
+ .assertRoute(`/fs-api-simple/without`)
+ })
+
+ it(`fs-api client only splat with`, () => {
+ assertPageVisits([
+ {
+ path: "/fs-api/with/with/",
+ status: 301,
+ destinationPath: "/fs-api/with/with",
+ },
+ ])
+
+ cy.visit(`/fs-api/with/with/`)
+ .waitForRouteChange()
+ .assertRoute(`/fs-api/with/with`)
+ })
+
+ it(`fs-api client only splat without`, () => {
+ assertPageVisits([
+ {
+ path: "/fs-api/without/without",
+ status: 200,
+ },
+ ])
+
+ cy.visit(`/fs-api/without/without`)
+ .waitForRouteChange()
+ .assertRoute(`/fs-api/without/without`)
+ })
+
+ it(`client-only with`, () => {
+ assertPageVisits([
+ {
+ path: "/client-only/with/",
+ status: 301,
+ destinationPath: "/client-only/with",
+ },
+ ])
+
+ cy.visit(`/client-only/with/`)
+ .waitForRouteChange()
+ .assertRoute(`/client-only/with`)
+ })
+
+ it(`client-only without`, () => {
+ assertPageVisits([
+ {
+ path: "/client-only/without",
+ status: 200,
+ },
+ ])
+
+ cy.visit(`/client-only/without`)
+ .waitForRouteChange()
+ .assertRoute(`/client-only/without`)
+ })
+
+ it(`client-only-splat with`, () => {
+ assertPageVisits([
+ {
+ path: "/client-only-splat/with/with/",
+ status: 301,
+ destinationPath: "/client-only-splat/with/with",
+ },
+ ])
+
+ cy.visit(`/client-only-splat/with/with/`)
+ .waitForRouteChange()
+ .assertRoute(`/client-only-splat/with/with`)
+ })
+
+ it(`client-only-splat without`, () => {
+ assertPageVisits([
+ {
+ path: "/client-only-splat/without/without",
+ status: 200,
+ },
+ ])
+
+ cy.visit(`/client-only-splat/without/without`)
+ .waitForRouteChange()
+ .assertRoute(`/client-only-splat/without/without`)
+ })
+
+ it(`query-param-hash with`, () => {
+ assertPageVisits([
+ {
+ path: "/page-2/?query_param=hello#anchor",
+ status: 301,
+ destinationPath: "/page-2?query_param=hello#anchor",
+ },
+ ])
+
+ cy.visit(`/page-2/?query_param=hello#anchor`)
+ .waitForRouteChange()
+ .assertRoute(`/page-2?query_param=hello#anchor`)
+ })
+
+ it(`query-param-hash without`, () => {
+ assertPageVisits([
+ {
+ path: "/page-2?query_param=hello#anchor",
+ status: 200,
+ },
+ ])
+
+ cy.visit(`/page-2?query_param=hello#anchor`)
+ .waitForRouteChange()
+ .assertRoute(`/page-2?query_param=hello#anchor`)
+ })
+})
diff --git a/e2e-tests/trailing-slash/cypress/integration/static.js b/e2e-tests/trailing-slash/cypress/integration/static.js
new file mode 100644
index 0000000000000..6b1e7a8872ea1
--- /dev/null
+++ b/e2e-tests/trailing-slash/cypress/integration/static.js
@@ -0,0 +1,41 @@
+import { assertPageVisits } from "../support/utils/trailing-slash"
+
+const IS_BUILD = Cypress.env(`IS_BUILD`)
+
+const itWhenIsBuild = IS_BUILD ? it : it.skip
+
+describe(`static directory`, () => {
+ describe(`static/something.html`, () => {
+ itWhenIsBuild(`visiting directly result in 200`, () => {
+ assertPageVisits([{ path: "/static/something.html", status: 200 }])
+
+ cy.visit(`/something.html`).assertRoute(`/something.html`)
+ })
+
+ it(`adding trailing slash result in 404`, () => {
+ // works for build+serve, doesn't work for develop
+ assertPageVisits([{ path: "/something.html/", status: 404 }])
+
+ cy.visit(`/something.html/`, {
+ failOnStatusCode: false,
+ }).assertRoute(`/something.html/`)
+ })
+ })
+
+ describe(`static/nested/index.html`, () => {
+ itWhenIsBuild(
+ `visiting without trailing slash redirects to trailing slash`,
+ () => {
+ assertPageVisits([{ path: "/nested", status: 200 }])
+
+ cy.visit(`/nested`).assertRoute(`/nested/`)
+ }
+ )
+
+ it(`visiting with trailing slash returns 200`, () => {
+ assertPageVisits([{ path: "/nested/", status: 404 }])
+
+ cy.visit(`/nested/`, { failOnStatusCode: false }).assertRoute(`/nested/`)
+ })
+ })
+})
diff --git a/e2e-tests/trailing-slash/cypress/plugins/index.js b/e2e-tests/trailing-slash/cypress/plugins/index.js
new file mode 100644
index 0000000000000..fd170fba6912b
--- /dev/null
+++ b/e2e-tests/trailing-slash/cypress/plugins/index.js
@@ -0,0 +1,17 @@
+// ***********************************************************
+// This example plugins/index.js can be used to load plugins
+//
+// You can change the location of this file or turn off loading
+// the plugins file with the 'pluginsFile' configuration option.
+//
+// You can read more here:
+// https://on.cypress.io/plugins-guide
+// ***********************************************************
+
+// This function is called when a project is opened or re-opened (e.g. due to
+// the project's config changing)
+
+module.exports = (on, config) => {
+ // `on` is used to hook into various events Cypress emits
+ // `config` is the resolved Cypress config
+}
diff --git a/e2e-tests/trailing-slash/cypress/support/commands.js b/e2e-tests/trailing-slash/cypress/support/commands.js
new file mode 100644
index 0000000000000..a13f2cb8f7884
--- /dev/null
+++ b/e2e-tests/trailing-slash/cypress/support/commands.js
@@ -0,0 +1,3 @@
+Cypress.Commands.add(`assertRoute`, route => {
+ cy.url().should(`equal`, `${window.location.origin}${route}`)
+})
diff --git a/e2e-tests/trailing-slash/cypress/support/index.js b/e2e-tests/trailing-slash/cypress/support/index.js
new file mode 100644
index 0000000000000..83237f7c18da2
--- /dev/null
+++ b/e2e-tests/trailing-slash/cypress/support/index.js
@@ -0,0 +1,2 @@
+import "gatsby-cypress"
+import "./commands"
diff --git a/e2e-tests/trailing-slash/cypress/support/utils/trailing-slash.js b/e2e-tests/trailing-slash/cypress/support/utils/trailing-slash.js
new file mode 100644
index 0000000000000..50b59854dd8dc
--- /dev/null
+++ b/e2e-tests/trailing-slash/cypress/support/utils/trailing-slash.js
@@ -0,0 +1,16 @@
+export function assertPageVisits(pages) {
+ for (let i = 0; i < pages; i++) {
+ const page = pages[i]
+
+ cy.intercept(new RegExp(`^${page.path}$`), req => {
+ req.continue(res => {
+ expect(res.statusCode).to.equal(page.status)
+ if (page.destinationPath) {
+ expect(res.headers.location).to.equal(page.destinationPath)
+ } else {
+ expect(res.headers.location).toBeUndefined()
+ }
+ })
+ })
+ }
+}
diff --git a/e2e-tests/trailing-slash/gatsby-browser.js b/e2e-tests/trailing-slash/gatsby-browser.js
new file mode 100644
index 0000000000000..be552c7f5970e
--- /dev/null
+++ b/e2e-tests/trailing-slash/gatsby-browser.js
@@ -0,0 +1 @@
+import "./global.css"
diff --git a/e2e-tests/trailing-slash/gatsby-config.js b/e2e-tests/trailing-slash/gatsby-config.js
new file mode 100644
index 0000000000000..01a4fb4540838
--- /dev/null
+++ b/e2e-tests/trailing-slash/gatsby-config.js
@@ -0,0 +1,12 @@
+const trailingSlash = process.env.TRAILING_SLASH || `legacy`
+console.info(`TrailingSlash: ${trailingSlash}`)
+
+module.exports = {
+ trailingSlash,
+ siteMetadata: {
+ siteMetadata: {
+ siteUrl: `https://www.domain.tld`,
+ title: `Trailing Slash`,
+ },
+ },
+}
diff --git a/e2e-tests/trailing-slash/gatsby-node.js b/e2e-tests/trailing-slash/gatsby-node.js
new file mode 100644
index 0000000000000..36a7821c020d8
--- /dev/null
+++ b/e2e-tests/trailing-slash/gatsby-node.js
@@ -0,0 +1,78 @@
+const posts = [
+ {
+ id: 1,
+ slug: `/with/`,
+ title: `With Trailing Slash`,
+ content: `With Trailing Slash`,
+ },
+ {
+ id: 2,
+ slug: `/without`,
+ title: `Without Trailing Slash`,
+ content: `Without Trailing Slash`,
+ },
+ {
+ id: 3,
+ slug: `/`,
+ title: `Index page`,
+ content: `This is an index page`,
+ },
+]
+
+exports.sourceNodes = ({ actions, createNodeId, createContentDigest }) => {
+ const { createNode } = actions
+
+ posts.forEach(post => {
+ createNode({
+ ...post,
+ id: createNodeId(`post-${post.id}`),
+ _id: post.id,
+ parent: null,
+ children: [],
+ internal: {
+ type: `Post`,
+ content: JSON.stringify(post),
+ contentDigest: createContentDigest(post),
+ },
+ })
+ })
+}
+
+exports.createSchemaCustomization = ({ actions }) => {
+ const { createTypes } = actions
+
+ createTypes(`#graphql
+ type Post implements Node {
+ id: ID!
+ slug: String!
+ title: String!
+ content: String!
+ }
+ `)
+}
+
+const templatePath = require.resolve(`./src/templates/template.js`)
+
+exports.createPages = async ({ graphql, actions }) => {
+ const { createPage } = actions
+
+ const result = await graphql(`
+ {
+ allPost {
+ nodes {
+ slug
+ }
+ }
+ }
+ `)
+
+ result.data.allPost.nodes.forEach(node => {
+ createPage({
+ path: `/create-page${node.slug}`,
+ component: templatePath,
+ context: {
+ slug: node.slug,
+ },
+ })
+ })
+}
diff --git a/e2e-tests/trailing-slash/global.css b/e2e-tests/trailing-slash/global.css
new file mode 100644
index 0000000000000..2369598c9758b
--- /dev/null
+++ b/e2e-tests/trailing-slash/global.css
@@ -0,0 +1,69 @@
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+}
+* {
+ margin: 0;
+}
+html,
+body {
+ height: 100%;
+}
+body {
+ line-height: 1.5;
+ -webkit-font-smoothing: antialiased;
+}
+img,
+picture,
+video,
+canvas,
+svg {
+ display: block;
+ max-width: 100%;
+}
+input,
+button,
+textarea,
+select {
+ font: inherit;
+}
+p,
+h1,
+h2,
+h3,
+h4,
+h5,
+h6 {
+ overflow-wrap: break-word;
+}
+#__gatsby {
+ isolation: isolate;
+}
+
+:root {
+ --light-gray: #e2e8f0;
+ --dark-gray: #1d2739;
+ --body-bg: var(--light-gray);
+ --body-color: var(--dark-gray);
+ --link-color: #000;
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --body-bg: var(--dark-gray);
+ --body-color: var(--light-gray);
+ --link-color: #fff;
+ }
+}
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica,
+ Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
+ background: var(--body-bg);
+ color: var(--body-color);
+}
+
+a {
+ color: var(--link-color);
+}
diff --git a/e2e-tests/trailing-slash/package.json b/e2e-tests/trailing-slash/package.json
new file mode 100644
index 0000000000000..652ecf1ec1ddf
--- /dev/null
+++ b/e2e-tests/trailing-slash/package.json
@@ -0,0 +1,42 @@
+{
+ "name": "trailing-slash",
+ "description": "E2E Tests for trailingSlash config option",
+ "version": "1.0.0",
+ "author": "LekoArts",
+ "dependencies": {
+ "cypress": "^9.1.1",
+ "gatsby": "next",
+ "react": "^17.0.2",
+ "react-dom": "^17.0.2"
+ },
+ "license": "MIT",
+ "scripts": {
+ "develop": "cross-env CYPRESS_SUPPORT=y gatsby develop",
+ "build": "cross-env CYPRESS_SUPPORT=y gatsby build",
+ "clean": "gatsby clean",
+ "serve": "gatsby serve",
+ "format": "prettier --write '**/*.js' --ignore-path .gitignore",
+ "cy:open:develop": "cypress open --config baseUrl=http://localhost:8000",
+ "cy:open:build": "cypress open --config baseUrl=http://localhost:9000 --env IS_BUILD=y",
+ "debug:develop": "start-server-and-test develop http://localhost:8000 cy:open:develop",
+ "debug:build": "start-server-and-test serve http://localhost:9000 cy:open:build",
+ "cy:develop:option": "cross-env-shell node ../../scripts/cypress-run-with-conditional-record-flag.js --browser chrome --config-file \"cypress-$OPTION.json\" --config baseUrl=http://localhost:8000",
+ "cy:build:option": "cross-env-shell node ../../scripts/cypress-run-with-conditional-record-flag.js --browser chrome --config-file \"cypress-$OPTION.json\" --config baseUrl=http://localhost:9000 --env IS_BUILD=y",
+ "develop:option": "cross-env-shell CYPRESS_SUPPORT=y TRAILING_SLASH=$OPTION gatsby develop",
+ "build:option": "cross-env-shell CYPRESS_SUPPORT=y TRAILING_SLASH=$OPTION gatsby build",
+ "t:opt:develop": "cross-env-shell OPTION=$OPTION start-server-and-test develop:option http://localhost:8000 cy:develop:option",
+ "t:opt:build": "cross-env-shell OPTION=$OPTION TRAILING_SLASH=$OPTION start-server-and-test serve http://localhost:9000 cy:build:option",
+ "test:always": "cross-env OPTION=always npm run build:option && cross-env OPTION=always npm run t:opt:build && npm run clean && cross-env OPTION=always npm run t:opt:develop",
+ "test:never": "cross-env OPTION=never npm run build:option && cross-env OPTION=never npm run t:opt:build && npm run clean && cross-env OPTION=never npm run t:opt:develop",
+ "test:ignore": "cross-env OPTION=ignore npm run build:option && cross-env OPTION=ignore npm run t:opt:build && npm run clean && cross-env OPTION=ignore npm run t:opt:develop",
+ "test:legacy": "cross-env OPTION=legacy npm run build:option && cross-env OPTION=legacy npm run t:opt:build && npm run clean && cross-env OPTION=legacy npm run t:opt:develop",
+ "test": "npm-run-all -c -s test:always test:never test:ignore test:legacy"
+ },
+ "devDependencies": {
+ "cross-env": "^7.0.3",
+ "gatsby-cypress": "^2.4.0",
+ "npm-run-all": "^4.1.5",
+ "prettier": "^2.5.1",
+ "start-server-and-test": "^1.14.0"
+ }
+}
diff --git a/e2e-tests/trailing-slash/src/api/nested/index.js b/e2e-tests/trailing-slash/src/api/nested/index.js
new file mode 100644
index 0000000000000..4c22214ed5443
--- /dev/null
+++ b/e2e-tests/trailing-slash/src/api/nested/index.js
@@ -0,0 +1,4 @@
+module.exports = (req, res) => {
+ res.send(`hello`)
+}
+
diff --git a/e2e-tests/trailing-slash/src/api/test.js b/e2e-tests/trailing-slash/src/api/test.js
new file mode 100644
index 0000000000000..a7137d46d07c4
--- /dev/null
+++ b/e2e-tests/trailing-slash/src/api/test.js
@@ -0,0 +1,3 @@
+module.exports = (req, res) => {
+ res.send(`hello`)
+}
\ No newline at end of file
diff --git a/e2e-tests/trailing-slash/src/pages/client-only-splat/[...name].js b/e2e-tests/trailing-slash/src/pages/client-only-splat/[...name].js
new file mode 100644
index 0000000000000..58087626e61ef
--- /dev/null
+++ b/e2e-tests/trailing-slash/src/pages/client-only-splat/[...name].js
@@ -0,0 +1,14 @@
+import * as React from "react"
+
+const ClientOnlySplatNamePage = ({ params }) => {
+ return (
+
+ {params.name}
+
+ {JSON.stringify(params, null, 2)}
+
+
+ )
+}
+
+export default ClientOnlySplatNamePage
diff --git a/e2e-tests/trailing-slash/src/pages/client-only/[name].js b/e2e-tests/trailing-slash/src/pages/client-only/[name].js
new file mode 100644
index 0000000000000..e8a4c7d19cbf3
--- /dev/null
+++ b/e2e-tests/trailing-slash/src/pages/client-only/[name].js
@@ -0,0 +1,14 @@
+import * as React from "react"
+
+const ClientOnlyNamePage = ({ params }) => {
+ return (
+
+ {params.name}
+
+ {JSON.stringify(params, null, 2)}
+
+
+ )
+}
+
+export default ClientOnlyNamePage
diff --git a/e2e-tests/trailing-slash/src/pages/fs-api-simple/{Post.slug}.js b/e2e-tests/trailing-slash/src/pages/fs-api-simple/{Post.slug}.js
new file mode 100644
index 0000000000000..5e816cc0affcd
--- /dev/null
+++ b/e2e-tests/trailing-slash/src/pages/fs-api-simple/{Post.slug}.js
@@ -0,0 +1,18 @@
+import * as React from "react"
+import { Link } from "gatsby"
+
+const FSApiSimplePage = ({ pageContext }) => {
+ return (
+
+ {pageContext.slug}
+
+ Go Back
+
+
+ {JSON.stringify(pageContext, null, 2)}
+
+
+ )
+}
+
+export default FSApiSimplePage
diff --git a/e2e-tests/trailing-slash/src/pages/fs-api/{Post.slug}/[...name].js b/e2e-tests/trailing-slash/src/pages/fs-api/{Post.slug}/[...name].js
new file mode 100644
index 0000000000000..66d7201f743b7
--- /dev/null
+++ b/e2e-tests/trailing-slash/src/pages/fs-api/{Post.slug}/[...name].js
@@ -0,0 +1,14 @@
+import * as React from "react"
+
+const FSApiClientOnlySplatNamePage = ({ params }) => {
+ return (
+
+ {params.name}
+
+ {JSON.stringify(params, null, 2)}
+
+
+ )
+}
+
+export default FSApiClientOnlySplatNamePage
diff --git a/e2e-tests/trailing-slash/src/pages/fs-api/{Post.slug}/index.js b/e2e-tests/trailing-slash/src/pages/fs-api/{Post.slug}/index.js
new file mode 100644
index 0000000000000..6eccb0ae3eef2
--- /dev/null
+++ b/e2e-tests/trailing-slash/src/pages/fs-api/{Post.slug}/index.js
@@ -0,0 +1,18 @@
+import * as React from "react"
+import { Link } from "gatsby"
+
+const FSApiPage = ({ pageContext }) => {
+ return (
+
+ {pageContext.slug}
+
+ Go Back
+
+
+ {JSON.stringify(pageContext, null, 2)}
+
+
+ )
+}
+
+export default FSApiPage
diff --git a/e2e-tests/trailing-slash/src/pages/index.js b/e2e-tests/trailing-slash/src/pages/index.js
new file mode 100644
index 0000000000000..6583ee69ee27c
--- /dev/null
+++ b/e2e-tests/trailing-slash/src/pages/index.js
@@ -0,0 +1,141 @@
+import * as React from "react"
+import { Link, graphql } from "gatsby"
+
+const IndexPage = ({ data }) => {
+ const {
+ allPost: { nodes: posts },
+ } = data
+ return (
+
+ Trailing Slash Testing
+
+ -
+
+ Page Creator Without Trailing Slash
+
+
+ -
+
+ Page Creator With Trailing Slash
+
+
+ -
+
+ Create Page With Trailing Slash
+
+
+ -
+
+ Create Page Without Trailing Slash
+
+
+ -
+
+ FS API With Trailing Slash
+
+
+ -
+
+ FS API Without Trailing Slash
+
+
+ -
+
+ FS API Client-Only Without Trailing Slash
+
+
+ -
+
+ FS API Client-Only With Trailing Slash
+
+
+ -
+
+ FS API Simple With Trailing Slash
+
+
+ -
+
+ FS API Simple Without Trailing Slash
+
+
+ -
+
+ Go to page-2 with hash
+
+
+ -
+
+ Go to page-2 with hash With Trailing Slash
+
+
+ -
+
+ Go to page-2 with query param
+
+
+ -
+
+ Go to page-2 with query param and hash
+
+
+ -
+
+ Client-Only Simple Without Trailing Slash
+
+
+ -
+
+ Client-Only Simple With Trailing Slash
+
+
+ -
+
+ Client-Only-Splat Without Trailing Slash
+
+
+ -
+
+ Client-Only-Splat With Trailing Slash
+
+
+ {posts.map(post => (
+ -
+
+ Go to {post.slug} from gatsbyPath
+
+
+ ))}
+
+
+ )
+}
+
+export default IndexPage
+
+export const query = graphql`
+ {
+ allPost {
+ nodes {
+ _id
+ slug
+ gatsbyPath(filePath: "/fs-api-simple/{Post.slug}")
+ }
+ }
+ }
+`
diff --git a/e2e-tests/trailing-slash/src/pages/page-2.js b/e2e-tests/trailing-slash/src/pages/page-2.js
new file mode 100644
index 0000000000000..72dcfe568d8a7
--- /dev/null
+++ b/e2e-tests/trailing-slash/src/pages/page-2.js
@@ -0,0 +1,64 @@
+import * as React from "react"
+import { Link } from "gatsby"
+
+const PageTwo = () => {
+ return (
+
+ Page Two
+
+ Go Back
+
+
+ Tergeo mobilicorpus mortis nox tarantallegra mobilicorpus felicis
+ locomotor unction. Sonorus evanesco riddikulus lumos sonorus curse.
+ Mobilicorpus mortis leviosa lumos dissendium funnunculus. Imperio
+ reducio cruciatus portus evanesco imperio crucio inflamarae. Rictusempra
+ immobilus incarcerous ennervate muffliato evanesco. Engorgio locomotor
+ stupefy mobilicorpus. Locomotor homorphus leviosa accio incantartem
+ totalus sonorus sectumsempra lumos protego aparecium. Impedimenta
+ incarcerous petrificus patronum exume impedimenta accio immobilus
+ aparecium tarantallegra vipera. Arania arania quietus patronum
+ funnunculus. Wingardium leviosa felicis nox tarantallegra expecto
+ quietus jelly-legs. Mortis ennervate patronum serpensortia expecto
+ mobilicorpus waddiwasi. Legilimens legilimens protego inflamarae
+ specialis leviosa portus diffindo tarantallegra immobilus. Impedimenta
+ momentum me jelly-legs. Sonorus aresto densaugeo confundus immobilus
+ accio quodpot evanesco imperio totalus patronum. Reducto leviosa nox
+ portus funnunculus confundus cruciatus. Incarcerous portus sonorus
+ babbling impedimenta. Finite evanesco wingardium kedavra momentum
+ bulbadox lumos evanesco cushioning arania. Locomotor unction sonorus
+ wingardium expelliarumus dissendium aresto. Legilimens sonorus
+ imperturbable mobilicorpus lumos incarcerous mobilicorpus. Langlock
+ banishing unctuous expelliarmus. Avis locomotor immobilus leviosa finite
+ serpensortia imperio.
+
+
+ Tergeo mobilicorpus mortis nox tarantallegra mobilicorpus felicis
+ locomotor unction. Sonorus evanesco riddikulus lumos sonorus curse.
+ Mobilicorpus mortis leviosa lumos dissendium funnunculus. Imperio
+ reducio cruciatus portus evanesco imperio crucio inflamarae. Rictusempra
+ immobilus incarcerous ennervate muffliato evanesco. Engorgio locomotor
+ stupefy mobilicorpus. Locomotor homorphus leviosa accio incantartem
+ totalus sonorus sectumsempra lumos protego aparecium. Impedimenta
+ incarcerous petrificus patronum exume impedimenta accio immobilus
+ aparecium tarantallegra vipera. Arania arania quietus patronum
+ funnunculus. Wingardium leviosa felicis nox tarantallegra expecto
+ quietus jelly-legs. Mortis ennervate patronum serpensortia expecto
+ mobilicorpus waddiwasi. Legilimens legilimens protego inflamarae
+ specialis leviosa portus diffindo tarantallegra immobilus. Impedimenta
+ momentum me jelly-legs. Sonorus aresto densaugeo confundus immobilus
+ accio quodpot evanesco imperio totalus patronum. Reducto leviosa nox
+ portus funnunculus confundus cruciatus. Incarcerous portus sonorus
+ babbling impedimenta. Finite evanesco wingardium kedavra momentum
+ bulbadox lumos evanesco cushioning arania. Locomotor unction sonorus
+ wingardium expelliarumus dissendium aresto. Legilimens sonorus
+ imperturbable mobilicorpus lumos incarcerous mobilicorpus. Langlock
+ banishing unctuous expelliarmus. Avis locomotor immobilus leviosa finite
+ serpensortia imperio.
+
+ Anchor
+
+ )
+}
+
+export default PageTwo
diff --git a/e2e-tests/trailing-slash/src/templates/template.js b/e2e-tests/trailing-slash/src/templates/template.js
new file mode 100644
index 0000000000000..d56afb3a40c4e
--- /dev/null
+++ b/e2e-tests/trailing-slash/src/templates/template.js
@@ -0,0 +1,18 @@
+import * as React from "react"
+import { Link } from "gatsby"
+
+const Template = ({ pageContext }) => {
+ return (
+
+ {pageContext.title}
+
+ Go Back
+
+
+ {JSON.stringify(pageContext, null, 2)}
+
+
+ )
+}
+
+export default Template
diff --git a/e2e-tests/trailing-slash/static/nested/index.html b/e2e-tests/trailing-slash/static/nested/index.html
new file mode 100644
index 0000000000000..e69de29bb2d1d
diff --git a/e2e-tests/trailing-slash/static/something.html b/e2e-tests/trailing-slash/static/something.html
new file mode 100644
index 0000000000000..e69de29bb2d1d
diff --git a/integration-tests/structured-logging/__tests__/to-do.js b/integration-tests/structured-logging/__tests__/to-do.js
index d7277b807709a..5de5df637e747 100644
--- a/integration-tests/structured-logging/__tests__/to-do.js
+++ b/integration-tests/structured-logging/__tests__/to-do.js
@@ -136,6 +136,12 @@ const commonAssertions = events => {
timestamp: joi.string().required(),
}),
+ joi.object({
+ type: joi.string().required().valid(`GATSBY_CONFIG_KEYS`),
+ payload: joi.object().required(),
+ timestamp: joi.string().required(),
+ }),
+
joi.object({
type: joi.string().required().valid(`RENDER_PAGE_TREE`),
payload: joi.object(),
diff --git a/jest.config.js b/jest.config.js
index 7638a3b00a513..7c3936da273c5 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -46,6 +46,7 @@ module.exports = {
"^weak-lru-cache$": `/node_modules/weak-lru-cache/dist/index.cjs`,
"^ordered-binary$": `/node_modules/ordered-binary/dist/index.cjs`,
"^msgpackr$": `/node_modules/msgpackr/dist/node.cjs`,
+ "^gatsby-page-utils/(.*)$": `gatsby-page-utils/dist/$1`, // Workaround for https://github.com/facebook/jest/issues/9771
},
snapshotSerializers: [`jest-serializer-path`],
collectCoverageFrom: coverageDirs,
diff --git a/packages/gatsby-link/package.json b/packages/gatsby-link/package.json
index 40d943ade96a7..1019ee395b8e1 100644
--- a/packages/gatsby-link/package.json
+++ b/packages/gatsby-link/package.json
@@ -9,6 +9,7 @@
"dependencies": {
"@babel/runtime": "^7.15.4",
"@types/reach__router": "^1.3.9",
+ "gatsby-page-utils": "^2.7.0-next.0",
"prop-types": "^15.7.2"
},
"devDependencies": {
diff --git a/packages/gatsby-link/src/__tests__/is-local-link.js b/packages/gatsby-link/src/__tests__/is-local-link.js
new file mode 100644
index 0000000000000..79a52ae5fa53c
--- /dev/null
+++ b/packages/gatsby-link/src/__tests__/is-local-link.js
@@ -0,0 +1,25 @@
+import { isLocalLink } from "../is-local-link"
+
+describe(`isLocalLink`, () => {
+ it(`returns true on relative link`, () => {
+ expect(isLocalLink(`/docs/some-doc`)).toBe(true)
+ })
+ it(`returns false on absolute link`, () => {
+ expect(isLocalLink(`https://www.gatsbyjs.com`)).toBe(false)
+ })
+ it(`returns undefined if input is undefined or not a string`, () => {
+ expect(isLocalLink(undefined)).toBeUndefined()
+ expect(isLocalLink(-1)).toBeUndefined()
+ })
+ // TODO(v5): Unskip Tests
+ it.skip(`throws TypeError if input is undefined`, () => {
+ expect(() => isLocalLink(undefined)).toThrowError(
+ `Expected a \`string\`, got \`undefined\``
+ )
+ })
+ it.skip(`throws TypeError if input is not a string`, () => {
+ expect(() => isLocalLink(-1)).toThrowError(
+ `Expected a \`string\`, got \`number\``
+ )
+ })
+})
diff --git a/packages/gatsby-link/src/__tests__/rewrite-link-path.js b/packages/gatsby-link/src/__tests__/rewrite-link-path.js
new file mode 100644
index 0000000000000..213b6ab2fbf7d
--- /dev/null
+++ b/packages/gatsby-link/src/__tests__/rewrite-link-path.js
@@ -0,0 +1,57 @@
+import { rewriteLinkPath } from "../rewrite-link-path"
+
+beforeEach(() => {
+ global.__TRAILING_SLASH__ = ``
+})
+
+const getRewriteLinkPath = (option = `legacy`) => {
+ global.__TRAILING_SLASH__ = option
+ return rewriteLinkPath
+}
+
+describe(`rewriteLinkPath`, () => {
+ it(`handles legacy option`, () => {
+ expect(getRewriteLinkPath()(`/path`, `/`)).toBe(`/path`)
+ expect(getRewriteLinkPath()(`/path/`, `/`)).toBe(`/path/`)
+ expect(getRewriteLinkPath()(`/path#hash`, `/`)).toBe(`/path#hash`)
+ expect(getRewriteLinkPath()(`/path?query_param=hello`, `/`)).toBe(
+ `/path?query_param=hello`
+ )
+ expect(getRewriteLinkPath()(`/path?query_param=hello#anchor`, `/`)).toBe(
+ `/path?query_param=hello#anchor`
+ )
+ })
+ it(`handles always option`, () => {
+ expect(getRewriteLinkPath(`always`)(`/path`, `/`)).toBe(`/path/`)
+ expect(getRewriteLinkPath(`always`)(`/path/`, `/`)).toBe(`/path/`)
+ expect(getRewriteLinkPath(`always`)(`/path#hash`, `/`)).toBe(`/path/#hash`)
+ expect(getRewriteLinkPath(`always`)(`/path?query_param=hello`, `/`)).toBe(
+ `/path/?query_param=hello`
+ )
+ expect(
+ getRewriteLinkPath(`always`)(`/path?query_param=hello#anchor`, `/`)
+ ).toBe(`/path/?query_param=hello#anchor`)
+ })
+ it(`handles never option`, () => {
+ expect(getRewriteLinkPath(`never`)(`/path`, `/`)).toBe(`/path`)
+ expect(getRewriteLinkPath(`never`)(`/path/`, `/`)).toBe(`/path`)
+ expect(getRewriteLinkPath(`never`)(`/path/#hash`, `/`)).toBe(`/path#hash`)
+ expect(getRewriteLinkPath(`never`)(`/path/?query_param=hello`, `/`)).toBe(
+ `/path?query_param=hello`
+ )
+ expect(
+ getRewriteLinkPath(`never`)(`/path/?query_param=hello#anchor`, `/`)
+ ).toBe(`/path?query_param=hello#anchor`)
+ })
+ it(`handles ignore option`, () => {
+ expect(getRewriteLinkPath(`ignore`)(`/path`, `/`)).toBe(`/path`)
+ expect(getRewriteLinkPath(`ignore`)(`/path/`, `/`)).toBe(`/path/`)
+ expect(getRewriteLinkPath(`ignore`)(`/path#hash`, `/`)).toBe(`/path#hash`)
+ expect(getRewriteLinkPath(`ignore`)(`/path?query_param=hello`, `/`)).toBe(
+ `/path?query_param=hello`
+ )
+ expect(
+ getRewriteLinkPath(`ignore`)(`/path?query_param=hello#anchor`, `/`)
+ ).toBe(`/path?query_param=hello#anchor`)
+ })
+})
diff --git a/packages/gatsby-link/src/index.js b/packages/gatsby-link/src/index.js
index d80fa11c6a0ab..490289713aad2 100644
--- a/packages/gatsby-link/src/index.js
+++ b/packages/gatsby-link/src/index.js
@@ -1,14 +1,12 @@
import PropTypes from "prop-types"
import React from "react"
import { Link, Location } from "@gatsbyjs/reach-router"
-import { resolve } from "@gatsbyjs/reach-router/lib/utils"
-
import { parsePath } from "./parse-path"
+import { isLocalLink } from "./is-local-link"
+import { rewriteLinkPath } from "./rewrite-link-path"
export { parsePath }
-const isAbsolutePath = path => path?.startsWith(`/`)
-
export function withPrefix(path, prefix = getGlobalBasePrefix()) {
if (!isLocalLink(path)) {
return path
@@ -39,34 +37,10 @@ const getGlobalBasePrefix = () =>
: undefined
: __BASE_PATH__
-const isLocalLink = path =>
- path &&
- !path.startsWith(`http://`) &&
- !path.startsWith(`https://`) &&
- !path.startsWith(`//`)
-
export function withAssetPrefix(path) {
return withPrefix(path, getGlobalPathPrefix())
}
-function absolutify(path, current) {
- // If it's already absolute, return as-is
- if (isAbsolutePath(path)) {
- return path
- }
- return resolve(path, current)
-}
-
-const rewriteLinkPath = (path, relativeTo) => {
- if (typeof path === `number`) {
- return path
- }
- if (!isLocalLink(path)) {
- return path
- }
- return isAbsolutePath(path) ? withPrefix(path) : absolutify(path, relativeTo)
-}
-
const NavLinkPropTypes = {
activeClassName: PropTypes.string,
activeStyle: PropTypes.object,
diff --git a/packages/gatsby-link/src/is-local-link.js b/packages/gatsby-link/src/is-local-link.js
new file mode 100644
index 0000000000000..b97c7a1915f5f
--- /dev/null
+++ b/packages/gatsby-link/src/is-local-link.js
@@ -0,0 +1,13 @@
+// Copied from https://github.com/sindresorhus/is-absolute-url/blob/3ab19cc2e599a03ea691bcb8a4c09fa3ebb5da4f/index.js
+const ABSOLUTE_URL_REGEX = /^[a-zA-Z][a-zA-Z\d+\-.]*?:/
+const isAbsolute = path => ABSOLUTE_URL_REGEX.test(path)
+
+export const isLocalLink = path => {
+ if (typeof path !== `string`) {
+ return undefined
+ // TODO(v5): Re-Add TypeError
+ // throw new TypeError(`Expected a \`string\`, got \`${typeof path}\``)
+ }
+
+ return !isAbsolute(path)
+}
diff --git a/packages/gatsby-link/src/rewrite-link-path.js b/packages/gatsby-link/src/rewrite-link-path.js
new file mode 100644
index 0000000000000..7493867a82af6
--- /dev/null
+++ b/packages/gatsby-link/src/rewrite-link-path.js
@@ -0,0 +1,45 @@
+import { resolve } from "@gatsbyjs/reach-router/lib/utils"
+// Specific import to treeshake Node.js stuff
+import { applyTrailingSlashOption } from "gatsby-page-utils/apply-trailing-slash-option"
+import { parsePath } from "./parse-path"
+import { isLocalLink } from "./is-local-link"
+import { withPrefix } from "."
+
+const isAbsolutePath = path => path?.startsWith(`/`)
+
+const getGlobalTrailingSlash = () =>
+ process.env.NODE_ENV !== `production`
+ ? typeof __TRAILING_SLASH__ !== `undefined`
+ ? __TRAILING_SLASH__
+ : undefined
+ : __TRAILING_SLASH__
+
+function absolutify(path, current) {
+ // If it's already absolute, return as-is
+ if (isAbsolutePath(path)) {
+ return path
+ }
+ return resolve(path, current)
+}
+
+export const rewriteLinkPath = (path, relativeTo) => {
+ if (typeof path === `number`) {
+ return path
+ }
+ if (!isLocalLink(path)) {
+ return path
+ }
+
+ const { pathname, search, hash } = parsePath(path)
+ const option = getGlobalTrailingSlash()
+ let adjustedPath = path
+
+ if (option === `always` || option === `never`) {
+ const output = applyTrailingSlashOption(pathname, option)
+ adjustedPath = `${output}${search}${hash}`
+ }
+
+ return isAbsolutePath(adjustedPath)
+ ? withPrefix(adjustedPath)
+ : absolutify(adjustedPath, relativeTo)
+}
diff --git a/packages/gatsby-page-utils/package.json b/packages/gatsby-page-utils/package.json
index 17146d5f748ad..4995c7f04b77c 100644
--- a/packages/gatsby-page-utils/package.json
+++ b/packages/gatsby-page-utils/package.json
@@ -4,6 +4,10 @@
"description": "Gatsby library that helps creating pages",
"main": "dist/index.js",
"types": "dist/index.d.ts",
+ "exports": {
+ ".": "./dist/index.js",
+ "./*": "./dist/*.js"
+ },
"scripts": {
"build": "babel src --out-dir dist/ --ignore \"**/__tests__\" --extensions \".ts\"",
"typegen": "rimraf \"dist/**/*.d.ts\" && tsc --emitDeclarationOnly --declaration --declarationDir dist/",
diff --git a/packages/gatsby-page-utils/src/__tests__/__snapshots__/create-path.ts.snap b/packages/gatsby-page-utils/src/__tests__/__snapshots__/create-path.ts.snap
deleted file mode 100644
index c1c9962513708..0000000000000
--- a/packages/gatsby-page-utils/src/__tests__/__snapshots__/create-path.ts.snap
+++ /dev/null
@@ -1,9 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`create-path should create unix paths 1`] = `
-Array [
- "/b/c/de/",
- "/bee/",
- "/b/d/c/",
-]
-`;
diff --git a/packages/gatsby-page-utils/src/__tests__/apply-trailing-slash-option.ts b/packages/gatsby-page-utils/src/__tests__/apply-trailing-slash-option.ts
new file mode 100644
index 0000000000000..1ab370dede016
--- /dev/null
+++ b/packages/gatsby-page-utils/src/__tests__/apply-trailing-slash-option.ts
@@ -0,0 +1,50 @@
+import { applyTrailingSlashOption } from "../apply-trailing-slash-option"
+
+describe(`applyTrailingSlashOption`, () => {
+ const indexPage = `/`
+ const withoutSlash = `/nested/path`
+ const withSlash = `/nested/path/`
+
+ it(`returns / for root index page`, () => {
+ expect(applyTrailingSlashOption(indexPage)).toEqual(indexPage)
+ })
+ describe(`always`, () => {
+ it(`should add trailing slash`, () => {
+ expect(applyTrailingSlashOption(withoutSlash, `always`)).toEqual(
+ withSlash
+ )
+ })
+ it(`should leave existing slash`, () => {
+ expect(applyTrailingSlashOption(withSlash, `always`)).toEqual(withSlash)
+ })
+ })
+ describe(`never`, () => {
+ it(`should leave root index`, () => {
+ expect(applyTrailingSlashOption(indexPage, `never`)).toEqual(indexPage)
+ })
+ it(`should remove trailing slashes`, () => {
+ expect(applyTrailingSlashOption(withSlash, `never`)).toEqual(withoutSlash)
+ })
+ it(`should leave non-trailing paths`, () => {
+ expect(applyTrailingSlashOption(withoutSlash, `never`)).toEqual(
+ withoutSlash
+ )
+ })
+ })
+ describe(`ignore`, () => {
+ it(`should return input (trailing)`, () => {
+ expect(applyTrailingSlashOption(withSlash, `ignore`)).toEqual(withSlash)
+ })
+ it(`should return input (non-trailing)`, () => {
+ expect(applyTrailingSlashOption(withoutSlash, `ignore`)).toEqual(
+ withoutSlash
+ )
+ })
+ })
+ describe(`legacy`, () => {
+ it(`should do nothing`, () => {
+ expect(applyTrailingSlashOption(withSlash)).toEqual(withSlash)
+ expect(applyTrailingSlashOption(withoutSlash)).toEqual(withoutSlash)
+ })
+ })
+})
diff --git a/packages/gatsby-page-utils/src/__tests__/create-path.ts b/packages/gatsby-page-utils/src/__tests__/create-path.ts
index 7c9507f34ed08..bf7a415e14981 100644
--- a/packages/gatsby-page-utils/src/__tests__/create-path.ts
+++ b/packages/gatsby-page-utils/src/__tests__/create-path.ts
@@ -4,6 +4,26 @@ describe(`create-path`, () => {
it(`should create unix paths`, () => {
const paths = [`b/c/de`, `bee`, `b/d/c/`]
- expect(paths.map(p => createPath(p))).toMatchSnapshot()
+ expect(paths.map(p => createPath(p))).toMatchInlineSnapshot(`
+ Array [
+ "/b/c/de/",
+ "/bee/",
+ "/b/d/c/",
+ ]
+ `)
+ })
+ it(`should parse index`, () => {
+ expect(createPath(`foo/bar/index`)).toEqual(`/foo/bar/`)
+ })
+ it(`should have working trailingSlash option`, () => {
+ expect(createPath(`foo/bar/`, false)).toEqual(`/foo/bar`)
+ expect(createPath(`foo/bar/index`, false)).toEqual(`/foo/bar`)
+ })
+ it(`should support client-only routes`, () => {
+ expect(createPath(`foo/[name]`, false, true)).toEqual(`/foo/[name]`)
+ })
+ it(`should support client-only splat routes`, () => {
+ expect(createPath(`foo/[...name]`, false, true)).toEqual(`/foo/[...name]`)
+ expect(createPath(`foo/[...]`, false, true)).toEqual(`/foo/[...]`)
})
})
diff --git a/packages/gatsby-page-utils/src/apply-trailing-slash-option.ts b/packages/gatsby-page-utils/src/apply-trailing-slash-option.ts
new file mode 100644
index 0000000000000..11515d58ea6f4
--- /dev/null
+++ b/packages/gatsby-page-utils/src/apply-trailing-slash-option.ts
@@ -0,0 +1,21 @@
+// TODO(v5): Remove legacy setting and default to "always"
+export type TrailingSlash = "always" | "never" | "ignore" | "legacy"
+
+export const applyTrailingSlashOption = (
+ input: string,
+ option: TrailingSlash = `legacy`
+): string => {
+ const hasHtmlSuffix = input.endsWith(`.html`)
+ if (input === `/`) return input
+ if (hasHtmlSuffix) {
+ option = `never`
+ }
+ if (option === `always`) {
+ return input.endsWith(`/`) ? input : `${input}/`
+ }
+ if (option === `never`) {
+ return input.endsWith(`/`) ? input.slice(0, -1) : input
+ }
+
+ return input
+}
diff --git a/packages/gatsby-page-utils/src/create-path.ts b/packages/gatsby-page-utils/src/create-path.ts
index ff599f5ed1cf6..8de87935238bb 100644
--- a/packages/gatsby-page-utils/src/create-path.ts
+++ b/packages/gatsby-page-utils/src/create-path.ts
@@ -1,8 +1,18 @@
import { parse, posix } from "path"
-export function createPath(filePath: string): string {
- const { dir, name } = parse(filePath)
+export function createPath(
+ filePath: string,
+ // TODO(v5): Set this default to false
+ withTrailingSlash: boolean = true,
+ usePathBase: boolean = false
+): string {
+ const { dir, name, base } = parse(filePath)
+ // When a collection route also has client-only routes (e.g. {Product.name}/[...sku])
+ // The "name" would be .. and "ext" .sku -- that's why "base" needs to be used instead
+ // to get [...sku]. usePathBase is set to "true" in collection-route-builder and gatsbyPath
+ const parsedBase = base === `index` ? `` : base
const parsedName = name === `index` ? `` : name
+ const postfix = withTrailingSlash ? `/` : ``
- return posix.join(`/`, dir, parsedName, `/`)
+ return posix.join(`/`, dir, usePathBase ? parsedBase : parsedName, postfix)
}
diff --git a/packages/gatsby-page-utils/src/index.ts b/packages/gatsby-page-utils/src/index.ts
index 69654368acfd9..abb242f4fb421 100644
--- a/packages/gatsby-page-utils/src/index.ts
+++ b/packages/gatsby-page-utils/src/index.ts
@@ -2,3 +2,7 @@ export { validatePath } from "./validate-path"
export { createPath } from "./create-path"
export { ignorePath, IPathIgnoreOptions } from "./ignore-path"
export { watchDirectory } from "./watch-directory"
+export {
+ applyTrailingSlashOption,
+ TrailingSlash,
+} from "./apply-trailing-slash-option"
diff --git a/packages/gatsby-plugin-gatsby-cloud/src/create-site-config.js b/packages/gatsby-plugin-gatsby-cloud/src/create-site-config.js
index 879ed70e93be4..f4fa7c0eed9d4 100644
--- a/packages/gatsby-plugin-gatsby-cloud/src/create-site-config.js
+++ b/packages/gatsby-plugin-gatsby-cloud/src/create-site-config.js
@@ -1,6 +1,10 @@
import * as fs from "fs-extra"
import { SITE_CONFIG_FILENAME } from "./constants"
+/*
+ * @deprecated TODO(v5): Will be Remove in V5 since we're now sending config over IPC
+ * see https://github.com/gatsbyjs/gatsby/pull/34411
+ */
export default async function createSiteConfig(pluginData, _pluginOptions) {
const { publicFolder } = pluginData
const siteConfig = {
diff --git a/packages/gatsby-plugin-page-creator/src/__tests__/derive-path.ts b/packages/gatsby-plugin-page-creator/src/__tests__/derive-path.ts
index 03127d9d0a5f8..aa1ec85d1029e 100644
--- a/packages/gatsby-plugin-page-creator/src/__tests__/derive-path.ts
+++ b/packages/gatsby-plugin-page-creator/src/__tests__/derive-path.ts
@@ -192,10 +192,9 @@ describe(`derive-path`, () => {
})
it(`keeps existing slashes around and handles possible double forward slashes`, () => {
- // This tests three things
- // 1) The trailing slash should be removed (as createPath will be used later anyways)
- // 2) There shouldn't be a double forward slash in the final URL => blog//fire-and-powder/
- // 3) If the slug is supposed to be a URL (e.g. foo/bar) it should keep that
+ // This tests two things
+ // 1) There shouldn't be a double forward slash in the final URL => blog//fire-and-powder/
+ // 2) If the slug is supposed to be a URL (e.g. foo/bar) it should keep that
expect(
derivePath(
`blog/{MarkdownRemark.fields__slug}`,
@@ -206,7 +205,7 @@ describe(`derive-path`, () => {
},
reporter
).derivedPath
- ).toEqual(`blog/fire-and-powder`)
+ ).toEqual(`blog/fire-and-powder/`)
expect(
derivePath(
`blog/{MarkdownRemark.fields__slug}`,
@@ -229,7 +228,7 @@ describe(`derive-path`, () => {
},
reporter
).derivedPath
- ).toEqual(`foo/dolores.js`)
+ ).toEqual(`foo/dolores`)
})
it(`supports file extension with existing slashes around`, () => {
@@ -241,7 +240,7 @@ describe(`derive-path`, () => {
},
reporter
).derivedPath
- ).toEqual(`foo/dolores.js`)
+ ).toEqual(`foo/dolores/`)
expect(
derivePath(
`foo/{Model.name}/template.js`,
@@ -250,7 +249,7 @@ describe(`derive-path`, () => {
},
reporter
).derivedPath
- ).toEqual(`foo/dolores/template.js`)
+ ).toEqual(`foo/dolores/template`)
})
it(`supports mixed collection and client-only route`, () => {
@@ -263,6 +262,24 @@ describe(`derive-path`, () => {
reporter
).derivedPath
).toEqual(`foo/dolores/[...name]`)
+ expect(
+ derivePath(
+ `{Model.name}/[...name]`,
+ {
+ name: `dolores`,
+ },
+ reporter
+ ).derivedPath
+ ).toEqual(`dolores/[...name]`)
+ expect(
+ derivePath(
+ `{Model.name}/[name]`,
+ {
+ name: `dolores`,
+ },
+ reporter
+ ).derivedPath
+ ).toEqual(`dolores/[name]`)
})
it(`supports index paths`, () => {
@@ -283,7 +300,7 @@ describe(`derive-path`, () => {
},
reporter
).derivedPath
- ).toEqual(`index.js`)
+ ).toEqual(`index`)
expect(
derivePath(
`foo/{Page.path}`,
@@ -292,7 +309,7 @@ describe(`derive-path`, () => {
},
reporter
).derivedPath
- ).toEqual(`foo`)
+ ).toEqual(`foo/`)
expect(
derivePath(
`foo/{Page.path}/bar`,
@@ -302,6 +319,15 @@ describe(`derive-path`, () => {
reporter
).derivedPath
).toEqual(`foo/bar`)
+ expect(
+ derivePath(
+ `foo/{Page.path}/bar/`,
+ {
+ path: `/`,
+ },
+ reporter
+ ).derivedPath
+ ).toEqual(`foo/bar/`)
expect(
derivePath(
`foo/{Page.pathOne}/{Page.pathTwo}`,
@@ -322,6 +348,16 @@ describe(`derive-path`, () => {
reporter
).derivedPath
).toEqual(`foo/bar`)
+ expect(
+ derivePath(
+ `foo/{Page.pathOne}/{Page.pathTwo}`,
+ {
+ pathOne: `/`,
+ pathTwo: `/bar/`,
+ },
+ reporter
+ ).derivedPath
+ ).toEqual(`foo/bar/`)
expect(
derivePath(
`foo/{Page.path}/[...name]`,
diff --git a/packages/gatsby-plugin-page-creator/src/create-client-only-page.ts b/packages/gatsby-plugin-page-creator/src/create-client-only-page.ts
index 5bb353c723c01..6e40f8eb8370f 100644
--- a/packages/gatsby-plugin-page-creator/src/create-client-only-page.ts
+++ b/packages/gatsby-plugin-page-creator/src/create-client-only-page.ts
@@ -1,5 +1,9 @@
import { Actions } from "gatsby"
-import { createPath } from "gatsby-page-utils"
+import {
+ createPath,
+ applyTrailingSlashOption,
+ TrailingSlash,
+} from "gatsby-page-utils"
import { getMatchPath } from "gatsby-core-utils"
// Create a client side page with a matchPath
@@ -9,12 +13,14 @@ import { getMatchPath } from "gatsby-core-utils"
export function createClientOnlyPage(
filePath: string,
absolutePath: string,
- actions: Actions
+ actions: Actions,
+ trailingSlash: TrailingSlash
): void {
const path = createPath(filePath)
+ const modifiedPath = applyTrailingSlashOption(path, trailingSlash)
actions.createPage({
- path,
+ path: modifiedPath,
matchPath: getMatchPath(path),
component: absolutePath,
context: {},
diff --git a/packages/gatsby-plugin-page-creator/src/create-page-wrapper.ts b/packages/gatsby-plugin-page-creator/src/create-page-wrapper.ts
index d5ec631fdb644..12d9ec0cb5e89 100644
--- a/packages/gatsby-plugin-page-creator/src/create-page-wrapper.ts
+++ b/packages/gatsby-plugin-page-creator/src/create-page-wrapper.ts
@@ -4,6 +4,7 @@ import {
validatePath,
ignorePath,
IPathIgnoreOptions,
+ applyTrailingSlashOption,
} from "gatsby-page-utils"
import { Options as ISlugifyOptions } from "@sindresorhus/slugify"
import { createClientOnlyPage } from "./create-client-only-page"
@@ -11,6 +12,7 @@ import { createPagesFromCollectionBuilder } from "./create-pages-from-collection
import systemPath from "path"
import { trackFeatureIsUsed } from "gatsby-telemetry"
import { Reporter } from "gatsby/reporter"
+import type { TrailingSlash } from "gatsby-page-utils"
function pathIsCollectionBuilder(path: string): boolean {
return path.includes(`{`)
@@ -26,6 +28,7 @@ export function createPage(
actions: Actions,
graphql: CreatePagesArgs["graphql"],
reporter: Reporter,
+ trailingSlash: TrailingSlash,
ignore?: IPathIgnoreOptions | string | Array | null,
slugifyOptions?: ISlugifyOptions
): void {
@@ -51,6 +54,7 @@ export function createPage(
actions,
graphql,
reporter,
+ trailingSlash,
slugifyOptions
)
return
@@ -59,14 +63,15 @@ export function createPage(
// If the path includes a `[]` in it, then we create it as a client only route
if (pathIsClientOnlyRoute(absolutePath)) {
trackFeatureIsUsed(`UnifiedRoutes:client-page-builder`)
- createClientOnlyPage(filePath, absolutePath, actions)
+ createClientOnlyPage(filePath, absolutePath, actions, trailingSlash)
return
}
// Create page object
const createdPath = createPath(filePath)
+ const modifiedPath = applyTrailingSlashOption(createdPath, trailingSlash)
const page = {
- path: createdPath,
+ path: modifiedPath,
component: absolutePath,
context: {},
}
diff --git a/packages/gatsby-plugin-page-creator/src/create-pages-from-collection-builder.ts b/packages/gatsby-plugin-page-creator/src/create-pages-from-collection-builder.ts
index ab4cf59461f5a..21ef8ad26ef3e 100644
--- a/packages/gatsby-plugin-page-creator/src/create-pages-from-collection-builder.ts
+++ b/packages/gatsby-plugin-page-creator/src/create-pages-from-collection-builder.ts
@@ -3,6 +3,7 @@ import { Actions, CreatePagesArgs } from "gatsby"
import { createPath } from "gatsby-page-utils"
import { Reporter } from "gatsby/reporter"
import { Options as ISlugifyOptions } from "@sindresorhus/slugify"
+import { TrailingSlash, applyTrailingSlashOption } from "gatsby-page-utils"
import { reverseLookupParams } from "./extract-query"
import { getMatchPath } from "gatsby-core-utils"
import { getCollectionRouteParams } from "./get-collection-route-params"
@@ -18,6 +19,7 @@ export async function createPagesFromCollectionBuilder(
actions: Actions,
graphql: CreatePagesArgs["graphql"],
reporter: Reporter,
+ trailingSlash: TrailingSlash,
slugifyOptions?: ISlugifyOptions
): Promise {
if (isValidCollectionPathImplementation(absolutePath, reporter) === false) {
@@ -28,6 +30,7 @@ export async function createPagesFromCollectionBuilder(
actions,
graphql,
reporter,
+ trailingSlash,
slugifyOptions
)
)
@@ -46,6 +49,7 @@ export async function createPagesFromCollectionBuilder(
actions,
graphql,
reporter,
+ trailingSlash,
slugifyOptions
)
)
@@ -82,6 +86,7 @@ ${errors.map(error => error.message).join(`\n`)}`.trim(),
actions,
graphql,
reporter,
+ trailingSlash,
slugifyOptions
)
)
@@ -118,7 +123,10 @@ ${errors.map(error => error.message).join(`\n`)}`.trim(),
reporter,
slugifyOptions
)
- const path = createPath(derivedPath)
+ // TODO(v5): Remove legacy handling
+ const isLegacy = trailingSlash === `legacy`
+ const hasTrailingSlash = derivedPath.endsWith(`/`)
+ const path = createPath(derivedPath, isLegacy || hasTrailingSlash, true)
// We've already created a page with this path
if (knownPagePaths.has(path)) {
return
@@ -131,8 +139,10 @@ ${errors.map(error => error.message).join(`\n`)}`.trim(),
// matchPath is an optional value. It's used if someone does a path like `{foo}/[bar].js`
const matchPath = getMatchPath(path)
+ const modifiedPath = applyTrailingSlashOption(path, trailingSlash)
+
actions.createPage({
- path: path,
+ path: modifiedPath,
matchPath,
component: absolutePath,
context: {
@@ -168,6 +178,7 @@ ${errors.map(error => error.message).join(`\n`)}`.trim(),
actions,
graphql,
reporter,
+ trailingSlash,
slugifyOptions
)
)
diff --git a/packages/gatsby-plugin-page-creator/src/derive-path.ts b/packages/gatsby-plugin-page-creator/src/derive-path.ts
index 5193358599a32..4f7244b174e94 100644
--- a/packages/gatsby-plugin-page-creator/src/derive-path.ts
+++ b/packages/gatsby-plugin-page-creator/src/derive-path.ts
@@ -5,7 +5,6 @@ import {
extractFieldWithoutUnion,
extractAllCollectionSegments,
switchToPeriodDelimiters,
- stripTrailingSlash,
removeFileExtension,
} from "./path-utils"
@@ -26,8 +25,8 @@ export function derivePath(
// 0. Since this function will be called for every path times count of nodes the errors will be counted and then the calling function will throw the error once
let errors = 0
- // 1. Incoming path can optionally be stripped of file extension (but not mandatory)
- let modifiedPath = path
+ // 1. Incoming path can optionally contain file extension
+ let modifiedPath = removeFileExtension(path)
// 2. Pull out the slug parts that are within { } brackets.
const slugParts = extractAllCollectionSegments(path)
@@ -57,8 +56,8 @@ export function derivePath(
return
}
- // 3.d Safely slugify all values (to keep URL structures) and remove any trailing slash
- const value = stripTrailingSlash(safeSlugify(nodeValue, slugifyOptions))
+ // 3.d Safely slugify all values (to keep URL structures)
+ const value = safeSlugify(nodeValue, slugifyOptions)
// 3.e replace the part of the slug with the actual value
modifiedPath = modifiedPath.replace(slugPart, value)
@@ -67,12 +66,9 @@ export function derivePath(
// 4. Remove double forward slashes that could occur in the final URL
modifiedPath = modifiedPath.replace(doubleForwardSlashes, `/`)
- // 5. Remove trailing slashes that could occur in the final URL
- modifiedPath = stripTrailingSlash(modifiedPath)
-
- // 6. If the final URL appears to be an index path, use the "index" file naming convention
- if (indexRoute.test(removeFileExtension(modifiedPath))) {
- modifiedPath = `index${modifiedPath}`
+ // 5.a If the final URL appears to be an index path, use the "index" file naming convention
+ if (indexRoute.test(modifiedPath)) {
+ modifiedPath = `index`
}
const derivedPath = modifiedPath
diff --git a/packages/gatsby-plugin-page-creator/src/gatsby-node.ts b/packages/gatsby-plugin-page-creator/src/gatsby-node.ts
index 0a50cebab8cf9..d732e1536d695 100644
--- a/packages/gatsby-plugin-page-creator/src/gatsby-node.ts
+++ b/packages/gatsby-plugin-page-creator/src/gatsby-node.ts
@@ -15,6 +15,7 @@ import {
createPath,
watchDirectory,
IPathIgnoreOptions,
+ applyTrailingSlashOption,
} from "gatsby-page-utils"
import { Options as ISlugifyOptions } from "@sindresorhus/slugify"
import { createPage } from "./create-page-wrapper"
@@ -69,7 +70,8 @@ export async function createPagesStatefully(
): Promise {
try {
const { deletePage } = actions
- const { program } = store.getState()
+ const { program, config } = store.getState()
+ const { trailingSlash = `legacy` } = config
const exts = program.extensions.map(e => `${e.slice(1)}`).join(`,`)
@@ -110,6 +112,7 @@ Please pick a path to an existing directory.`,
actions,
graphql,
reporter,
+ trailingSlash,
ignore,
slugifyOptions
)
@@ -129,6 +132,7 @@ Please pick a path to an existing directory.`,
actions,
graphql,
reporter,
+ trailingSlash,
ignore,
slugifyOptions
)
@@ -182,6 +186,7 @@ export function setFieldsOnGraphQLNodeType(
): Record {
try {
const extensions = store.getState().program.extensions
+ const { trailingSlash = `legacy` } = store.getState().config
const collectionQuery = _.camelCase(`all ${type.name}`)
if (knownCollections.has(collectionQuery)) {
return {
@@ -215,8 +220,17 @@ export function setFieldsOnGraphQLNodeType(
reporter,
slugifyOptions
)
+ // TODO(v5): Remove legacy handling
+ const isLegacy = trailingSlash === `legacy`
+ const hasTrailingSlash = derivedPath.endsWith(`/`)
+ const path = createPath(
+ derivedPath,
+ isLegacy || hasTrailingSlash,
+ true
+ )
+ const modifiedPath = applyTrailingSlashOption(path, trailingSlash)
- return createPath(derivedPath)
+ return modifiedPath
},
},
}
diff --git a/packages/gatsby-plugin-page-creator/src/get-collection-route-params.ts b/packages/gatsby-plugin-page-creator/src/get-collection-route-params.ts
index 486ecf5b0c299..e493c8e224f09 100644
--- a/packages/gatsby-plugin-page-creator/src/get-collection-route-params.ts
+++ b/packages/gatsby-plugin-page-creator/src/get-collection-route-params.ts
@@ -25,7 +25,7 @@ export function getCollectionRouteParams(
return
}
// Use the previously created regex to match prefix-123 to prefix-(.+)
- const match = urlParts[i].match(templateRegex[i])
+ const match = urlParts[i]?.match(templateRegex[i])
if (!match) {
return
diff --git a/packages/gatsby/cache-dir/.eslintrc.json b/packages/gatsby/cache-dir/.eslintrc.json
index 7396d0ec0c261..beddda247f05e 100644
--- a/packages/gatsby/cache-dir/.eslintrc.json
+++ b/packages/gatsby/cache-dir/.eslintrc.json
@@ -4,6 +4,7 @@
},
"globals": {
"__PATH_PREFIX__": false,
+ "__TRAILNG_SLASH__": false,
"___emitter": false
}
}
diff --git a/packages/gatsby/index.d.ts b/packages/gatsby/index.d.ts
index 0bb743ce9df9c..76b3a823a88e4 100644
--- a/packages/gatsby/index.d.ts
+++ b/packages/gatsby/index.d.ts
@@ -234,6 +234,8 @@ export interface GatsbyConfig {
flags?: Record
/** It’s common for sites to be hosted somewhere other than the root of their domain. Say we have a Gatsby site at `example.com/blog/`. In this case, we would need a prefix (`/blog`) added to all paths on the site. */
pathPrefix?: string
+ /** `never` removes all trailing slashes, `always` adds it, and `ignore` doesn't automatically change anything and it's in user hands to keep things consistent. By default `legacy` is used which is the behavior until v5. With Gatsby v5 "always" will be the default. */
+ trailingSlash?: "always" | "never" | "ignore" | "legacy"
/** In some circumstances you may want to deploy assets (non-HTML resources such as JavaScript, CSS, etc.) to a separate domain. `assetPrefix` allows you to use Gatsby with assets hosted from a separate domain */
assetPrefix?: string
/** Gatsby uses the ES6 Promise API. Because some browsers don't support this, Gatsby includes a Promise polyfill by default. If you'd like to provide your own Promise polyfill, you can set `polyfill` to false.*/
diff --git a/packages/gatsby/package.json b/packages/gatsby/package.json
index fc10de7298780..3a0a81578dec8 100644
--- a/packages/gatsby/package.json
+++ b/packages/gatsby/package.json
@@ -82,6 +82,7 @@
"gatsby-graphiql-explorer": "^2.7.0-next.0",
"gatsby-legacy-polyfills": "^2.7.0-next.0",
"gatsby-link": "^4.7.0-next.0",
+ "gatsby-page-utils": "^2.7.0-next.0",
"gatsby-plugin-page-creator": "^4.7.0-next.0",
"gatsby-plugin-typescript": "^4.7.0-next.0",
"gatsby-plugin-utils": "^3.1.0-next.0",
diff --git a/packages/gatsby/src/commands/build.ts b/packages/gatsby/src/commands/build.ts
index fb2772d039716..2ed486536da02 100644
--- a/packages/gatsby/src/commands/build.ts
+++ b/packages/gatsby/src/commands/build.ts
@@ -61,6 +61,7 @@ import {
preparePageTemplateConfigs,
} from "../utils/page-mode"
import { validateEngines } from "../utils/validate-engines"
+import { constructConfigObject } from "../utils/gatsby-cloud-config"
module.exports = async function build(
program: IBuildArgs,
@@ -467,6 +468,19 @@ module.exports = async function build(
root: state.program.directory,
})
+ if (process.send) {
+ const gatsbyCloudConfig = constructConfigObject(state.config)
+
+ process.send({
+ type: `LOG_ACTION`,
+ action: {
+ type: `GATSBY_CONFIG_KEYS`,
+ payload: gatsbyCloudConfig,
+ timestamp: new Date().toJSON(),
+ },
+ })
+ }
+
report.info(`Done building in ${process.uptime()} sec`)
buildActivity.end()
diff --git a/packages/gatsby/src/commands/serve.ts b/packages/gatsby/src/commands/serve.ts
index a7613b34ffbd5..efeb2d637f94c 100644
--- a/packages/gatsby/src/commands/serve.ts
+++ b/packages/gatsby/src/commands/serve.ts
@@ -16,9 +16,19 @@ import { getConfigFile } from "../bootstrap/get-config-file"
import { preferDefault } from "../bootstrap/prefer-default"
import { IProgram } from "./types"
import { IPreparedUrls, prepareUrls } from "../utils/prepare-urls"
-import { IGatsbyFunction } from "../redux/types"
+import {
+ IGatsbyConfig,
+ IGatsbyFunction,
+ IGatsbyPage,
+ IGatsbyState,
+} from "../redux/types"
import { reverseFixedPagePath } from "../utils/page-data"
import { initTracer } from "../utils/tracer"
+import { configureTrailingSlash } from "../utils/express-middlewares"
+import { getDataStore, detectLmdbStore } from "../datastore"
+
+process.env.GATSBY_EXPERIMENTAL_LMDB_STORE = `1`
+detectLmdbStore()
interface IMatchPath {
path: string
@@ -101,9 +111,9 @@ module.exports = async (program: IServeProgram): Promise => {
program.directory,
`gatsby-config`
)
- const config = preferDefault(configModule)
+ const config: IGatsbyConfig = preferDefault(configModule)
- const { pathPrefix: configPathPrefix } = config || {}
+ const { pathPrefix: configPathPrefix, trailingSlash } = config || {}
const pathPrefix = prefixPaths && configPathPrefix ? configPathPrefix : `/`
@@ -116,6 +126,28 @@ module.exports = async (program: IServeProgram): Promise => {
app.use(telemetry.expressMiddleware(`SERVE`))
router.use(compression())
+
+ router.use(
+ configureTrailingSlash(
+ () =>
+ ({
+ pages: {
+ get(pathName: string): IGatsbyPage | undefined {
+ return getDataStore().getNode(`SitePage ${pathName}`) as
+ | IGatsbyPage
+ | undefined
+ },
+ values(): Iterable {
+ return getDataStore().iterateNodesByType(
+ `SitePage`
+ ) as Iterable
+ },
+ },
+ } as unknown as IGatsbyState),
+ trailingSlash
+ )
+ )
+
router.use(express.static(`public`, { dotfiles: `allow` }))
const compiledFunctionsDir = path.join(
diff --git a/packages/gatsby/src/internal-plugins/dev-404-page/raw_dev-404-page.js b/packages/gatsby/src/internal-plugins/dev-404-page/raw_dev-404-page.js
index 3add076ea0691..5cdf32e97030b 100644
--- a/packages/gatsby/src/internal-plugins/dev-404-page/raw_dev-404-page.js
+++ b/packages/gatsby/src/internal-plugins/dev-404-page/raw_dev-404-page.js
@@ -243,6 +243,8 @@ export default function API (req, res) {
export default Dev404Page
+// ESLint is complaining about the backslash in regex
+/* eslint-disable */
export const pagesQuery = graphql`
query PagesQuery {
allSiteFunction {
@@ -250,10 +252,11 @@ export const pagesQuery = graphql`
functionRoute
}
}
- allSitePage(filter: { path: { ne: "/dev-404-page/" } }) {
+ allSitePage(filter: { path: { regex: "/^(?!\/dev-404-page).+$/" } }) {
nodes {
path
}
}
}
`
+/* eslint-enable */
diff --git a/packages/gatsby/src/joi-schemas/__tests__/joi.ts b/packages/gatsby/src/joi-schemas/__tests__/joi.ts
index 0cf825d2da485..5b07f48657f8e 100644
--- a/packages/gatsby/src/joi-schemas/__tests__/joi.ts
+++ b/packages/gatsby/src/joi-schemas/__tests__/joi.ts
@@ -107,6 +107,39 @@ describe(`gatsby config`, () => {
`[Error: assetPrefix must be an absolute URI when used with pathPrefix]`
)
})
+
+ it(`returns "legacy" for trailingSlash when not set`, () => {
+ const config = {}
+
+ const result = gatsbyConfigSchema.validate(config)
+ expect(result.value).toEqual(
+ expect.objectContaining({
+ trailingSlash: `legacy`,
+ })
+ )
+ })
+
+ it(`throws when trailingSlash is not valid string`, () => {
+ const config = {
+ trailingSlash: `arrakis`,
+ }
+
+ const result = gatsbyConfigSchema.validate(config)
+ expect(result.error).toMatchInlineSnapshot(
+ `[ValidationError: "trailingSlash" must be one of [always, never, ignore, legacy]]`
+ )
+ })
+
+ it(`throws when trailingSlash is not a string`, () => {
+ const config = {
+ trailingSlash: true,
+ }
+
+ const result = gatsbyConfigSchema.validate(config)
+ expect(result.error).toMatchInlineSnapshot(
+ `[ValidationError: "trailingSlash" must be one of [always, never, ignore, legacy]]`
+ )
+ })
})
describe(`node schema`, () => {
diff --git a/packages/gatsby/src/joi-schemas/joi.ts b/packages/gatsby/src/joi-schemas/joi.ts
index 9ad06f2621dd2..c652a13ccc9ab 100644
--- a/packages/gatsby/src/joi-schemas/joi.ts
+++ b/packages/gatsby/src/joi-schemas/joi.ts
@@ -51,6 +51,9 @@ export const gatsbyConfigSchema: Joi.ObjectSchema = Joi.object()
developMiddleware: Joi.func(),
jsxRuntime: Joi.string().valid(`automatic`, `classic`).default(`classic`),
jsxImportSource: Joi.string(),
+ trailingSlash: Joi.string()
+ .valid(`always`, `never`, `ignore`, `legacy`) // TODO(v5): Remove legacy
+ .default(`legacy`),
})
// throws when both assetPrefix and pathPrefix are defined
.when(
diff --git a/packages/gatsby/src/redux/actions/__tests__/internal.ts b/packages/gatsby/src/redux/actions/__tests__/internal.ts
index ecb14d8b1fa08..29d73c315cc3d 100644
--- a/packages/gatsby/src/redux/actions/__tests__/internal.ts
+++ b/packages/gatsby/src/redux/actions/__tests__/internal.ts
@@ -27,6 +27,7 @@ describe(`setSiteConfig`, () => {
"siteMetadata": Object {
"hi": true,
},
+ "trailingSlash": "legacy",
},
"type": "SET_SITE_CONFIG",
}
@@ -41,6 +42,7 @@ describe(`setSiteConfig`, () => {
"jsxRuntime": "classic",
"pathPrefix": "",
"polyfill": true,
+ "trailingSlash": "legacy",
},
"type": "SET_SITE_CONFIG",
}
diff --git a/packages/gatsby/src/redux/actions/public.js b/packages/gatsby/src/redux/actions/public.js
index 6053170f87974..ff15c000bae51 100644
--- a/packages/gatsby/src/redux/actions/public.js
+++ b/packages/gatsby/src/redux/actions/public.js
@@ -20,6 +20,7 @@ const {
truncatePath,
tooLongSegmentsInPath,
} = require(`../../utils/path`)
+const { applyTrailingSlashOption } = require(`gatsby-page-utils`)
const apiRunnerNode = require(`../../utils/api-runner-node`)
const { trackCli } = require(`gatsby-telemetry`)
const { getNonGatsbyCodeFrame } = require(`../../utils/stack-trace-utils`)
@@ -275,6 +276,7 @@ ${reservedFields.map(f => ` * "${f}"`).join(`\n`)}
page.component = pageComponentPath
}
+ const { trailingSlash } = store.getState().config
const rootPath = store.getState().program.directory
const { error, message, panicOnBuild } = validatePageComponent(
page,
@@ -386,6 +388,8 @@ ${reservedFields.map(f => ` * "${f}"`).join(`\n`)}
page.path = truncatedPath
}
+ page.path = applyTrailingSlashOption(page.path, trailingSlash)
+
const internalPage: Page = {
internalComponentName,
path: page.path,
diff --git a/packages/gatsby/src/redux/types.ts b/packages/gatsby/src/redux/types.ts
index 96672503adaba..9cc18293020aa 100644
--- a/packages/gatsby/src/redux/types.ts
+++ b/packages/gatsby/src/redux/types.ts
@@ -1,3 +1,4 @@
+import type { TrailingSlash } from "gatsby-page-utils"
import { IProgram } from "../commands/types"
import { GraphQLFieldExtensionDefinition } from "../schema/extensions"
import { DocumentNode, GraphQLSchema, DefinitionNode } from "graphql"
@@ -97,6 +98,7 @@ export interface IGatsbyConfig {
mapping?: Record
jsxRuntime?: "classic" | "automatic"
jsxImportSource?: string
+ trailingSlash?: TrailingSlash
}
export interface IGatsbyNode {
diff --git a/packages/gatsby/src/services/initialize.ts b/packages/gatsby/src/services/initialize.ts
index 5c1234759c3bf..c1c416f910a22 100644
--- a/packages/gatsby/src/services/initialize.ts
+++ b/packages/gatsby/src/services/initialize.ts
@@ -310,16 +310,21 @@ export async function initialize({
// The last, gatsby-node.js, is important as many gatsby sites put important
// logic in there e.g. generating slugs for custom pages.
const pluginVersions = flattenedPlugins.map(p => p.version)
+
+ const state = store.getState()
+
const hashes: any = await Promise.all([
md5File(`package.json`),
md5File(`${program.directory}/gatsby-config.js`).catch(() => {}), // ignore as this file isn't required),
md5File(`${program.directory}/gatsby-node.js`).catch(() => {}), // ignore as this file isn't required),
+ { trailingSlash: state.config.trailingSlash },
])
+
const pluginsHash = crypto
.createHash(`md5`)
.update(JSON.stringify(pluginVersions.concat(hashes)))
.digest(`hex`)
- const state = store.getState()
+
const oldPluginsHash = state && state.status ? state.status.PLUGINS_HASH : ``
// Check if anything has changed. If it has, delete the site's .cache
@@ -604,6 +609,9 @@ export async function initialize({
})
activity.end()
+ // Track trailing slash option used in config
+ telemetry.trackFeatureIsUsed(`trailingSlash:${state.config.trailingSlash}`)
+
// Collect resolvable extensions and attach to program.
const extensions = [`.mjs`, `.js`, `.jsx`, `.wasm`, `.json`]
// Change to this being an action and plugins implement `onPreBootstrap`
diff --git a/packages/gatsby/src/utils/__tests__/gatsby-cloud-config.ts b/packages/gatsby/src/utils/__tests__/gatsby-cloud-config.ts
new file mode 100644
index 0000000000000..53621e57fa691
--- /dev/null
+++ b/packages/gatsby/src/utils/__tests__/gatsby-cloud-config.ts
@@ -0,0 +1,26 @@
+import { constructConfigObject } from "../gatsby-cloud-config"
+
+describe(`constructConfigObject`, () => {
+ it(`should return defaults with empty config`, () => {
+ expect(constructConfigObject({})).toEqual({
+ trailingSlash: `legacy`,
+ pathPrefix: ``,
+ })
+ })
+ it(`should pass defined keys to output`, () => {
+ expect(
+ constructConfigObject({
+ trailingSlash: `always`,
+ pathPrefix: `/blog`,
+ assetPrefix: `https://cdn.example.com`,
+ jsxRuntime: `automatic`,
+ plugins: [],
+ siteMetadata: { title: `ACME` },
+ })
+ ).toEqual({
+ trailingSlash: `always`,
+ pathPrefix: `/blog`,
+ assetPrefix: `https://cdn.example.com`,
+ })
+ })
+})
diff --git a/packages/gatsby/src/utils/did-you-mean.ts b/packages/gatsby/src/utils/did-you-mean.ts
index 89e430dce097e..08e5484510c84 100644
--- a/packages/gatsby/src/utils/did-you-mean.ts
+++ b/packages/gatsby/src/utils/did-you-mean.ts
@@ -12,6 +12,7 @@ export const KNOWN_CONFIG_KEYS = [
`developMiddleware`,
`jsxRuntime`,
`jsxImportSource`,
+ `trailingSlash`,
]
export function didYouMean(
diff --git a/packages/gatsby/src/utils/eslint-config.ts b/packages/gatsby/src/utils/eslint-config.ts
index f7be652c969eb..e1fb6f489924e 100644
--- a/packages/gatsby/src/utils/eslint-config.ts
+++ b/packages/gatsby/src/utils/eslint-config.ts
@@ -29,6 +29,7 @@ export const eslintRequiredConfig: ESLint.Options = {
globals: {
graphql: true,
__PATH_PREFIX__: true,
+ __TRAILING_SLASH__: true,
__BASE_PATH__: true, // this will rarely, if ever, be used by consumers
},
extends: [eslintRequirePreset],
@@ -47,6 +48,7 @@ export const eslintConfig = (
globals: {
graphql: true,
__PATH_PREFIX__: true,
+ __TRAILING_SLASH__: true,
__BASE_PATH__: true, // this will rarely, if ever, be used by consumers
},
extends: [
diff --git a/packages/gatsby/src/utils/express-middlewares.ts b/packages/gatsby/src/utils/express-middlewares.ts
new file mode 100644
index 0000000000000..dc44a64a97539
--- /dev/null
+++ b/packages/gatsby/src/utils/express-middlewares.ts
@@ -0,0 +1,64 @@
+import type { TrailingSlash } from "gatsby-page-utils"
+import express from "express"
+import type { IGatsbyState } from "../redux/types"
+import { findPageByPath } from "./find-page-by-path"
+
+export const configureTrailingSlash =
+ (getState: () => IGatsbyState, option: TrailingSlash | undefined) =>
+ (
+ req: express.Request,
+ res: express.Response,
+ next: express.NextFunction
+ ): void => {
+ const method = req.method.toLocaleLowerCase()
+ if (![`get`, `head`].includes(method)) {
+ next()
+ return
+ }
+
+ if (req?.path.split(`/`)?.pop()?.includes(`.`)) {
+ // Path has an extension. Do not add slash.
+ next()
+ return
+ }
+
+ if (req.path.length <= 1) {
+ next()
+ return
+ }
+
+ // check if it's Gatsby Page
+ const page = findPageByPath(getState(), req.path)
+
+ if (page) {
+ if (option === `never`) {
+ if (req.path.slice(-1) === `/` && page.path !== req.path) {
+ // Remove trailing slash
+ const query = req.url.slice(req.path.length)
+ res.redirect(301, req.path.slice(0, -1) + query)
+ return
+ } else {
+ // express.static really doesn't like paths without trailing slashes
+ // so we "rewrite" request to look like request with trailing slash
+ // otherwise we'll have an infinite redirect loop. We did this because
+ // express.static automatically adds the redirect trailing slash then
+ const BASE = `http://localhost`
+ const urlToMessWith = new URL(req.url, BASE)
+ urlToMessWith.pathname += `/`
+
+ // The incoming req.url is relative, so we remove the base again
+ // we use new URL so that queries/hashes are handled automatically
+ req.url = urlToMessWith.toString().replace(BASE, ``)
+ }
+ } else if (option === `always`) {
+ if (req.path.slice(-1) !== `/` && page.path !== req.path) {
+ // Add trailing slash
+ const query = req.url.slice(req.path.length)
+ res.redirect(301, `${req.path}/${query}`)
+ return
+ }
+ }
+ }
+
+ next()
+ }
diff --git a/packages/gatsby/src/utils/gatsby-cloud-config.ts b/packages/gatsby/src/utils/gatsby-cloud-config.ts
new file mode 100644
index 0000000000000..5219c1d84b1a1
--- /dev/null
+++ b/packages/gatsby/src/utils/gatsby-cloud-config.ts
@@ -0,0 +1,18 @@
+import { IGatsbyConfig } from "../internal"
+
+type ConstructConfigObjectResponse = Pick<
+ IGatsbyConfig,
+ "trailingSlash" | "assetPrefix" | "pathPrefix"
+>
+
+export function constructConfigObject(
+ gatsbyConfig: IGatsbyConfig
+): ConstructConfigObjectResponse {
+ return {
+ trailingSlash: gatsbyConfig.trailingSlash ?? `legacy`,
+ pathPrefix: gatsbyConfig.pathPrefix ?? ``,
+ ...(gatsbyConfig.assetPrefix
+ ? { assetPrefix: gatsbyConfig.assetPrefix }
+ : {}),
+ }
+}
diff --git a/packages/gatsby/src/utils/merge-gatsby-config.ts b/packages/gatsby/src/utils/merge-gatsby-config.ts
index 361f6d7ac0c77..ba765a9d105b3 100644
--- a/packages/gatsby/src/utils/merge-gatsby-config.ts
+++ b/packages/gatsby/src/utils/merge-gatsby-config.ts
@@ -1,5 +1,6 @@
import _ from "lodash"
import { Express } from "express"
+import type { TrailingSlash } from "gatsby-page-utils"
// TODO export it in index.d.ts
type PluginEntry =
| string
@@ -25,8 +26,9 @@ interface IGatsbyConfigInput {
url: string
}
developMiddleware?(app: Express): void
- jsxRuntime?: string
+ jsxRuntime?: "classic" | "automatic"
jsxImportSource?: string
+ trailingSlash?: TrailingSlash
}
type ConfigKey = keyof IGatsbyConfigInput
diff --git a/packages/gatsby/src/utils/start-server.ts b/packages/gatsby/src/utils/start-server.ts
index e884c00256151..01158518a8f74 100644
--- a/packages/gatsby/src/utils/start-server.ts
+++ b/packages/gatsby/src/utils/start-server.ts
@@ -52,6 +52,7 @@ import { renderDevHTML } from "./dev-ssr/render-dev-html"
import { getServerData, IServerData } from "./get-server-data"
import { ROUTES_DIRECTORY } from "../constants"
import { getPageMode } from "./page-mode"
+import { configureTrailingSlash } from "./express-middlewares"
type ActivityTracker = any // TODO: Replace this with proper type once reporter is typed
@@ -173,6 +174,7 @@ export async function startServer(
*/
const graphqlEndpoint = `/_+graphi?ql`
+ // TODO(v5): Remove GraphQL Playground (GraphiQL will be more future-proof)
if (process.env.GATSBY_GRAPHQL_IDE === `playground`) {
app.get(
graphqlEndpoint,
@@ -523,6 +525,10 @@ export async function startServer(
developMiddleware(app, program)
}
+ const { proxy, trailingSlash } = store.getState().config
+
+ app.use(configureTrailingSlash(() => store.getState(), trailingSlash))
+
// Disable directory indexing i.e. serving index.html from a directory.
// This can lead to serving stale html files during development.
//
@@ -530,7 +536,6 @@ export async function startServer(
app.use(developStatic(`public`, { index: false }))
// Set up API proxy.
- const { proxy } = store.getState().config
if (proxy) {
proxy.forEach(({ prefix, url }) => {
app.use(`${prefix}/*`, (req, res) => {
diff --git a/packages/gatsby/src/utils/webpack.config.js b/packages/gatsby/src/utils/webpack.config.js
index f2f4ca6e12c43..488fcceb5683f 100644
--- a/packages/gatsby/src/utils/webpack.config.js
+++ b/packages/gatsby/src/utils/webpack.config.js
@@ -52,7 +52,7 @@ module.exports = async (
const stage = suppliedStage
const { rules, loaders, plugins } = createWebpackUtils(stage, program)
- const { assetPrefix, pathPrefix } = store.getState().config
+ const { assetPrefix, pathPrefix, trailingSlash } = store.getState().config
const publicPath = getPublicPath({ assetPrefix, pathPrefix, ...program })
@@ -228,6 +228,7 @@ module.exports = async (
__ASSET_PREFIX__: JSON.stringify(
program.prefixPaths ? assetPrefix : ``
),
+ __TRAILING_SLASH__: JSON.stringify(trailingSlash),
// TODO Improve asset passing to pages
BROWSER_ESM_ONLY: JSON.stringify(hasES6ModuleSupport(directory)),
}),