From ea13795161b0e8224ae53cfa4ac24dea885b9e4b Mon Sep 17 00:00:00 2001 From: Paul D'Ambra Date: Mon, 6 Jul 2026 15:34:44 +0100 Subject: [PATCH 1/3] Fix inline markup leaking into docs heading anchors Headings that embed inline markup (e.g. an icon before the text) had that markup leak into their generated anchor ID, producing broken section links. Adds a local remark plugin that assigns a clean anchor ID to every heading before gatsby-remark-autolink-headers runs, stripping inline markup so it never reaches the URL. A shared helper centralizes the slug logic and is reused by the sidebar table of contents and the Algolia search index so all three agree (and trims a leading space that previously slugged to a leading hyphen). Generated-By: PostHog Code Task-Id: 43f6aaf6-0c09-48da-8061-ebb543dc7318 --- gatsby-config.js | 2 + gatsby/algoliaConfig.js | 3 +- gatsby/createPages.ts | 17 ++++----- gatsby/utils/headingSlug.js | 26 +++++++++++++ plugins/gatsby-remark-heading-slug/index.js | 38 +++++++++++++++++++ .../gatsby-remark-heading-slug/package.json | 5 +++ 6 files changed, 81 insertions(+), 10 deletions(-) create mode 100644 gatsby/utils/headingSlug.js create mode 100644 plugins/gatsby-remark-heading-slug/index.js create mode 100644 plugins/gatsby-remark-heading-slug/package.json 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..def34f80212d --- /dev/null +++ b/gatsby/utils/headingSlug.js @@ -0,0 +1,26 @@ +// 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`). +const stripHeadingMarkup = (value = '') => (typeof value === 'string' ? value.replace(INLINE_MARKUP_REGEX, '').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" +} From 4ec497ffa714419b28c6d7ce63bb84d9a1e50d34 Mon Sep 17 00:00:00 2001 From: Paul D'Ambra Date: Mon, 6 Jul 2026 16:14:46 +0100 Subject: [PATCH 2/3] Strip stray angle brackets when cleaning heading markup CodeQL flagged the tag-stripping regex as incomplete multi-character sanitization. Drop any leftover angle brackets after removing balanced tag pairs so no partial tag can survive. Output for real headings is unchanged (the icon spans are fully removed either way); this only hardens the helper against malformed/unmatched markup. Generated-By: PostHog Code Task-Id: 43f6aaf6-0c09-48da-8061-ebb543dc7318 --- gatsby/utils/headingSlug.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/gatsby/utils/headingSlug.js b/gatsby/utils/headingSlug.js index def34f80212d..0c2ba70a78ad 100644 --- a/gatsby/utils/headingSlug.js +++ b/gatsby/utils/headingSlug.js @@ -16,8 +16,11 @@ 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`). -const stripHeadingMarkup = (value = '') => (typeof value === 'string' ? value.replace(INLINE_MARKUP_REGEX, '').trim() : '') +// slug to `-feature-flags`). After removing balanced tag pairs, any stray angle +// brackets are dropped too so no partial/malformed tag can survive — the result is +// always safe as plain text (this also feeds github-slugger, which strips the rest). +const stripHeadingMarkup = (value = '') => + typeof value === 'string' ? value.replace(INLINE_MARKUP_REGEX, '').replace(/[<>]/g, '').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. From 226d478c6f4f7a6b181fdc160c1cee7056773014 Mon Sep 17 00:00:00 2001 From: Paul D'Ambra Date: Mon, 6 Jul 2026 16:20:01 +0100 Subject: [PATCH 3/3] Strip heading markup to a fixed point to satisfy CodeQL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeQL's "incomplete multi-character sanitization" query flags the tag-strip regex on its shape, so the earlier bracket-removal follow-up didn't clear it. Apply the removal repeatedly until the string stops changing — the remediation CodeQL documents for this query. Output for real headings is unchanged. Generated-By: PostHog Code Task-Id: 43f6aaf6-0c09-48da-8061-ebb543dc7318 --- gatsby/utils/headingSlug.js | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/gatsby/utils/headingSlug.js b/gatsby/utils/headingSlug.js index 0c2ba70a78ad..ccd5e9b3dc12 100644 --- a/gatsby/utils/headingSlug.js +++ b/gatsby/utils/headingSlug.js @@ -16,11 +16,20 @@ 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`). After removing balanced tag pairs, any stray angle -// brackets are dropped too so no partial/malformed tag can survive — the result is -// always safe as plain text (this also feeds github-slugger, which strips the rest). -const stripHeadingMarkup = (value = '') => - typeof value === 'string' ? value.replace(INLINE_MARKUP_REGEX, '').replace(/[<>]/g, '').trim() : '' +// 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.