fix(desktop): open markdown preview links inside the app

Electron denies every target=_blank window, so a web link in a previewed
.md file looked clickable and did nothing; a #fragment link rode the
HashRouter and changed the app route instead of scrolling the note; a
relative link to a sibling note never opened it.

Links now take the same doors chat links take: web links render through
ExternalLink (in-app browser, Cmd/Ctrl for native), file links are wrapped
in the existing #preview/ hash before Streamdown's hardener sees them and
resolved against the note's directory by normalizeOrLocalPreviewTarget,
and headings get GitHub-style ids from a rehype plugin so a TOC click
scrolls within the pane. Slugs and lookups are NFC-normalized so a TOC
written on another editor still finds its heading.

Fixes #81055
Co-authored-by: xxxigm <tuancanhnguyen706@gmail.com>
This commit is contained in:
Austin Pickett
2026-09-24 13:24:23 -04:00
parent 7fd7f33cc2
commit 6ee0ae4d5e
4 changed files with 450 additions and 29 deletions

View File

@@ -1,5 +1,8 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { setAlwaysExternalLinks } from '@/store/external-links'
import { $previewTabs } from '@/store/preview'
import { MarkdownPreview } from './preview-file'
@@ -42,12 +45,75 @@ describe('MarkdownPreview', () => {
expect(img?.getAttribute('src')).toBe('https://example.com/chart.png')
})
it('renders external links to open in a new tab safely', () => {
const { container } = render(<MarkdownPreview text={'[docs](https://example.com/docs)'} />)
it('opens an https link in the in-app browser instead of a blank window', async () => {
$previewTabs.set([])
setAlwaysExternalLinks(false)
render(<MarkdownPreview text={'[docs](https://example.com/docs)'} />)
const anchor = container.querySelector('a')
expect(anchor?.getAttribute('href')).toBe('https://example.com/docs')
expect(anchor?.getAttribute('target')).toBe('_blank')
expect(anchor?.getAttribute('rel')).toBe('noopener noreferrer')
const anchor = screen.getByRole('link', { name: 'docs' })
const click = new MouseEvent('click', { bubbles: true, cancelable: true })
anchor.dispatchEvent(click)
expect(click.defaultPrevented).toBe(true)
await waitFor(() => {
expect(
$previewTabs.get().some(tab => tab.target.kind === 'url' && tab.target.url === 'https://example.com/docs')
).toBe(true)
})
})
it('scrolls a table-of-contents link to its heading without changing the app route', () => {
$previewTabs.set([])
const hash = window.location.hash
const scroll = vi.fn()
HTMLElement.prototype.scrollIntoView = scroll
const { container } = render(
<MarkdownPreview text={'## Managed Tiered KV Cache\n\n[jump](#managed-tiered-kv-cache)'} />
)
const heading = container.querySelector('#managed-tiered-kv-cache')
expect(heading?.tagName).toBe('H2')
fireEvent.click(screen.getByRole('link', { name: 'jump' }))
expect(scroll).toHaveBeenCalledTimes(1)
expect(window.location.hash).toBe(hash)
expect($previewTabs.get()).toEqual([])
})
it('scrolls to a heading whose TOC link uses a different Unicode normalization', () => {
const scroll = vi.fn()
HTMLElement.prototype.scrollIntoView = scroll
render(<MarkdownPreview text={'## \u30D3\n\n[jump](#\u30D2\u3099)'} />)
fireEvent.click(screen.getByRole('link', { name: 'jump' }))
expect(scroll).toHaveBeenCalledTimes(1)
})
it('opens a relative markdown link as the sibling file', async () => {
$previewTabs.set([])
render(<MarkdownPreview filePath="/vault/notes/index.md" text={'[next](../other.md)'} />)
fireEvent.click(screen.getByRole('link', { name: 'next' }))
await waitFor(() => {
expect($previewTabs.get().some(tab => tab.target.kind === 'file' && tab.target.path === '/vault/other.md')).toBe(
true
)
})
})
it('does not navigate a scriptable link', () => {
$previewTabs.set([])
const { container } = render(<MarkdownPreview text={'[bad](javascript:alert(1))'} />)
expect(container.querySelector('a[href^="javascript:"]')).toBeNull()
expect($previewTabs.get()).toEqual([])
})
})

View File

@@ -7,8 +7,8 @@ import type {
MouseEvent as ReactMouseEvent,
ReactNode
} from 'react'
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Streamdown } from 'streamdown'
import { createContext, Fragment, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
import { defaultRehypePlugins, defaultRemarkPlugins, Streamdown } from 'streamdown'
import { requestComposerFocus, requestComposerInsertRefs } from '@/app/chat/composer/focus'
import { droppedFileInlineRef } from '@/app/chat/composer/inline-refs'
@@ -29,13 +29,23 @@ import {
readDesktopFileText,
writeDesktopFileText
} from '@/lib/desktop-fs'
import { ExternalLink } from '@/lib/external-link'
import { Check, Pencil, X } from '@/lib/icons'
import { createMemoizedMathPlugin } from '@/lib/katex-memo'
import { isComposerChord } from '@/lib/keybinds/chords'
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
import { shikiLanguageForFilename } from '@/lib/markdown-code'
import { normalizeFilePreviewMath } from '@/lib/markdown-preprocess'
import {
decodeHashFragment,
noteDirectory,
rehypePreviewHeadingIds,
remarkPreviewFileLinks,
scrollPreviewHeading
} from '@/lib/preview-markdown-links'
import { previewTargetFromMarkdownHref } from '@/lib/preview-targets'
import { cn } from '@/lib/utils'
import type { PreviewTarget } from '@/store/preview'
import { openPreview, type PreviewTarget } from '@/store/preview'
import { setPreviewDirty } from '@/store/preview-edit'
import { $connection, $currentCwd } from '@/store/session'
import { notifyWorkspaceChanged } from '@/store/workspace-events'
@@ -379,16 +389,61 @@ function MarkdownImage({ alt, src, ...rest }: ComponentProps<'img'>) {
)
}
function MarkdownLink({ children, className, href, ...rest }: ComponentProps<'a'>) {
const isExternal = /^https?:\/\//i.test(href || '')
const PreviewNoteContext = createContext<string | undefined>(undefined)
const MARKDOWN_LINK_CLASS = 'text-foreground underline underline-offset-2 hover:text-primary'
async function openLinkedNote(target: string, filePath?: string) {
const preview = await normalizeOrLocalPreviewTarget(target, noteDirectory(filePath))
if (preview) {
openPreview(preview)
}
}
// Same doors as chat links: web → ExternalLink (in-app browser, Cmd/Ctrl for
// native; a blank window is denied by Electron), `#preview/…` → the preview
// rail, `#fragment` → scroll this note (the hash must never reach the router).
function MarkdownLink({ children, className, href, node: _node, ...rest }: ComponentProps<'a'> & { node?: unknown }) {
const filePath = useContext(PreviewNoteContext)
const raw = href?.trim() ?? ''
const fileTarget = previewTargetFromMarkdownHref(raw)
const linkClass = cn(MARKDOWN_LINK_CLASS, className)
if (!raw) {
return <span className={linkClass}>{children}</span>
}
if (!fileTarget && !raw.startsWith('#')) {
return (
<ExternalLink className={linkClass} href={raw}>
{children}
</ExternalLink>
)
}
return (
<a
className={cn('text-foreground underline underline-offset-2 hover:text-primary', className)}
href={href}
rel={isExternal ? 'noopener noreferrer' : undefined}
target={isExternal ? '_blank' : undefined}
{...rest}
className={linkClass}
href={raw}
onAuxClick={event => void event.preventDefault()}
onClick={event => {
event.preventDefault()
event.stopPropagation()
if (fileTarget) {
void openLinkedNote(fileTarget, filePath)
return
}
const root = event.currentTarget.closest('[data-preview-markdown]')
if (root) {
scrollPreviewHeading(root, decodeHashFragment(raw))
}
}}
>
{children}
</a>
@@ -416,21 +471,33 @@ const MARKDOWN_COMPONENTS = {
a: MarkdownLink
}
export function MarkdownPreview({ text }: { text: string }) {
// Passing either plugin list REPLACES Streamdown's defaults, so both spread them.
const PREVIEW_REMARK_PLUGINS = [...Object.values(defaultRemarkPlugins), remarkPreviewFileLinks]
const PREVIEW_REHYPE_PLUGINS = [...Object.values(defaultRehypePlugins), rehypePreviewHeadingIds]
export function MarkdownPreview({ filePath, text }: { filePath?: string; text: string }) {
const mathText = useMemo(() => normalizeFilePreviewMath(text), [text])
return (
<div className="preview-markdown mx-auto max-w-3xl px-4 py-3 text-sm text-foreground" data-selectable-text="true">
<Streamdown
components={MARKDOWN_COMPONENTS}
controls={false}
mode="static"
parseIncompleteMarkdown={false}
plugins={{ math: previewMathPlugin }}
<PreviewNoteContext.Provider value={filePath}>
<div
className="preview-markdown mx-auto max-w-3xl px-4 py-3 text-sm text-foreground"
data-preview-markdown=""
data-selectable-text="true"
>
{mathText}
</Streamdown>
</div>
<Streamdown
components={MARKDOWN_COMPONENTS}
controls={false}
mode="static"
parseIncompleteMarkdown={false}
plugins={{ math: previewMathPlugin }}
rehypePlugins={PREVIEW_REHYPE_PLUGINS}
remarkPlugins={PREVIEW_REMARK_PLUGINS}
>
{mathText}
</Streamdown>
</div>
</PreviewNoteContext.Provider>
)
}
@@ -1151,7 +1218,7 @@ export function LocalFilePreview({
/>
<div className="min-h-0 flex-1 overflow-auto">
{mode === 'rendered' ? (
<MarkdownPreview text={state.text} />
<MarkdownPreview filePath={filePath} text={state.text} />
) : mode === 'diff' ? (
<FileDiffPanel
className="mx-0 mb-0 h-full max-h-none"

View File

@@ -0,0 +1,114 @@
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest'
import {
findPreviewHeading,
isPreviewFileHref,
noteDirectory,
previewHeadingSlug,
rehypePreviewHeadingIds,
remarkPreviewFileLinks
} from './preview-markdown-links'
describe('previewHeadingSlug', () => {
it('matches the GitHub slug a generated table of contents links to', () => {
expect(previewHeadingSlug('Managed Tiered KV Cache')).toBe('managed-tiered-kv-cache')
expect(previewHeadingSlug(' Hello, World! ')).toBe('hello-world')
expect(previewHeadingSlug('a -- b')).toBe('a-b')
})
it('slugs composed and decomposed spellings alike', () => {
const composed = '\u30D3'
const decomposed = '\u30D2\u3099'
expect(previewHeadingSlug(decomposed)).toBe(previewHeadingSlug(composed))
})
})
describe('rehypePreviewHeadingIds', () => {
const heading = (tagName: string, text: string) => ({
children: [{ type: 'text', value: text }],
properties: {},
tagName,
type: 'element'
})
it('stamps ids and numbers duplicates', () => {
const tree = {
children: [heading('h2', 'Setup'), heading('h3', 'Setup'), heading('h2', '!!!'), heading('p', 'Setup')],
type: 'root'
}
rehypePreviewHeadingIds()(tree)
const [first, second, empty, paragraph] = tree.children
expect(first.properties).toEqual({ id: 'setup' })
expect(second.properties).toEqual({ id: 'setup-1' })
expect(empty.properties).toEqual({})
expect(paragraph.properties).toEqual({})
})
})
describe('remarkPreviewFileLinks', () => {
const link = (url: string) => ({ children: [], type: 'link', url })
it('wraps file links in the preview hash and leaves web and anchor links alone', () => {
const tree = {
children: [
link('../other.md'),
link('/abs/note.md'),
link('file:///tmp/a.md'),
link('https://example.com'),
link('mailto:a@b.c'),
link('#heading'),
link('www.example.com'),
link('//cdn.example.com/x')
],
type: 'root'
}
remarkPreviewFileLinks()(tree)
expect(tree.children.map(node => node.url)).toEqual([
`#preview/${encodeURIComponent('../other.md')}`,
`#preview/${encodeURIComponent('/abs/note.md')}`,
`#preview/${encodeURIComponent('file:///tmp/a.md')}`,
'https://example.com',
'mailto:a@b.c',
'#heading',
'www.example.com',
'//cdn.example.com/x'
])
})
it('classifies hrefs', () => {
expect(isPreviewFileHref('notes/a.md')).toBe(true)
expect(isPreviewFileHref('C:\\notes\\a.md')).toBe(true)
expect(isPreviewFileHref('javascript:alert(1)')).toBe(false)
expect(isPreviewFileHref('')).toBe(false)
})
})
describe('noteDirectory', () => {
it('returns the directory the note lives in', () => {
expect(noteDirectory('/vault/notes/index.md')).toBe('/vault/notes')
expect(noteDirectory('/index.md')).toBe('/')
expect(noteDirectory('C:\\vault\\index.md')).toBe('C:\\vault')
expect(noteDirectory('index.md')).toBeUndefined()
expect(noteDirectory(undefined)).toBeUndefined()
})
})
describe('findPreviewHeading', () => {
it('finds a heading by id, by re-slugged text, and across Unicode normalization', () => {
const root = document.createElement('div')
root.innerHTML = `<h2 id="setup">Setup</h2><h2 id="${previewHeadingSlug('\u30D3')}">ビ</h2>`
expect(findPreviewHeading(root, 'setup')?.textContent).toBe('Setup')
expect(findPreviewHeading(root, 'Setup')?.textContent).toBe('Setup')
expect(findPreviewHeading(root, encodeURIComponent('\u30D2\u3099'))?.textContent).toBe('ビ')
expect(findPreviewHeading(root, 'missing')).toBeNull()
})
})

View File

@@ -0,0 +1,174 @@
import { previewMarkdownHref } from '@/lib/preview-targets'
/**
* Where a click in the desktop markdown preview goes.
*
* Electron denies every `target=_blank` / `window.open` (GHSA-9f4c-93c8-jc8g),
* so a web link has to travel through `ExternalLink` like chat links do. A
* `#fragment` href is the app's HashRouter, so a table-of-contents click must
* scroll the note instead of changing the route. A path to a sibling note is
* protected from Streamdown's URL hardening by the same `#preview/…` door
* chat transcripts use, and resolved against the note's directory on click.
*/
export type PreviewMarkdownLink =
| { kind: 'external'; href: string }
| { kind: 'file'; target: string }
| { kind: 'hash'; fragment: string }
| { kind: 'inert' }
const SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i
const HEADING_SELECTOR = 'h1, h2, h3, h4, h5, h6'
const NOTE_DIRECTORY_RE = /^(.*)[\\/][^\\/]*$/
/** Composed and decomposed spellings look identical on screen and must slug
* alike, or a TOC written on another editor misses its own heading (#81055). */
export function previewHeadingSlug(text: string): string {
return text
.normalize('NFC')
.trim()
.toLowerCase()
.replace(/[^\p{L}\p{N}\p{M}\-_ ]+/gu, '')
.replace(/ +/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
}
function nextHeadingId(text: string, counts: Map<string, number>): string | null {
const base = previewHeadingSlug(text)
if (!base) {
return null
}
const seen = counts.get(base) ?? 0
counts.set(base, seen + 1)
return seen === 0 ? base : `${base}-${seen}`
}
interface HastNode {
children?: HastNode[]
properties?: Record<string, unknown>
tagName?: string
type: string
value?: string
}
function hastText(node: HastNode): string {
if (node.type === 'text') {
return node.value ?? ''
}
return (node.children ?? []).map(hastText).join('')
}
/** Rehype attacher: GitHub-style ids on headings, duplicates numbered
* `slug`, `slug-1`, … the way generated TOCs expect. */
export function rehypePreviewHeadingIds() {
return (tree: HastNode) => {
const counts = new Map<string, number>()
const visit = (node: HastNode) => {
if (node.type === 'element' && node.tagName && /^h[1-6]$/.test(node.tagName)) {
const id = nextHeadingId(hastText(node), counts)
if (id) {
node.properties = { ...node.properties, id }
}
}
for (const child of node.children ?? []) {
visit(child)
}
}
visit(tree)
}
}
interface MdastNode {
children?: MdastNode[]
type: string
url?: string
}
/** A link that names a file rather than a web resource or an in-note anchor. */
export function isPreviewFileHref(href: string): boolean {
const raw = href.trim()
if (!raw || raw.startsWith('#') || raw.startsWith('//') || /^www\./i.test(raw)) {
return false
}
return !SCHEME_RE.test(raw) || /^file:/i.test(raw) || /^[A-Za-z]:[\\/]/.test(raw)
}
/** Remark attacher. Streamdown's hardener rewrites `../note.md` against a
* dummy origin and drops `file:` outright, so file links are wrapped in the
* `#preview/…` hash before that pass and unwrapped by the link component. */
export function remarkPreviewFileLinks() {
return (tree: MdastNode) => {
const visit = (node: MdastNode) => {
if (node.type === 'link' && node.url && isPreviewFileHref(node.url)) {
node.url = previewMarkdownHref(node.url.trim())
}
for (const child of node.children ?? []) {
visit(child)
}
}
visit(tree)
}
}
export function decodeHashFragment(href: string): string {
const raw = href.replace(/^#/, '')
try {
return decodeURIComponent(raw)
} catch {
return raw
}
}
/** Directory a note's relative links resolve against. */
export function noteDirectory(filePath?: string): string | undefined {
const match = filePath?.match(NOTE_DIRECTORY_RE)
if (!match) {
return undefined
}
return match[1] || '/'
}
export function findPreviewHeading(root: ParentNode, fragment: string): HTMLElement | null {
const decoded = decodeHashFragment(fragment).normalize('NFC')
if (!decoded) {
return null
}
const headings = [...root.querySelectorAll<HTMLElement>(HEADING_SELECTOR)]
const slugged = previewHeadingSlug(decoded)
return (
headings.find(heading => heading.id === decoded) ??
headings.find(heading => heading.id === slugged || heading.id.toLowerCase() === decoded.toLowerCase()) ??
null
)
}
export function scrollPreviewHeading(root: ParentNode, fragment: string): boolean {
const heading = findPreviewHeading(root, fragment)
if (!heading) {
return false
}
heading.scrollIntoView({ block: 'start' })
return true
}