Skip to content
Merged
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
33 changes: 33 additions & 0 deletions packages/session-ui/component-tests/markdown.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,39 @@ story("keeps link icons on the same line as the start of their label", async ({
expect(result.long).toBeGreaterThan(1)
})

story("scrolls fragment links to their heading inside the document's scroller", async ({ page }) => {
await page.evaluate(async (fixture) => {
const { mountMarkdown } = await import(fixture)
const filler = Array.from({ length: 40 }, (_, index) => `Paragraph ${index}`).join("\n\n")
await mountMarkdown({
text: `See [Host services](#host-services) and [the second usage](#usage-1).\n\n## Usage\n\n${filler}\n\n## Host services\n\n${filler}\n\n## Usage\n\n${filler}`,
})
const host = document.querySelector<HTMLElement>('[data-testid="markdown-fixture"]')!
host.style.height = "240px"
host.style.overflowY = "auto"
}, fixture)
const host = page.getByTestId("markdown-fixture")
const markdown = host.locator('[data-component="markdown"]')
await expect(markdown).toHaveAttribute("data-markdown-ready", "")
const url = page.url()

const offset = (index: number) =>
host.evaluate(
(host, index) =>
host.querySelectorAll("h2")[index]!.getBoundingClientRect().top - host.getBoundingClientRect().top,
index,
)

await expect(markdown.getByRole("link", { name: "Host services" })).not.toHaveAttribute("target", "_blank")
await markdown.getByRole("link", { name: "Host services" }).click()
await expect.poll(() => offset(1)).toBeCloseTo(0, 0)

await host.evaluate((host) => host.scrollTo({ top: 0 }))
await markdown.getByRole("link", { name: "the second usage" }).click()
await expect.poll(() => offset(2)).toBeCloseTo(0, 0)
expect(page.url()).toBe(url)
})

async function resolvedColor(page: Page, token: string) {
return page.evaluate((token) => {
const probe = document.createElement("span")
Expand Down
35 changes: 35 additions & 0 deletions packages/session-ui/src/components/markdown-fragment-links.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { GlobalRegistrator } from "@happy-dom/global-registrator"
import { afterAll, beforeAll, expect, test } from "bun:test"
import { fragmentTarget, headingSlug } from "./markdown-fragment-links"

beforeAll(() => GlobalRegistrator.register())

afterAll(() => GlobalRegistrator.unregister())

test("slugs headings the way GitHub anchors them", () => {
expect(headingSlug("Host services")).toBe("host-services")
expect(headingSlug("What's `node:*`?")).toBe("whats-node")
expect(headingSlug("API & SDK: v2.0")).toBe("api--sdk-v20")
expect(headingSlug("snake_case and-hyphens")).toBe("snake_case-and-hyphens")
expect(headingSlug("Überblick 概要")).toBe("überblick-概要")
})

test("finds headings by slug, numbering repeats, then raw HTML ids and names", () => {
const root = document.createElement("div")
root.innerHTML = [
"<h2>Host services</h2>",
"<h3>Usage</h3>",
"<h3>Usage</h3>",
'<p id="user-content-custom">custom</p>',
'<a name="user-content-legacy"></a>',
].join("")
const headings = root.querySelectorAll("h3")

expect(fragmentTarget(root, "host-services")?.textContent).toBe("Host services")
expect(fragmentTarget(root, "Host-Services")?.textContent).toBe("Host services")
expect(fragmentTarget(root, "usage")).toBe(headings[0])
expect(fragmentTarget(root, "usage-1")).toBe(headings[1])
expect(fragmentTarget(root, "custom")?.textContent).toBe("custom")
expect(fragmentTarget(root, "legacy")?.getAttribute("name")).toBe("user-content-legacy")
expect(fragmentTarget(root, "missing")).toBeUndefined()
})
74 changes: 74 additions & 0 deletions packages/session-ui/src/components/markdown-fragment-links.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/** A heading's anchor as GitHub writes it: lowercased, punctuation dropped, spaces as hyphens. */
export function headingSlug(text: string) {
return text
.trim()
.toLowerCase()
.replace(/[^\p{L}\p{M}\p{N}\p{Pc}\- ]/gu, "")
.replaceAll(" ", "-")
}

/**
* The element a `#fragment` link names in this document: a heading by its slug, where repeated headings take `-1`,
* `-2` and so on, or an element whose raw HTML `id` or `name` it is.
*/
export function fragmentTarget(root: HTMLElement, fragment: string) {
const slug = fragment.toLowerCase()
const seen = new Map<string, number>()

const heading = Array.from(root.querySelectorAll<HTMLElement>("h1, h2, h3, h4, h5, h6")).find((heading) => {
const base = headingSlug(heading.textContent ?? "")
const count = seen.get(base) ?? 0
seen.set(base, count + 1)

return (count === 0 ? base : `${base}-${count}`) === slug
})

if (heading) return heading
// Sanitizing prefixes raw HTML `id` and `name` so they cannot clobber the app's own globals.
const named = CSS.escape(`user-content-${fragment}`)

return root.querySelector<HTMLElement>(`[id="${named}"], [name="${named}"]`) ?? undefined
}

/** Fragment links scroll to their target in this document instead of navigating the app. */
export function setupFragmentLinks(root: HTMLElement) {
const click = (event: MouseEvent) => {
if (event.defaultPrevented || event.button !== 0 || !(event.target instanceof Element)) return
const link = event.target.closest('a[href^="#"]')

if (!(link instanceof HTMLAnchorElement) || !root.contains(link)) return
event.preventDefault()
const fragment = decodeFragment(link.getAttribute("href")!.slice(1))
const target = fragment ? fragmentTarget(root, fragment) : undefined

if (!target) return
const scroller = scrollParent(root)

// `scrollIntoView` would also scroll clipped ancestors and shift the surrounding layout.
if (!scroller) return target.scrollIntoView({ block: "start" })
scroller.scrollTo({
top: scroller.scrollTop + target.getBoundingClientRect().top - scroller.getBoundingClientRect().top,
})
}

root.addEventListener("click", click)

return () => root.removeEventListener("click", click)
}

function scrollParent(element: HTMLElement) {
let parent = element.parentElement

while (parent) {
if (/auto|scroll/.test(getComputedStyle(parent).overflowY)) return parent
parent = parent.parentElement
}
}

function decodeFragment(value: string) {
try {
return decodeURIComponent(value)
} catch {
return value
}
}
6 changes: 6 additions & 0 deletions packages/session-ui/src/components/markdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
} from "../context/markdown"
import { createMarkdownImages } from "./markdown-image"
import { createImagePreview } from "./image-preview"
import { setupFragmentLinks } from "./markdown-fragment-links"
import { markSessionLinks, setupSessionLinks } from "./markdown-session-links"
import { markdownClipboardHtml } from "./markdown-clipboard"

Expand Down Expand Up @@ -805,6 +806,7 @@ export function Markdown(
let linkCleanup: (() => void) | undefined
let faviconCleanup: (() => void) | undefined
let sessionLinkCleanup: (() => void) | undefined
let fragmentLinkCleanup: (() => void) | undefined
let readImage: ReadMarkdownImage | undefined
let images: ReturnType<typeof createMarkdownImages> | undefined

Expand Down Expand Up @@ -895,6 +897,8 @@ export function Markdown(

if (!sessionLinkCleanup) sessionLinkCleanup = setupSessionLinks(container, () => markdown?.openSession)

if (!fragmentLinkCleanup) fragmentLinkCleanup = setupFragmentLinks(container)

if (!faviconCleanup) faviconCleanup = setupExternalLinkFavicons(container)
container.toggleAttribute("data-local-links", !!markdown?.openLocalFile)

Expand Down Expand Up @@ -924,6 +928,8 @@ export function Markdown(

if (sessionLinkCleanup) sessionLinkCleanup()

if (fragmentLinkCleanup) fragmentLinkCleanup()

if (faviconCleanup) faviconCleanup()
const container = root()

Expand Down
6 changes: 5 additions & 1 deletion packages/ui/src/context/marked-base.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,12 @@ export function createMarkdownBase() {
renderer: {
link({ href, title, tokens }) {
const titleAttr = title ? ` title="${title}"` : ""
const text = this.parser.parseInline(tokens)

return `<a href="${href}"${titleAttr} class="external-link" target="_blank" rel="noopener noreferrer">${this.parser.parseInline(tokens)}</a>`
// A fragment link points within the document, so it stays in the page.
if (href.startsWith("#")) return `<a href="${href}"${titleAttr}>${text}</a>`

return `<a href="${href}"${titleAttr} class="external-link" target="_blank" rel="noopener noreferrer">${text}</a>`
},
},
})
Expand Down
8 changes: 8 additions & 0 deletions packages/ui/src/context/marked-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ test.each([
expect(parseSmallMarkdown(markdown)).toBe(expected)
})

test("keeps fragment links in the page", async () => {
const markdown = "See [Host services](#host-services)"
const expected = '<p>See <a href="#host-services">Host services</a></p>\n'

expect(await parser.parse(markdown)).toBe(expected)
expect(parseSmallMarkdown(markdown)).toBe(expected)
})

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
Loading