diff --git a/docs/docs/gatsby-link.md b/docs/docs/gatsby-link.md index 154e4c907c6d..73be5be55e0f 100644 --- a/docs/docs/gatsby-link.md +++ b/docs/docs/gatsby-link.md @@ -348,6 +348,10 @@ const Link = ({ children, to, activeClassName, partiallyActive, ...other }) => { export default Link ``` +### Relative links + +The `` component follows [the behavior of @reach/router](https://reach.tech/router/nesting) by ignoring trailing slashes and treating each page as if it were a directory when resolving relative links. For example if you are on either `/blog/my-great-page` or `/blog/my-great-page/` (note the trailing slash), a link to `../second-page` will take you to `/blog/second-page`. + ### File Downloads You can similarly check for file downloads: diff --git a/docs/docs/linking-between-pages.md b/docs/docs/linking-between-pages.md index aaf89ba68f6c..eb79d7f08ead 100644 --- a/docs/docs/linking-between-pages.md +++ b/docs/docs/linking-between-pages.md @@ -31,6 +31,10 @@ The above code will add a link to the contact page, automatically rendered in HT > **Note:** the value `"/"` for the `to` property will take users to the home page. +## Using relative links in the `` component + +Relative links are ones where the `to` property doesn't start with a `/`. These behave slightly differently from relative links in `` tags, and instead follow [the behavior of @reach/router](https://reach.tech/router/nesting). This avoids confusion with trailing slashes by ignoring them entirely and treating every page as if it were a directory. For example, if you are on either `/blog/my-great-page` or `/blog/my-great-page/` (note the trailing slash), a link to `../second-page` will take you to `/blog/second-page`. Similarly, if you are on `/blog` or `/blog/` a link to `hello-world` will take you to `/blog/hello-world`. + ## Using `` for external links If you are linking to pages not handled by your Gatsby site (such as on a different domain), you should use the native HTML `` tag instead of Gatsby Link. diff --git a/e2e-tests/development-runtime/cypress/integration/navigation/linking.js b/e2e-tests/development-runtime/cypress/integration/navigation/linking.js index d8902104df0c..c15825a940ef 100644 --- a/e2e-tests/development-runtime/cypress/integration/navigation/linking.js +++ b/e2e-tests/development-runtime/cypress/integration/navigation/linking.js @@ -33,6 +33,42 @@ describe(`navigation`, () => { cy.location(`pathname`).should(`equal`, `/`) }) + describe(`relative links`, () => { + it(`can navigate to a subdirectory`, () => { + cy.getTestElement(`subdir-link`) + .click() + .location(`pathname`) + .should(`eq`, `/subdirectory/page-1`) + }) + + it(`can navigate to a sibling page`, () => { + cy.visit(`/subdirectory/page-1`) + .waitForRouteChange() + .getTestElement(`page-2-link`) + .click() + .location(`pathname`) + .should(`eq`, `/subdirectory/page-2`) + }) + + it(`can navigate to a parent page`, () => { + cy.visit(`/subdirectory/page-1`) + .waitForRouteChange() + .getTestElement(`page-parent-link`) + .click() + .location(`pathname`) + .should(`eq`, `/subdirectory`) + }) + + it(`can navigate to a sibling page programatically`, () => { + cy.visit(`/subdirectory/page-1`) + .waitForRouteChange() + .getTestElement(`page-2-button-link`) + .click() + .location(`pathname`) + .should(`eq`, `/subdirectory/page-2`) + }) + }) + describe(`non-existent route`, () => { beforeEach(() => { cy.getTestElement(`broken-link`).click().waitForRouteChange() diff --git a/e2e-tests/development-runtime/src/pages/index.js b/e2e-tests/development-runtime/src/pages/index.js index 50d05983c554..6cd781071fc2 100644 --- a/e2e-tests/development-runtime/src/pages/index.js +++ b/e2e-tests/development-runtime/src/pages/index.js @@ -26,6 +26,9 @@ const IndexPage = ({ data }) => ( Go to a broken link + + Go to subdirectory +

Blog posts

) diff --git a/e2e-tests/production-runtime/src/pages/subdirectory/index.js b/e2e-tests/production-runtime/src/pages/subdirectory/index.js new file mode 100644 index 000000000000..bbc1fef6ad98 --- /dev/null +++ b/e2e-tests/production-runtime/src/pages/subdirectory/index.js @@ -0,0 +1,23 @@ +import * as React from "react" +import { Link, navigate } from "gatsby" + +import Layout from "../../components/layout" + +const IndexPage = () => ( + +

Hi people

+

Welcome to your new Gatsby site.

+

Now go build something great.

+ + Go to page 2 + + +
+) + +export default IndexPage diff --git a/e2e-tests/production-runtime/src/pages/subdirectory/page-1.js b/e2e-tests/production-runtime/src/pages/subdirectory/page-1.js new file mode 100644 index 000000000000..755451156060 --- /dev/null +++ b/e2e-tests/production-runtime/src/pages/subdirectory/page-1.js @@ -0,0 +1,26 @@ +import * as React from "react" +import { Link, navigate } from "gatsby" + +import Layout from "../../components/layout" + +const IndexPage = () => ( + +

Hi people

+

Welcome to your new Gatsby site.

+

Now go build something great.

+ + Go to page 2 + + + Go up + + +
+) + +export default IndexPage diff --git a/e2e-tests/production-runtime/src/pages/subdirectory/page-2.js b/e2e-tests/production-runtime/src/pages/subdirectory/page-2.js new file mode 100644 index 000000000000..9e59c744085d --- /dev/null +++ b/e2e-tests/production-runtime/src/pages/subdirectory/page-2.js @@ -0,0 +1,16 @@ +import * as React from "react" +import { Link } from "gatsby" + +import Layout from "../../components/layout" + +const SecondPage = () => ( + +

Hi from the second page

+

Welcome to page 2

+ + Go back to page 1 + +
+) + +export default SecondPage diff --git a/packages/gatsby-link/src/__tests__/index.js b/packages/gatsby-link/src/__tests__/index.js index 0b1e0930e71f..82eaefb5c9dc 100644 --- a/packages/gatsby-link/src/__tests__/index.js +++ b/packages/gatsby-link/src/__tests__/index.js @@ -134,6 +134,52 @@ describe(``, () => { expect(link.getAttribute(`href`)).toEqual(`${pathPrefix}${location}`) }) + it(`correctly handles pathPrefix with trailing slash`, () => { + const pathPrefix = `/prefixed/` + const location = `/courses?sort=name` + const { link } = setup({ linkProps: { to: location }, pathPrefix }) + expect(link.getAttribute(`href`)).toEqual(`/prefixed${location}`) + }) + + it(`ignores pathPrefix for external links`, () => { + const pathPrefix = `/prefixed/` + const location = `https://example.com` + const { link } = setup({ linkProps: { to: location }, pathPrefix }) + expect(link.getAttribute(`href`)).toEqual(location) + }) + + it(`handles relative link with "./"`, () => { + const location = `./courses?sort=name` + const { link } = setup({ linkProps: { to: location } }) + expect(link.getAttribute(`href`)).toEqual(`/active/courses?sort=name`) + }) + + it(`handles relative link with "../"`, () => { + const location = `../courses?sort=name` + const { link } = setup({ linkProps: { to: location } }) + expect(link.getAttribute(`href`)).toEqual(`/courses?sort=name`) + }) + + it(`handles bare relative link`, () => { + const location = `courses?sort=name` + const { link } = setup({ linkProps: { to: location } }) + expect(link.getAttribute(`href`)).toEqual(`/active/courses?sort=name`) + }) + + it(`handles relative link with pathPrefix`, () => { + const pathPrefix = `/prefixed` + const sourcePath = `/prefixed/active/` + const location = `./courses?sort=name` + const { link } = setup({ + linkProps: { to: location }, + pathPrefix, + sourcePath, + }) + expect(link.getAttribute(`href`)).toEqual( + `${pathPrefix}/active/courses?sort=name` + ) + }) + it(`does not warn when internal`, () => { jest.spyOn(global.console, `warn`) const to = `/courses?sort=name` @@ -141,6 +187,13 @@ describe(``, () => { expect(console.warn).not.toBeCalled() }) + it(`does not warn when relative`, () => { + jest.spyOn(global.console, `warn`) + const to = `./courses?sort=name` + setup({ linkProps: { to } }) + expect(console.warn).not.toBeCalled() + }) + it(`warns when not internal`, () => { jest.spyOn(global.console, `warn`) const to = `https://gatsby.org` diff --git a/packages/gatsby-link/src/index.js b/packages/gatsby-link/src/index.js index 6c9dd1fe1986..8a4c5bd0abf1 100644 --- a/packages/gatsby-link/src/index.js +++ b/packages/gatsby-link/src/index.js @@ -1,26 +1,51 @@ import PropTypes from "prop-types" import React from "react" -import { Link } from "@reach/router" +import { Link, Location } from "@reach/router" +import { resolve } from "@reach/router/lib/utils" import { parsePath } from "./parse-path" export { parsePath } -export function withPrefix(path) { - return normalizePath( - [ - typeof __BASE_PATH__ !== `undefined` ? __BASE_PATH__ : __PATH_PREFIX__, - path, - ].join(`/`) - ) +const isAbsolutePath = path => path.startsWith(`/`) + +export function withPrefix(path, prefix = __BASE_PATH__) { + if (!isLocalLink(path)) { + return path + } + + if (path.startsWith(`./`) || path.startsWith(`../`)) { + return path + } + const base = prefix ?? __PATH_PREFIX__ ?? `/` + + return `${base?.endsWith(`/`) ? base.slice(0, -1) : base}${ + path.startsWith(`/`) ? path : `/${path}` + }` } +const isLocalLink = path => + !path.startsWith(`http://`) && + !path.startsWith(`https://`) && + !path.startsWith(`//`) + export function withAssetPrefix(path) { - return [__PATH_PREFIX__].concat([path.replace(/^\//, ``)]).join(`/`) + return withPrefix(path, __PATH_PREFIX__) } -function normalizePath(path) { - return path.replace(/\/+/g, `/`) +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 (!isLocalLink(path)) { + return path + } + return isAbsolutePath(path) ? withPrefix(path) : absolutify(path, relativeTo) } const NavLinkPropTypes = { @@ -67,14 +92,20 @@ class GatsbyLink extends React.Component { componentDidUpdate(prevProps, prevState) { // Preserve non IO functionality if no support if (this.props.to !== prevProps.to && !this.state.IOSupported) { - ___loader.enqueue(parsePath(this.props.to).pathname) + ___loader.enqueue( + parsePath(rewriteLinkPath(this.props.to, window.location.pathname)) + .pathname + ) } } componentDidMount() { // Preserve non IO functionality if no support if (!this.state.IOSupported) { - ___loader.enqueue(parsePath(this.props.to).pathname) + ___loader.enqueue( + parsePath(rewriteLinkPath(this.props.to, window.location.pathname)) + .pathname + ) } } @@ -98,7 +129,10 @@ class GatsbyLink extends React.Component { if (this.state.IOSupported && ref) { // If IO supported and element reference found, setup Observer functionality this.io = createIntersectionObserver(ref, () => { - ___loader.enqueue(parsePath(this.props.to).pathname) + ___loader.enqueue( + parsePath(rewriteLinkPath(this.props.to, window.location.pathname)) + .pathname + ) }) } } @@ -131,59 +165,67 @@ class GatsbyLink extends React.Component { /* eslint-enable no-unused-vars */ ...rest } = this.props - - const LOCAL_URL = /^\/(?!\/)/ - if (process.env.NODE_ENV !== `production` && !LOCAL_URL.test(to)) { + if (process.env.NODE_ENV !== `production` && !isLocalLink(to)) { console.warn( `External link ${to} was detected in a Link component. Use the Link component only for internal links. See: https://gatsby.dev/internal-links` ) } - const prefixedTo = withPrefix(to) - return ( - { - if (onMouseEnter) { - onMouseEnter(e) - } - ___loader.hovering(parsePath(to).pathname) - }} - onClick={e => { - if (onClick) { - onClick(e) - } - - if ( - e.button === 0 && // ignore right clicks - !this.props.target && // let browser handle "target=_blank" - !e.defaultPrevented && // onClick prevented default - !e.metaKey && // ignore clicks with modifier keys... - !e.altKey && - !e.ctrlKey && - !e.shiftKey - ) { - e.preventDefault() - - let shouldReplace = replace - const isCurrent = encodeURI(to) === window.location.pathname - if (typeof replace !== `boolean` && isCurrent) { - shouldReplace = true - } - - // Make sure the necessary scripts and data are - // loaded before continuing. - navigate(to, { state, replace: shouldReplace }) - } - - return true + + {({ location }) => { + const prefixedTo = rewriteLinkPath(to, location.pathname) + return isLocalLink(prefixedTo) ? ( + { + if (onMouseEnter) { + onMouseEnter(e) + } + ___loader.hovering(parsePath(prefixedTo).pathname) + }} + onClick={e => { + if (onClick) { + onClick(e) + } + + if ( + e.button === 0 && // ignore right clicks + !this.props.target && // let browser handle "target=_blank" + !e.defaultPrevented && // onClick prevented default + !e.metaKey && // ignore clicks with modifier keys... + !e.altKey && + !e.ctrlKey && + !e.shiftKey + ) { + e.preventDefault() + + let shouldReplace = replace + const isCurrent = + encodeURI(prefixedTo) === window.location.pathname + if (typeof replace !== `boolean` && isCurrent) { + shouldReplace = true + } + // Make sure the necessary scripts and data are + // loaded before continuing. + window.___navigate(prefixedTo, { + state, + replace: shouldReplace, + }) + } + + return true + }} + {...rest} + /> + ) : ( +
+ ) }} - {...rest} - /> + ) } } @@ -206,17 +248,17 @@ export default React.forwardRef((props, ref) => ( )) export const navigate = (to, options) => { - window.___navigate(withPrefix(to), options) + window.___navigate(rewriteLinkPath(to, window.location.pathname), options) } export const push = to => { showDeprecationWarning(`push`, `navigate`, 3) - window.___push(withPrefix(to)) + window.___push(rewriteLinkPath(to, window.location.pathname)) } export const replace = to => { showDeprecationWarning(`replace`, `navigate`, 3) - window.___replace(withPrefix(to)) + window.___replace(rewriteLinkPath(to, window.location.pathname)) } // TODO: Remove navigateTo for Gatsby v3 diff --git a/packages/gatsby/cache-dir/__tests__/strip-prefix.js b/packages/gatsby/cache-dir/__tests__/strip-prefix.js index 5a220adb4e95..1cb93e31ffe6 100644 --- a/packages/gatsby/cache-dir/__tests__/strip-prefix.js +++ b/packages/gatsby/cache-dir/__tests__/strip-prefix.js @@ -24,4 +24,8 @@ describe(`strip-prefix`, () => { it(`returns input str if no prefix is provided`, () => { expect(stripPrefix(`/bar`)).toBe(`/bar`) }) + + it(`returns "/" if str equals prefix`, () => { + expect(stripPrefix(`/bar`, `/bar`)).toBe(`/`) + }) }) diff --git a/packages/gatsby/cache-dir/find-path.js b/packages/gatsby/cache-dir/find-path.js index 494e35ec5073..3159c7ff14d0 100644 --- a/packages/gatsby/cache-dir/find-path.js +++ b/packages/gatsby/cache-dir/find-path.js @@ -17,6 +17,23 @@ const trimPathname = rawPathname => { return trimmedPathname } +function absolutify(path) { + // If it's already absolute, return as-is + if ( + path.startsWith(`/`) || + path.startsWith(`https://`) || + path.startsWith(`http://`) + ) { + return path + } + // Calculate path relative to current location, adding a trailing slash to + // match behavior of @reach/router + return new URL( + path, + window.location.href + (window.location.href.endsWith(`/`) ? `` : `/`) + ).pathname +} + /** * Set list of matchPaths * @@ -55,8 +72,7 @@ export const findMatchPath = rawPathname => { // Or if `match-paths.json` contains `{ "/foo*": "/page1", ...}`, then // `/foo?bar=far` => `/page1` export const findPath = rawPathname => { - const trimmedPathname = trimPathname(rawPathname) - + const trimmedPathname = trimPathname(absolutify(rawPathname)) if (pathCache.has(trimmedPathname)) { return pathCache.get(trimmedPathname) } @@ -80,7 +96,7 @@ export const findPath = rawPathname => { * @return {string} */ export const cleanPath = rawPathname => { - const trimmedPathname = trimPathname(rawPathname) + const trimmedPathname = trimPathname(absolutify(rawPathname)) let foundPath = trimmedPathname if (foundPath === `/index.html`) { diff --git a/packages/gatsby/cache-dir/strip-prefix.js b/packages/gatsby/cache-dir/strip-prefix.js index 2dda1fbcea17..1c4228514ec9 100644 --- a/packages/gatsby/cache-dir/strip-prefix.js +++ b/packages/gatsby/cache-dir/strip-prefix.js @@ -3,15 +3,17 @@ * isn't found. */ -export default (str, prefix = ``) => { +export default function stripPrefix(str, prefix = ``) { if (!prefix) { return str } - prefix += `/` + if (str === prefix) { + return `/` + } - if (str.substr(0, prefix.length) === prefix) { - return str.slice(prefix.length - 1) + if (str.startsWith(`${prefix}/`)) { + return str.slice(prefix.length) } return str