Gatsby: [gatsby-remark-autolink-headers] Why do I get `/` in front of the href in the anchor?

Created on 2 Mar 2020  路  18Comments  路  Source: gatsbyjs/gatsby

Summary

When using gatsby-remark-autolink-headers, auto generated anchors link gets / in front of all my anchors. In my setup, this results it to go to base url when I need it to be relative.

Here is the html that I get for the anchor:

<a href="/#basic-card" aria-label="basic card permalink" class="anchor after">
<svg aria-hidden="true" focusable="false" height="16" version="1.1" viewBox="0 0 16 16" width="16">
<path fill-rule="evenodd" d="M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z"></path>
</svg>
</a>

Relevant information

Environment (if relevant)

System:
    OS: macOS 10.15.2
    CPU: (12) x64 Intel(R) Core(TM) i9-8950HK CPU @ 2.90GHz
    Shell: 5.7.1 - /bin/zsh
  Binaries:
    Node: 10.15.3 - ~/.nvm/versions/node/v10.15.3/bin/node
    Yarn: 1.19.1 - /usr/local/bin/yarn
    npm: 6.13.4 - ~/.nvm/versions/node/v10.15.3/bin/npm
  Languages:
    Python: 2.7.16 - /usr/bin/python
  Browsers:
    Chrome: 80.0.3987.122
    Safari: 13.0.4

File contents (if changed)

gatsby-config.js:

module.exports = {
  siteMetadata: {
    title: `Anvil Design System`,
    description: ``,
    author: `@sevicetitan`,
    menuLinks:[
      {
        name:'Foundations',
        link:'/foundation/'
      },
      {
        name:'Patterns',
        link:'/pattern/'
      },
      {
        name:'Components',
        link:'/component/',
      },
      {
        name:'Resources',
        link:'/resource/'
      },
    ]
  },
  plugins: [
    `gatsby-plugin-react-helmet`,
    `gatsby-plugin-sass`,
    {
      resolve: `gatsby-source-filesystem`,
      options: {
        name: `images`,
        path: `${__dirname}/src/images`,
      },
    },
    {
      resolve: `gatsby-source-filesystem`,
      options: {
        name: `foundation`,
        path: `${__dirname}/content/foundation`,
      },
    },
    {
      resolve: `gatsby-source-filesystem`,
      options: {
        name: `component`,
        path: `${__dirname}/content/component`,
      },
    },
    {
      resolve: `gatsby-source-filesystem`,
      options: {
        name: `pattern`,
        path: `${__dirname}/content/pattern`,
      },
    },
    {
      resolve: `gatsby-source-filesystem`,
      options: {
        name: `resource`,
        path: `${__dirname}/content/resource`,
      },
    },
    `gatsby-transformer-sharp`,
    `gatsby-plugin-sharp`,
    {
      resolve: `gatsby-plugin-mdx`,
      options: {
        extensions: [`.mdx`, `.md`],
        gatsbyRemarkPlugins: [
          {
            resolve: `gatsby-remark-autolink-headers`,
            options: {
              offsetY: `100`,
              isIconAfterHeader: true,
              removeAccents: true,
            },
          },
          {
            resolve: `gatsby-remark-images`,
            options: {
              maxWidth: 1035,
              sizeByPixelDensity: true,
            },
          },
        ]
      },
    },
    {
      resolve: 'gatsby-redirect-from',
      options: {
        query: 'allMdx'
      }
    },
    `gatsby-plugin-meta-redirect`,
    // {
    //   resolve: 'gatsby-plugin-algolia',
    //   options: require(`./gatsby-pluglin-algolia-config.js`)
    // }
  ],
}

package.json:

{
  "name": "docs",
  "private": true,
  "description": "A simple starter to get up and developing quickly with Gatsby",
  "version": "0.1.0",
  "author": "ServiceTitan",
  "devDependencies": {
    "@mdx-js/mdx": "^1.5.3",
    "@mdx-js/react": "^1.5.3",
    "@servicetitan/anvil-fonts": "^3.0.0",
    "@servicetitan/anvil-icons": "^3.5.0",
    "@servicetitan/design-system": "^3.4.0",
    "@servicetitan/tokens": "^3.1.0",
    "algoliasearch": "^3.35.1",
    "classnames": "2.2.6",
    "dotenv": "^8.2.0",
    "gatsby": "2.18.12",
    "gatsby-image": "^2.2.34",
    "gatsby-plugin-algolia": "^0.5.0",
    "gatsby-plugin-manifest": "^2.2.31",
    "gatsby-plugin-mdx": "^1.0.64",
    "gatsby-plugin-meta-redirect": "^1.1.1",
    "gatsby-plugin-offline": "^3.0.27",
    "gatsby-plugin-page-creator": "^2.1.37",
    "gatsby-plugin-react-helmet": "^3.1.16",
    "gatsby-plugin-sass": "^2.1.26",
    "gatsby-plugin-sharp": "^2.3.5",
    "gatsby-plugin-styled-components": "^3.1.16",
    "gatsby-redirect-from": "^0.2.1",
    "gatsby-remark-autolink-headers": "^2.1.21",
    "gatsby-remark-images": "^3.1.39",
    "gatsby-source-filesystem": "^2.1.40",
    "gatsby-transformer-remark": "^2.6.45",
    "gatsby-transformer-sharp": "^2.3.7",
    "github-slugger": "^1.2.1",
    "jsx-to-string": "1.4.0",
    "mdx-utils": "^0.2.0",
    "prettier": "^1.19.1",
    "prism-react-renderer": "^1.0.2",
    "prop-types": "^15.7.2",
    "react": "16.12.0",
    "react-dom": "16.12.0",
    "react-frame-component": "^4.1.1",
    "react-helmet": "^5.2.1",
    "react-instantsearch-dom": "^6.3.0",
    "react-live": "^2.2.2",
    "react-resizable": "^1.10.1",
    "react-rnd": "^10.1.5",
    "react-typography": "^0.16.19",
    "remark-html": "10.0.0",
    "remark-slug": "^5.1.2",
    "sass": "1.23.7"
  },
  "keywords": [
    "gatsby"
  ],
  "license": "MIT",
  "scripts": {
    "build": "gatsby build",
    "format": "prettier --write \"**/*.{js,jsx,json,md}\"",
    "start": "gatsby develop -o",
    "serve": "gatsby serve",
    "clean": "gatsby clean",
    "info": "gatsby info --clipboard",
    "test": "echo \"Write tests! -> https://gatsby.dev/unit-testing\" && exit 1"
  }
}

gatsby-node.js:

const path = require("path");
const remark = require("remark");
const remarkHTML = require("remark-html");

exports.onCreateNode = ({ node, actions, getNode }) => {
  const { createNodeField } = actions

  if (node.internal.type === "Mdx") {
    const description = node.frontmatter.description;

    if (description) {
      const parsedDescription = remark()
        .use(remarkHTML)
        .processSync(description)
        .toString();

      createNodeField({
        name: 'description',
        node,
        value: parsedDescription
      });
    }

    const pathArr = node.fileAbsolutePath.replace(`${__dirname}/content/`, "").replace(`.mdx`, "").split("/")
    const globalNav = pathArr[0].toLowerCase()
    const category = pathArr[2] && `${pathArr[1]}`.toLowerCase()
    const isIndex = pathArr[pathArr.length - 1].toLowerCase() === 'index'
    const path = node.fileAbsolutePath.replace(`${__dirname}/content/`, "").replace(`.mdx`, "").replace(" ", "-").replace("/index", "").toLowerCase()

    createNodeField({
      name: "globalNav",
      node,
      value: `${globalNav}`,
    })

    createNodeField({
      name: "slug",
      node,
      value: `/${path}/`,
    })

    createNodeField({
      name: "category",
      node,
      value: category,
    })

    createNodeField({
      name: "path",
      node,
      value: `/${path}/`,
    })

    createNodeField({
      name: "isIndex",
      node,
      value: isIndex,
    })
  }
}

exports.createSchemaCustomization = ({ actions }) => {
  const { createTypes } = actions

  const typeDefs = `
    type MdxFrontmatterLinks implements Node @dontInfer {
      github: String
      figma: String
      storybook: String
    }

    type MdxFrontmatter implements Node {
      hidden: Boolean
      keywords: [String]
      tags: [String]
      tabs: [String]
      component: String
      description: String
      linkTo: String
      redirect_from: [String]
      pageOrder: [String]
    }
  `
  createTypes(typeDefs)
}

exports.createPages = async ({ graphql, actions, reporter }) => {
  const { createPage } = actions

  const result = await graphql(`
    {
      allMdx {
        edges {
          node {
            id
            fileAbsolutePath
            fields {
              globalNav
              category
              path
              isIndex
            }
            frontmatter {
              title
              description
              hidden
              keywords
              tags
              component
              linkTo
            }
          }
        }
      }
    }
  `)

  if (result.errors) {
    reporter.panicOnBuild('馃毃  ERROR: Loading "createPages" query')
  }

  result.data.allMdx.edges.forEach(item => {
    createPage({
      path: item.node.fields.path,
      component: path.resolve(`./src/templates/docs.js`),
      context: {
        id: item.node.id,
        title: item.node.frontmatter.title,
        parent: item.node.frontmatter.component ? item.node.frontmatter.component : item.node.frontmatter.title,
        isIndex: item.node.fields.isIndex,
        globalNav: item.node.fields.globalNav,
        category: item.node.fields.category,
        hidden: item.node.frontmatter.hidden,
        keywords: item.node.frontmatter.keywords,
        tags: item.node.frontmatter.tags,
        linkTo: item.node.frontmatter.linkTo,
      },
    })
  })
}

// Allow importing components into MDX content
exports.onCreateWebpackConfig = ({ actions }) => {
  actions.setWebpackConfig({
    resolve: {
      modules: [path.resolve(__dirname, "src"), "node_modules"]
    }
  });
};

gatsby-browser.js:

/**
 * Implement Gatsby's Browser APIs in this file.
 *
 * See: https://www.gatsbyjs.org/docs/browser-apis/
 */

// You can delete this file if you're not using it
import React from 'react';
import { MDXProvider } from "@mdx-js/react";
import "./src/styles/styles.scss";
import '@servicetitan/anvil-fonts/dist/css/anvil-fonts.css';
import '@servicetitan/design-system/dist/system.css';
import { Block, Table } from './src/components';
import { Text } from '@servicetitan/design-system';
import Docs from "./src/templates/docs";
import { Link } from 'gatsby';

const components = {
    pre: props => <Block {...props} />,
    inlineCode: props => <code className="DocsInlineCode" {...props} />,
    p: props => <span><Text {...props} className="DocsText m-b-2" style={{maxWidth:`35em`}} /></span>,
    h1: props => <Text {...props} size='5' bold el='h2' />,
    h2: props => <Text {...props} size='4' bold el='h3' />,
    h3: props => <Text {...props} size='3' bold el='h4' />,
    ul: props => <ul className="DocsList" {...props} style={{maxWidth: `35em`}} />,
    li: props => <Text el="li" {...props} />,
    a: props => <Link {...props} to={props.href}/>,
    table: props => <Table {...props} />,
    blockquote: props => <blockquote className="DocsBlockquote" {...props} />,
    ...require('@servicetitan/design-system'),
}

export const wrapRootElement = ({element}) => {
    return (
        <MDXProvider components={components}>
            {element}
        </MDXProvider>
    )
}

export const wrapPageElement = ({element, props}) => {
    return (
        <Docs {...props}>{element}</Docs>
    )
}

const scrollToElement = hash => {
    const id = window.decodeURI(hash.replace(`#`, ``))

    if (id !== ``) {
        const element = document.getElementById(id)

        if (element) {
            window.scrollTo(0, element.offsetTop - 56 - 44)
        }
    }

    return null
}

export const onRouteUpdate = ({ location }) => {
    scrollToElement(location.hash)
    return false
}

export const shouldUpdateScroll = ({ prevRouterProps: { preLocation }, routerProps: { location } }) => {

    if(location.hash) {
        scrollToElement(location.hash);
    } else {
        window.scrollTo(0, 0)
    }

    return null
};

gatsby-ssr.js: N/A

question or discussion

Most helpful comment

This is a little bit more robust. TBH, this seems like something gatsby-link should do itself... but maybe the intent was to keep the purpose/usage of gatsby-link strictly for internal routing? This also seems like logic that would have to be duplicated over and over, in different projects... I wouldn't be surprised if there's a package already out there that serves this purpose, but maybe it would make sense to include a new Gatsby module that people could use, but also makes it very clear that it is for creating internal _and_ external links

import React from 'react';
import { PropTypes } from 'prop-types';
import { useStaticQuery, graphql } from 'gatsby';
import GatsbyLink from 'gatsby-link';

const DOMAIN_PATTERN = /^(?:https?:)?[/]{2,}([^/]+)/;
const HASH_PATTERN = /^#.*/;
const INTERNAL_PATTERN = /^\/(?!\/)/;
const FILE_PATTERN = /.*[/](.+\.[^/]+?)([/].*?)?([#?].*)?$/;

const getDomain = (href) => {
    const matches = DOMAIN_PATTERN.exec(href);
    return matches ? matches[1] : ""
}

const isHashHref = (href) => HASH_PATTERN.test(href);
const isFileHref = (href) => {
    const matches = FILE_PATTERN.exec(href);
    if (!matches) return false;
    if (matches[1]) {
        // Files won't have additional path segments following them
        if (matches[2] && /[^/]/.test(matches[2])) return false;
        return true;
    }
    return false;
}
const isInternalHref = (href, siteUrl) => {
    if (INTERNAL_PATTERN.test(href)) return true;
    const targetDomain = getDomain(href);
    const localDomain = targetDomain && siteUrl && getDomain(siteUrl);
    return localDomain && targetDomain === localDomain;
}


export const Link = (props) => {
    const { site: { siteMetadata: { siteUrl } } } = useStaticQuery(graphql`
        query {
            site {
                siteMetadata {
                    siteUrl
                }
            }
        }
    `)
    const { to } = props;
    const isHash = isHashHref(to);
    const isFile = !isHash && isFileHref(to);
    const isInternal = !isFile && !isHash && isInternalHref(to, siteUrl);

    if (isHash || isFile || !isInternal) {
        return <a {...props} href={to} />;
    } else {
        return <GatsbyLink {...props} />
    }
}

Link.propTypes = {
    to: PropTypes.string.isRequired,
}

EDIT: Forgot that host isn't available during SSR. the component could be altered to accept a parameter that defines the siteUrl, but as it is, the following config would be required:

// gatsby-config.js

// Can do something like this
const isDev = process.env.NODE_ENV !== 'production';

module.exports = {
  siteMetadata: {
    siteUrl: isDev ? `https://localhost/` : `https://foobar.com`
  }
}

All 18 comments

I believe every link in Gatsby is generated as a root-relative link. Lots of info here.

This should still work though. Are you seeing the correct behavior?

@herecydev
Well.. I'm expecting that when I am in the page /components/layouts/card and the header is #basic-card I expect either the anchor link to be relative or have the full path(/components/layouts/card#basic-card).

Is my expectation wrong?

If I just get /#basic-card it will take me to www.mysite.com/#basic-card not www.mysite.com/components/layouts/card#basic-card

Yer I think it's a general expectation (and rightfully so if you're programming directly in html). But unfortunately I don't think that'll work in this case.

Surely the simplest way to achieve what you want is to use a standard <a> element? <Link> is super useful and recommended if you're going between gatsby pages but in this case I can't think of a reason that using a hash link would be bad

If you need to go between pages i.e. /components/layouts/card to /components/layouts/othercard then you will need a different strategy.

Ah sorry, I think I totally missed mentioning that this is happening to gatsby-remark-autolink-headers 's auto generated anchor.

@tounsoo Continuing the conversation from spectrum...

I created my own little starter repo to test this, and all of my header links get created correctly. AFAICT, the gatsby-remark-autolink-headers code is correct... there is nothing in there that would cause it to prefix # with /... so possibly it's happening in gatsby-plugin-mdx. That's a completely different animal, so I need to have something that is broken, so I can do some debugging on it.

I think a simplified repo would be very helpful in this case. It's a great deal of work for me to try to replicate what you have without be able to see it. I can't just copy/paste your config files and hope everything works... I would have to stub out a lot of stuff, and also rip out a bunch that breaks.

You already know how your project is structured, so it is much easier for you to break it down and start building it up by layers until it stops working correctly. Sometimes, that process will reveal the issue all by itself.

Okay, I just noticed something...

You're replacing the a element with a gatsby-link. Look at the way gatsby-link processes the to argument:
https://github.com/gatsbyjs/gatsby/blob/70a6857aceafe4996c46e77fd58c4b2e2f586207/packages/gatsby-link/src/index.js#L142

withPrefix:
https://github.com/gatsbyjs/gatsby/blob/70a6857aceafe4996c46e77fd58c4b2e2f586207/packages/gatsby-link/src/index.js#L9-L16

That would be why your paths are being prefixed. __BASE_PATH__ gets set here:
https://github.com/gatsbyjs/gatsby/blob/e4dae4d6a46fe9ba1c3fb5398d8569e657553bd3/packages/gatsby/src/utils/webpack.config.js#L192-L198

So, it defaults to an empty string. Which, in withPrefix equals a /

Sorry for late response, I think you are on to something. I will try it out first thing tomorrow morning and if it doesnt work out, I will prepare a branch for us to debug.

Thank you so much for looking into this!

@Js-Brecht that worked!!!! you are awesome! I was totally not looking at that direction. You saved me so much time 鉂わ笍

Glad to hear! :+1:

I am closing this as resolved

@tounsoo how you solved and fixed your problem?

@muescha I removed the replacing.

As @Js-Brecht mentioned, I replacing <a> with gatsby's <Link> component in my gatsby-browser.js. Because <Link> always adds / as a base path, the hash link added by autoheader was also getting the base path from it.

One issue I just thought about... if you do want links that point to other internal routes, they won't navigate properly.

Might need to do something like this (pseudocode):

a: props => {
   if (props.href.startsWith('#')) {
      return <a href={props.href}>props.label</a>
   } else {
      return <Link {...props} to={props.href}/>
   }
}

You can also set the base path with - did you tried this?
https://www.gatsbyjs.org/docs/path-prefix/~~

@muescha Did not try that. Can I pass empty prefix as well?

@tounsoo sorry wrong idea from my side - i though it wrong as you will deploy it on a subpath.

@Js-Brecht maybe also check if it is not an local site link, then also use an a link for this external links

This is a little bit more robust. TBH, this seems like something gatsby-link should do itself... but maybe the intent was to keep the purpose/usage of gatsby-link strictly for internal routing? This also seems like logic that would have to be duplicated over and over, in different projects... I wouldn't be surprised if there's a package already out there that serves this purpose, but maybe it would make sense to include a new Gatsby module that people could use, but also makes it very clear that it is for creating internal _and_ external links

import React from 'react';
import { PropTypes } from 'prop-types';
import { useStaticQuery, graphql } from 'gatsby';
import GatsbyLink from 'gatsby-link';

const DOMAIN_PATTERN = /^(?:https?:)?[/]{2,}([^/]+)/;
const HASH_PATTERN = /^#.*/;
const INTERNAL_PATTERN = /^\/(?!\/)/;
const FILE_PATTERN = /.*[/](.+\.[^/]+?)([/].*?)?([#?].*)?$/;

const getDomain = (href) => {
    const matches = DOMAIN_PATTERN.exec(href);
    return matches ? matches[1] : ""
}

const isHashHref = (href) => HASH_PATTERN.test(href);
const isFileHref = (href) => {
    const matches = FILE_PATTERN.exec(href);
    if (!matches) return false;
    if (matches[1]) {
        // Files won't have additional path segments following them
        if (matches[2] && /[^/]/.test(matches[2])) return false;
        return true;
    }
    return false;
}
const isInternalHref = (href, siteUrl) => {
    if (INTERNAL_PATTERN.test(href)) return true;
    const targetDomain = getDomain(href);
    const localDomain = targetDomain && siteUrl && getDomain(siteUrl);
    return localDomain && targetDomain === localDomain;
}


export const Link = (props) => {
    const { site: { siteMetadata: { siteUrl } } } = useStaticQuery(graphql`
        query {
            site {
                siteMetadata {
                    siteUrl
                }
            }
        }
    `)
    const { to } = props;
    const isHash = isHashHref(to);
    const isFile = !isHash && isFileHref(to);
    const isInternal = !isFile && !isHash && isInternalHref(to, siteUrl);

    if (isHash || isFile || !isInternal) {
        return <a {...props} href={to} />;
    } else {
        return <GatsbyLink {...props} />
    }
}

Link.propTypes = {
    to: PropTypes.string.isRequired,
}

EDIT: Forgot that host isn't available during SSR. the component could be altered to accept a parameter that defines the siteUrl, but as it is, the following config would be required:

// gatsby-config.js

// Can do something like this
const isDev = process.env.NODE_ENV !== 'production';

module.exports = {
  siteMetadata: {
    siteUrl: isDev ? `https://localhost/` : `https://foobar.com`
  }
}

馃憤

Was this page helpful?
0 / 5 - 0 ratings