diff --git a/gatsby-config.js b/gatsby-config.js index 40928fabfbbe..0923864c6dbb 100644 --- a/gatsby-config.js +++ b/gatsby-config.js @@ -125,6 +125,8 @@ module.exports = { new URL(node.url).hostname === 'raw.githubusercontent.com', extensions: ['.mdx', '.md'], gatsbyRemarkPlugins: [ + // Must run before autolink-headers so heading IDs ignore inline markup (icons, labels) + { resolve: require.resolve('./plugins/gatsby-remark-heading-slug') }, { resolve: 'gatsby-remark-autolink-headers', options: { icon: false } }, { resolve: require.resolve('./plugins/gatsby-remark-video'), diff --git a/gatsby/algoliaConfig.js b/gatsby/algoliaConfig.js index bf64caca30c5..43982c4112fa 100644 --- a/gatsby/algoliaConfig.js +++ b/gatsby/algoliaConfig.js @@ -1,5 +1,6 @@ const { createContentDigest } = require('gatsby-core-utils') const Slugger = require('github-slugger') +const { slugifyHeading } = require('./utils/headingSlug') const slugger = new Slugger() const retrievePages = (type, regex) => { @@ -38,7 +39,7 @@ const retrievePages = (type, regex) => { ...page, headings: headings.map((heading) => ({ ...heading, - fragment: slugger.slug(heading.value), + fragment: slugifyHeading(heading.value, slugger), })), id, title: frontmatter.title, diff --git a/gatsby/createPages.ts b/gatsby/createPages.ts index 156b54a43470..4856c8b1db34 100644 --- a/gatsby/createPages.ts +++ b/gatsby/createPages.ts @@ -6,6 +6,7 @@ import menu from '../src/navs/index' import type { GatsbyContentResponse, MetaobjectsCollection } from '../src/templates/merch/types' import { flattenMenu, replacePath } from './utils' const Slugger = require('github-slugger') +const { stripHeadingMarkup, slugifyHeading } = require('./utils/headingSlug') const markdownLinkExtractor = require('markdown-link-extractor') const isMinimalBuild = process.env.GATSBY_MINIMAL === 'true' @@ -621,15 +622,14 @@ export const createPages: GatsbyNode['createPages'] = async ({ actions: { create // need to use slugger for header links to match const slugger = new Slugger() return headings.map((heading) => { - // Strip HTML tags from heading value - // Useful if we wanna add a beta label to a header - const cleanValue = heading.value.replace(/\s*<([a-z]+).+?>.+?<\/\1>/g, '') - + // Strip inline markup from heading value (icons, beta labels, etc.) so it + // doesn't leak into the display text or the anchor URL. Kept in sync with + // the heading IDs via the shared helper in ./utils/headingSlug. return { ...heading, depth: heading.depth - 2, - url: slugger.slug(cleanValue), - value: cleanValue, + url: slugifyHeading(heading.value, slugger), + value: stripHeadingMarkup(heading.value), } }) } @@ -1265,12 +1265,11 @@ async function createMinimalPages({ function formatToc(headings: Array<{ depth: number; value: string }>) { const slugger = new Slugger() return headings.map((heading) => { - const cleanValue = heading.value.replace(/\s*<([a-z]+).+?>.+?<\/\1>/g, '') return { ...heading, depth: heading.depth - 2, - url: slugger.slug(cleanValue), - value: cleanValue, + url: slugifyHeading(heading.value, slugger), + value: stripHeadingMarkup(heading.value), } }) } diff --git a/gatsby/utils/headingSlug.js b/gatsby/utils/headingSlug.js new file mode 100644 index 000000000000..ccd5e9b3dc12 --- /dev/null +++ b/gatsby/utils/headingSlug.js @@ -0,0 +1,38 @@ +// Shared helpers for turning heading text into anchor slugs. +// +// Some headings embed inline markup (an icon , a beta label, etc.). Left +// untouched, that markup leaks into generated anchor IDs — e.g. a heading like +// `## Feature flags` would produce an ID like +// `span-classnameinline-blockicontoggle-...-feature-flags` instead of +// `feature-flags`. Stripping the markup before slugging keeps section links clean +// and consistent across the element ID, the table of contents, and search. + +const GithubSlugger = require('github-slugger') + +// Matches a balanced pair of inline HTML/JSX tags, e.g. `...`. +// Kept identical to the regex used by `formatToc` in gatsby/createPages.ts so the +// element ID, the sidebar table of contents, and Algolia fragments all agree. +const INLINE_MARKUP_REGEX = /\s*<([a-z]+).+?>.+?<\/\1>/g + +// Remove inline markup and trim, so a heading like `... Feature flags` +// yields `Feature flags` rather than ` Feature flags` (a leading space would otherwise +// slug to `-feature-flags`). The removal is applied to a fixed point (repeat until the +// string stops changing) so nested or adjacent tags can't leave a partial tag behind. +const stripHeadingMarkup = (value = '') => { + if (typeof value !== 'string') { + return '' + } + let result = value + let previous + do { + previous = result + result = result.replace(INLINE_MARKUP_REGEX, '') + } while (result !== previous) + return result.trim() +} + +// Slugify a heading's text, ignoring any inline markup it contains. Pass an +// existing GithubSlugger instance to keep per-page de-duplication counters in sync. +const slugifyHeading = (value, slugger = new GithubSlugger()) => slugger.slug(stripHeadingMarkup(value)) + +module.exports = { stripHeadingMarkup, slugifyHeading, INLINE_MARKUP_REGEX } diff --git a/plugins/gatsby-remark-heading-slug/index.js b/plugins/gatsby-remark-heading-slug/index.js new file mode 100644 index 000000000000..c372a531dcd6 --- /dev/null +++ b/plugins/gatsby-remark-heading-slug/index.js @@ -0,0 +1,38 @@ +const visit = require('unist-util-visit') +const GithubSlugger = require('github-slugger') +const { slugifyHeading } = require('../../gatsby/utils/headingSlug') + +// Concatenate a node's text content, mirroring `mdast-util-to-string`. Inline +// HTML/JSX nodes expose their raw markup as `value`, which is exactly what we +// want to feed to the slugger (and then strip) so this matches what +// `gatsby-remark-autolink-headers` would otherwise produce. +const getNodeText = (node) => { + if (typeof node.value === 'string') { + return node.value + } + if (Array.isArray(node.children)) { + return node.children.map(getNodeText).join('') + } + return '' +} + +// Runs BEFORE gatsby-remark-autolink-headers to assign a clean anchor ID to every +// heading, stripping any inline markup (icon spans, beta labels, etc.) so it never +// leaks into the URL. autolink-headers only patches an ID when one isn't already +// set, so the value we write here wins. +module.exports = ({ markdownAST }) => { + const slugger = new GithubSlugger() + + visit(markdownAST, 'heading', (node) => { + const id = slugifyHeading(getNodeText(node), slugger) + + node.data = node.data || {} + node.data.id = id + node.data.htmlAttributes = node.data.htmlAttributes || {} + node.data.htmlAttributes.id = id + node.data.hProperties = node.data.hProperties || {} + node.data.hProperties.id = id + }) + + return markdownAST +} diff --git a/plugins/gatsby-remark-heading-slug/package.json b/plugins/gatsby-remark-heading-slug/package.json new file mode 100644 index 000000000000..e2487286f79d --- /dev/null +++ b/plugins/gatsby-remark-heading-slug/package.json @@ -0,0 +1,5 @@ +{ + "name": "gatsby-remark-heading-slug", + "version": "0.1.0", + "main": "index.js" +}