fix(editor): match Unicode heading anchors across rich and preview modes (#35077)

* fix(editor): match Unicode heading anchors across rich and preview modes

* test(editor): drop slugger tests that only re-test github-slugger

The preview-parity test in markdown-anchor-scroll.test.ts covers the behavior that matters.

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
OrcaWin
2026-10-29 17:14:52 -07:00
committed by GitHub
co-authored by OrcaWin
parent 32060decbb
commit 15023ab339
5 changed files with 33 additions and 57 deletions
+1
View File
@@ -316,6 +316,7 @@
"emoji-picker-react": "^4.22.3",
"emojibase-data": "17.0.0",
"esbuild": "^0.28.2",
"github-slugger": "2.0.0",
"happy-dom": "^21.0.4",
"hast-util-from-html-isomorphic": "2.0.0",
"hast-util-to-jsx-runtime": "2.3.6",
+3
View File
@@ -474,6 +474,9 @@ importers:
esbuild:
specifier: ^0.28.2
version: 0.28.2
github-slugger:
specifier: 2.0.0
version: 2.0.0
happy-dom:
specifier: ^21.0.4
version: 21.0.4
@@ -0,0 +1,28 @@
// @vitest-environment happy-dom
import { expect, it, vi } from 'vitest'
import { scrollToAnchorInEditor } from './markdown-anchor-scroll'
import { parseMarkdownPreviewDocument } from './markdown-preview-document-tree'
it.each([['Release ½'], ['Use ①', 'Use ²'], ['New \u{1e4d0}'], ['Café 中文', 'Café 中文']])(
'resolves preview anchors for rich headings: %j',
(...titles) => {
const { document: preview } = parseMarkdownPreviewDocument(
titles.map((title) => `## ${title}`).join('\n\n')
)
const root = document.createElement('div')
const headings = titles.map((title) => {
const heading = document.createElement('h2')
heading.textContent = title
root.append(heading)
return vi.spyOn(heading, 'scrollIntoView').mockImplementation(() => {})
})
for (const [index, anchor] of preview.blocks.flatMap((block) => block.anchors).entries()) {
headings.forEach((scroll) => scroll.mockClear())
scrollToAnchorInEditor(root, encodeURIComponent(anchor))
expect(headings.map((scroll) => scroll.mock.calls.length)).toEqual(
headings.map((_, headingIndex) => Number(index === headingIndex))
)
}
}
)
@@ -1,29 +0,0 @@
import { describe, expect, it } from 'vitest'
import { MarkdownHeadingSlugger, slugMarkdownHeading } from './markdown-heading-slug'
describe('markdown heading slugger', () => {
it('matches GitHub-style punctuation and space handling used by markdown anchors', () => {
expect(slugMarkdownHeading('A & B')).toBe('a--b')
expect(slugMarkdownHeading('https://example.com')).toBe('httpsexamplecom')
expect(slugMarkdownHeading('Keep_under-score')).toBe('keep_under-score')
})
it('adds stable duplicate suffixes', () => {
const slugger = new MarkdownHeadingSlugger()
expect([slugger.slug('Repeat'), slugger.slug('Repeat'), slugger.slug('Repeat')]).toEqual([
'repeat',
'repeat-1',
'repeat-2'
])
})
it('can reset duplicate state between render passes', () => {
const slugger = new MarkdownHeadingSlugger()
expect(slugger.slug('Repeat')).toBe('repeat')
expect(slugger.slug('Repeat')).toBe('repeat-1')
slugger.reset()
expect(slugger.slug('Repeat')).toBe('repeat')
})
})
@@ -1,28 +1 @@
// Match github-slugger@2.0.0 / rehype-slug@6.0.0 anchors without adding a runtime dependency.
const markdownSlugPunctuationPattern = /[^\p{L}\p{M}\p{N} _-]/gu
export class MarkdownHeadingSlugger {
private readonly occurrences = new Map<string, number>()
reset(): void {
this.occurrences.clear()
}
slug(value: string): string {
const baseSlug = slugMarkdownHeading(value)
let nextSlug = baseSlug
while (this.occurrences.has(nextSlug)) {
const nextCount = (this.occurrences.get(baseSlug) ?? 0) + 1
this.occurrences.set(baseSlug, nextCount)
nextSlug = `${baseSlug}-${nextCount}`
}
this.occurrences.set(nextSlug, 0)
return nextSlug
}
}
export function slugMarkdownHeading(value: string): string {
return value.toLowerCase().replace(markdownSlugPunctuationPattern, '').replace(/ /g, '-')
}
export { default as MarkdownHeadingSlugger, slug as slugMarkdownHeading } from 'github-slugger'