diff --git a/package.json b/package.json index 943aa1e5657..88dc1f35e13 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a54699ff06d..d0e1d380e16 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/renderer/src/components/editor/markdown-anchor-scroll.test.ts b/src/renderer/src/components/editor/markdown-anchor-scroll.test.ts new file mode 100644 index 00000000000..c0031b72448 --- /dev/null +++ b/src/renderer/src/components/editor/markdown-anchor-scroll.test.ts @@ -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)) + ) + } + } +) diff --git a/src/renderer/src/components/editor/markdown-heading-slug.test.ts b/src/renderer/src/components/editor/markdown-heading-slug.test.ts deleted file mode 100644 index 18050f2c765..00000000000 --- a/src/renderer/src/components/editor/markdown-heading-slug.test.ts +++ /dev/null @@ -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') - }) -}) diff --git a/src/renderer/src/components/editor/markdown-heading-slug.ts b/src/renderer/src/components/editor/markdown-heading-slug.ts index 095d3e8daee..8f9dfe943b2 100644 --- a/src/renderer/src/components/editor/markdown-heading-slug.ts +++ b/src/renderer/src/components/editor/markdown-heading-slug.ts @@ -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() - - 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'