Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
12 changes: 12 additions & 0 deletions packages/ui/src/context/marked-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
'<p><a href="#install" class="external-link">Install</a></p>\n',
)
})

test("adds unique ids to headings", async () => {
expect(await parser.parse("# Install\n\n## Install\n\n## API & Usage\n\n## Install")).toBe(
'<h1 id="install">Install</h1>\n<h2 id="install-1">Install</h2>\n<h2 id="api-usage">API &amp; Usage</h2>\n<h2 id="install-2">Install</h2>\n',
)
})

test("renders inline and block math", async () => {
expect(await parser.parse("\\(x^2\\)")).toContain('<span class="katex">')
expect(await parser.parse("$$\nx^2\n$$\n")).toContain('<span class="katex-display">')
Expand Down
27 changes: 25 additions & 2 deletions packages/ui/src/context/marked-parser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>) {
const headingCounts = new WeakMap<object, Map<string, number>>()

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<string, number>()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The -1, -2 numbering only applies within one parse() call. While a reply streams, markdown-stream.ts sends each finished top-level block to the worker separately. So two ## Install headings in the same reply each get id="install". Every message in a timeline also reuses the same ids, so #install always jumps to the first matching heading on the page, even if it's in a different message. In-document links probably need to be resolved within the message's own container, not left to the browser's hash navigation.

In the app, this HTML goes through DOMPurify with SANITIZE_NAMED_PROPS: true (session-ui/src/components/markdown-cache.tsx). That renames id="install" to id="user-content-install" but leaves href="#install" as it is. I checked this with the PR's output: after sanitizing, the link points at an id that no longer exists, so clicking it does nothing useful. The # link needs to match the prefixed id, or a click handler needs to map one to the other.

const count = counts.get(base) ?? 0
counts.set(base, count + 1)
headingCounts.set(this.parser, counts)
const id = count === 0 ? base : `${base}-${count}`
return `<h${depth} id="${id}">${this.parser.parseInline(tokens)}</h${depth}>\n`
},
link({ href, title, tokens }) {
const titleAttr = title ? ` title="${title}"` : ""
return `<a href="${href}"${titleAttr} class="external-link" target="_blank" rel="noopener noreferrer">${text}</a>`
const externalAttrs = href.trim().startsWith("#") ? "" : ' target="_blank" rel="noopener noreferrer"'
return `<a href="${href}"${titleAttr} class="external-link"${externalAttrs}>${this.parser.parseInline(tokens)}</a>`
},
},
},
Expand All @@ -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|$)/

Expand Down
Loading