diff --git a/packages/ui/src/context/marked-parser.test.ts b/packages/ui/src/context/marked-parser.test.ts index 3c79e0afa712..f1e9597065ae 100644 --- a/packages/ui/src/context/marked-parser.test.ts +++ b/packages/ui/src/context/marked-parser.test.ts @@ -9,6 +9,18 @@ test("renders links with application attributes", async () => { ) }) +test("keeps in-document links in the current document", async () => { + expect(await parser.parse("[Install](#install)")).toBe( + '

Install

\n', + ) +}) + +test("adds unique ids to headings", async () => { + expect(await parser.parse("# Install\n\n## Install\n\n## API & Usage\n\n## Install")).toBe( + '

Install

\n

Install

\n

API & Usage

\n

Install

\n', + ) +}) + test("renders inline and block math", async () => { expect(await parser.parse("\\(x^2\\)")).toContain('') expect(await parser.parse("$$\nx^2\n$$\n")).toContain('') diff --git a/packages/ui/src/context/marked-parser.tsx b/packages/ui/src/context/marked-parser.tsx index 71e48351ca53..d4702edcc6db 100644 --- a/packages/ui/src/context/marked-parser.tsx +++ b/packages/ui/src/context/marked-parser.tsx @@ -3,12 +3,25 @@ import { Marked, type MarkedExtension, type Tokens } from "marked" import markedShiki from "marked-shiki" export function createMarkdownParser(highlight: (code: string, language: string) => string | Promise) { + const headingCounts = new WeakMap>() + return new Marked( { renderer: { - link({ href, title, text }) { + heading({ tokens, depth }) { + const text = this.parser.parseInline(tokens, this.parser.textRenderer) + const base = slugifyHeading(text) + const counts = headingCounts.get(this.parser) ?? new Map() + const count = counts.get(base) ?? 0 + counts.set(base, count + 1) + headingCounts.set(this.parser, counts) + const id = count === 0 ? base : `${base}-${count}` + return `${this.parser.parseInline(tokens)}\n` + }, + link({ href, title, tokens }) { const titleAttr = title ? ` title="${title}"` : "" - return `${text}` + const externalAttrs = href.trim().startsWith("#") ? "" : ' target="_blank" rel="noopener noreferrer"' + return `${this.parser.parseInline(tokens)}` }, }, }, @@ -17,6 +30,16 @@ export function createMarkdownParser(highlight: (code: string, language: string) ) } +function slugifyHeading(text: string) { + return ( + text + .toLowerCase() + .trim() + .replace(/[^\p{L}\p{N}\s-]/gu, "") + .replace(/\s+/g, "-") || "section" + ) +} + const inlineMathRegex = /^\\\(((?:\\.|[^\\\n])*?)\\\)/ const blockMathRegex = /^\$\$\n([\s\S]+?)\n\$\$(?:\n|$)/