Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions gatsby-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
3 changes: 2 additions & 1 deletion gatsby/algoliaConfig.js
Original file line number Diff line number Diff line change
@@ -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) => {
Expand Down Expand Up @@ -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,
Expand Down
17 changes: 8 additions & 9 deletions gatsby/createPages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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),
}
})
}
Expand Down Expand Up @@ -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),
}
})
}
Expand Down
29 changes: 29 additions & 0 deletions gatsby/utils/headingSlug.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// Shared helpers for turning heading text into anchor slugs.
//
// Some headings embed inline markup (an icon <span>, a beta label, etc.). Left
// untouched, that markup leaks into generated anchor IDs — e.g. a heading like
// `## <span ...><IconToggle /></span> 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. `<span ...>...</span>`.
// 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 `<span>...</span> 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() : ''

Check failure

Code scanning / CodeQL

Incomplete multi-character sanitization High

This string may still contain
<script
, which may cause an HTML element injection vulnerability.
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed

// 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 }
38 changes: 38 additions & 0 deletions plugins/gatsby-remark-heading-slug/index.js
Original file line number Diff line number Diff line change
@@ -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
}
5 changes: 5 additions & 0 deletions plugins/gatsby-remark-heading-slug/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "gatsby-remark-heading-slug",
"version": "0.1.0",
"main": "index.js"
}
Loading