Repository navigation
Windows 文件链接把反斜杠当转义:点击后打开错误路径 #11164
Replies: 3 comments
|
补上可以直接应用的补丁:基于 From 99bde597ebdda19822e1fe369b2ee543be9b077e Mon Sep 17 00:00:00 2001
From: candy-Tong <563378816@qq.com>
Date: Tue, 27 Oct 2026 13:11:28 +0800
Subject: [PATCH] fix(client): keep the separator in Windows file-link
destinations
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Markdown reads a backslash before ASCII punctuation as an escape, so a link
destination written as a native Windows path lost the separator before a
dotted segment: `[AGENTS.md](C:\Users\黄\.dsh\AGENTS.md)` opened
`C:\Users\黄.dsh\AGENTS.md`.
The parse pass now recovers the authored destination from the source for a
drive-letter link whose parsed URL is exactly that destination's unescaped
form. It covers link definitions, and it skips `\\`, `\(`, and `\)`, where
the author escaped a character on purpose, so those keep the parser's path.
---
packages/client/ui-primitives/README.md | 3 +-
.../ui-primitives/src/markdown/parse.ts | 11 +-
.../src/markdown/windows-file-link-syntax.ts | 127 ++++++++++++++
...arkdown-windows-file-links.client.spec.tsx | 157 ++++++++++++++++++
4 files changed, 293 insertions(+), 5 deletions(-)
create mode 100644 packages/client/ui-primitives/src/markdown/windows-file-link-syntax.ts
create mode 100644 packages/client/ui-primitives/tests/markdown-windows-file-links.client.spec.tsx
diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md
index 380ce75142..920b13e060 100644
--- a/packages/client/ui-primitives/README.md
+++ b/packages/client/ui-primitives/README.md
@@ -108,7 +108,7 @@ The catalog above lists what each export is for; this section covers the behavio
The pure Markdown helper `formatQuoteText` normalizes CRLF and CR line endings, then prefixes each line with `> `.
-The nearest `MarkdownDelegateProvider` supplies optional `openExternalLink` and `openFile` navigation callbacks. Nested providers replace the enclosing capabilities, and callback changes reach already-rendered links without rebuilding Markdown. Its `openFile` makes local Markdown links clickable after settlement. Absolute and workspace-relative paths support percent escapes and `#L24` / `#L24-L30` fragments; ranges open at their first line. Literal `?` and `#` in filenames must be percent-encoded. The tooltip uses the decoded path and supplies the accessible name when the label is empty. The callback receives the decoded path and optional line, while the renderer preserves the label and displays a file icon. Without a callback, local links remain text. URL schemes, queries, unsupported fragments, and malformed destinations never reach the file opener.
+The nearest `MarkdownDelegateProvider` supplies optional `openExternalLink` and `openFile` navigation callbacks. Nested providers replace the enclosing capabilities, and callback changes reach already-rendered links without rebuilding Markdown. Its `openFile` makes local Markdown links clickable after settlement. Absolute and workspace-relative paths support percent escapes and `#L24` / `#L24-L30` fragments; ranges open at their first line. Literal `?` and `#` in filenames must be percent-encoded. A drive-letter destination — inline or through a link definition — reads a backslash before ordinary punctuation as a separator, so a native Windows path such as `C:\Users\me\.dsh\AGENTS.md` opens as written instead of losing that separator; `\\`, `\(`, and `\)` keep the parser's reading instead. The tooltip uses the decoded path and supplies the accessible name when the label is empty. The callback receives the decoded path and optional line, while the renderer preserves the label and displays a file icon. Without a callback, local links remain text. URL schemes, queries, unsupported fragments, and malformed destinations never reach the file opener.
`MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. Strikethrough requires `~~`; a single `~`, as in `10~20`, stays literal. A surrounding `MarkdownDelegateProvider` receives sanitized HTTP(S) URLs from ordinary clicks; modified clicks and links outside a provider retain native external-anchor behavior. `omitHtml` renders raw HTML as nothing instead of literal text, for documents written for HTML renderers. When the owner passes a `pathImages` vocabulary, image destinations that are local media paths rewrite to displayable URLs on settled renders only (the same streaming gate as file mentions); without a vocabulary, local destinations remain inert alt text. Rewritten images accept HTTP(S), data, blob, and the Desktop `dsh-app://app/api/file`, `dsh-app://app/api/plugin-market/detail-file`, and `dsh-app://app/api/plugin-manager/detail-file` routes; authored Desktop URLs remain inert. A load or decode failure replaces the image with its authored alt text, or the original destination when alt is empty; `fileImages` additionally supplies a localized failure prefix. Changing the image source permits a fresh load. Each mounted image appends a `load` query parameter to a resolved Host `api/file` URL, because browsers reuse a same-URL image within one document despite `no-store`; a newly rendered image of an overwritten file therefore loads its current bytes, and every remount requests the file again; the Host file route redirects that request to a cacheable URL for the current file version. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax. `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `UserTextRun` renders uninterpreted text and `ReferenceChip` renders business-resolved labels. An explicit `onActivate` makes a chip a keyboard-accessible button; repeated pointer clicks and an existing text selection do not activate it, while keyboard activation remains available. Labels inherit their host’s wrapping policy. Recognition and JSON transforms belong to [ui-conversation](../ui-conversation/README.md#sent-user-text).
@@ -218,6 +218,7 @@ These limits define how the atoms behave at the edges; they are current package
- **Diff search is bounded, input processing is linear** — the edit-distance limit trades precise alignment for a coarse replacement on heavily changed fragments. Normalization, fallback rows, and copied output still scale with input size; the height cap limits visible rows, not those allocations.
- **Known-site marks are a fixed list** — only the named hosts resolve to their own mark, and every other external host keeps the globe; recognizing an arbitrary site would require fetching its icon over the network.
- **Streaming defers cross-boundary reference resolution** — a reference-style link or footnote whose definition sits on the other side of the incremental freeze boundary renders as literal text while the reply streams; the settled full parse at finalize resolves it.
+- **A drive-letter destination reads `\` as a separator** — recovery excludes `\\`, `\(`, and `\)`, where the author escaped a character on purpose, so a Windows path whose punctuation must be escaped is spelled by percent-encoding instead.
- **A long highlighted fence retains its complete token DOM** — streaming avoids re-parsing, re-tokenizing, and reconciling the completed prefix, but it does not discard old colors or virtualize token spans. Final DOM cardinality therefore still follows the fence's token count; nested/container fences and a pathological single long line remain on the general tail path.
- **Overlong lines stay uncolored** — a source line of 1,000 or more UTF-16 units renders as one plain run in every highlighted surface, because TextMate scanning cost grows superlinearly with line length and a single generated line can otherwise block the page for seconds. The grammar state skips that line, so a comment or template literal that opens or closes on it mis-colors the following lines. Highlighting still runs synchronously during render, so a file with thousands of ordinary lines costs proportionally more.
- **The Go struct-field rule is patched locally** — when the Go grammar loads, its repeated one-line struct-field rule is replaced with the rule [worlpaker/go-syntax#25](https://github.com/worlpaker/go-syntax/pull/25) merged, because the vendored rule backtracks exponentially on lines such as a brace-bearing comment after `struct {`. If a dependency upgrade removes that rule, loading the grammar throws and Go code renders as plain text; delete the patch once `@shikijs/langs` ships the upstream rule.
diff --git a/packages/client/ui-primitives/src/markdown/parse.ts b/packages/client/ui-primitives/src/markdown/parse.ts
index 6933ce389f..edb49c821b 100644
--- a/packages/client/ui-primitives/src/markdown/parse.ts
+++ b/packages/client/ui-primitives/src/markdown/parse.ts
@@ -10,6 +10,7 @@
import type { Root } from 'mdast'
import { recoverLocalImages } from './local-image-syntax.ts'
+import { recoverWindowsFileLinks } from './windows-file-link-syntax.ts'
import { fromMarkdown } from 'mdast-util-from-markdown'
import { gfmFromMarkdown } from 'mdast-util-gfm'
import { mathFromMarkdown } from 'mdast-util-math'
@@ -28,10 +29,11 @@ const gfmOptions = { singleTilde: false }
* @returns The mdast root.
*/
export function parseGfm(text: string): Root {
- return recoverLocalImages(fromMarkdown(text, {
+ const parsed = fromMarkdown(text, {
extensions: [gfm(gfmOptions), cjkFriendlyStrong()],
mdastExtensions: [gfmFromMarkdown()],
- }), text)
+ })
+ return recoverWindowsFileLinks(recoverLocalImages(parsed, text), text)
}
/**
@@ -41,11 +43,12 @@ export function parseGfm(text: string): Root {
* @returns The mdast root.
*/
export function parseGfmWithMath(text: string): Root {
- return recoverLocalImages(fromMarkdown(text, {
+ const parsed = fromMarkdown(text, {
// `mathCompatibility()` MUST stay last: micromark dispatches the extension
// registered last first, so its dollar flow takes precedence over `math()`
// and keeps the opening line's text as math instead of upstream's fence meta.
extensions: [gfm(gfmOptions), cjkFriendlyStrong(), math(), mathCompatibility()],
mdastExtensions: [gfmFromMarkdown(), mathFromMarkdown()],
- }), text)
+ })
+ return recoverWindowsFileLinks(recoverLocalImages(parsed, text), text)
}
diff --git a/packages/client/ui-primitives/src/markdown/windows-file-link-syntax.ts b/packages/client/ui-primitives/src/markdown/windows-file-link-syntax.ts
new file mode 100644
index 0000000000..715f7df9c0
--- /dev/null
+++ b/packages/client/ui-primitives/src/markdown/windows-file-link-syntax.ts
@@ -0,0 +1,127 @@
+/**
+ * Recover Windows file-link destinations whose backslash separators Markdown reads as escapes.
+ *
+ * CommonMark treats `\` before ASCII punctuation as an escape, so a destination
+ * written as a native Windows path (`[notes](C:\Users\me\.dsh\AGENTS.md)`)
+ * parses into `C:\Users\me.dsh\AGENTS.md`: the separator before `.dsh` is gone
+ * and the click opens a file that does not exist. A drive-letter destination
+ * therefore keeps its authored spelling whenever the parsed URL is exactly that
+ * spelling's unescaped form.
+ *
+ * `\\`, `\(`, and `\)` are the exceptions, because the two readings cannot be
+ * told apart there and the escape reading wins: a doubled backslash is how a
+ * destination spells one separator, and an escaped parenthesis is how it spells
+ * one that would otherwise end it. A native path may wrap a separator around
+ * such punctuation, so that case keeps the parsed URL and a caller that needs
+ * it percent-encodes the punctuation instead.
+ */
+
+import type { Definition, Link, Root, RootContent } from 'mdast'
+
+/** A drive-letter destination uses `\` as a separator, so it is usually not an escape there. */
+const WINDOWS_DESTINATION = /^[a-z]:[\\/]/iu
+
+/**
+ * Escapes that change where a destination ends, which a native path also uses
+ * around a separator, so the two readings cannot be told apart. Brackets and
+ * other punctuation stay recoverable because they cannot end a destination, so
+ * a backslash before one is more likely a separator than an escape.
+ */
+const DELIBERATE_ESCAPE = /\\(?:\\|\(|\))/u
+
+/** Whitespace ends an unquoted destination before its optional title. */
+const WHITESPACE = /\s/u
+
+/** One CommonMark backslash escape: a backslash and the ASCII punctuation it consumes. */
+const BACKSLASH_ESCAPE = /\\([!-/:-@[-`{-~])/gu
+
+/**
+ * Recover authored backslash separators in Windows file-link destinations.
+ * @param root - Parsed Markdown tree, modified in place.
+ * @param source - Original source the tree was parsed from.
+ * @returns The same root with recovered destinations.
+ */
+export function recoverWindowsFileLinks(root: Root, source: string): Root {
+ const visit = (node: Root | RootContent): void => {
+ if (node.type === 'link' || node.type === 'definition') recoverDestination(node, source)
+ if ('children' in node) for (const child of node.children) visit(child)
+ }
+ visit(root)
+ return root
+}
+
+/**
+ * Keep one destination as authored when Markdown turned a separator into an escape.
+ * @param node - Parsed link or definition, modified in place.
+ * @param source - Original source the node was parsed from.
+ */
+function recoverDestination(node: Link | Definition, source: string): void {
+ const start = node.position?.start.offset
+ const end = node.position?.end.offset
+ if (start === undefined || end === undefined) return
+ const authored = authoredDestination(source.slice(start, end))
+ if (authored === undefined || authored === node.url) return
+ if (!WINDOWS_DESTINATION.test(authored) || DELIBERATE_ESCAPE.test(authored)) return
+ if (unescapeDestination(authored) === node.url) node.url = authored
+}
+
+/**
+ * Read the destination of one link or definition out of the source slice that produced it.
+ * @param raw - Source text spanning the whole node, such as `[notes](C:\a\.b)` or `[notes]: C:\a\.b`.
+ * @returns The destination exactly as authored, or undefined when the slice carries none.
+ */
+function authoredDestination(raw: string): string | undefined {
+ let index = 1
+ let nested = 0
+ for (; index < raw.length; index += 1) {
+ const character = raw.charAt(index)
+ if (character === '\\') index += 1
+ else if (character === '[') nested += 1
+ else if (character === ']') {
+ if (nested === 0) break
+ nested -= 1
+ }
+ }
+ if (raw.charAt(index) !== ']') return undefined
+ const separator = raw.charAt(index + 1)
+ if (separator !== '(' && separator !== ':') return undefined
+ return readDestination(raw, index + 2)
+}
+
+/**
+ * Read one destination that starts at `from`: angle-bracketed or bare, and its optional title excluded.
+ * @param raw - Source text containing the destination.
+ * @param from - Offset of the first character after the destination's separator.
+ * @returns The destination exactly as authored, or undefined when a bare destination has no text.
+ */
+function readDestination(raw: string, from: number): string | undefined {
+ let cursor = from
+ while (cursor < raw.length && WHITESPACE.test(raw.charAt(cursor))) cursor += 1
+ if (raw.charAt(cursor) === '<') {
+ const close = raw.indexOf('>', cursor + 1)
+ /* v8 ignore next -- a parsed angle-bracket destination always closes */
+ if (close < 0) return undefined
+ return raw.slice(cursor + 1, close)
+ }
+ const start = cursor
+ let parens = 0
+ for (; cursor < raw.length; cursor += 1) {
+ const character = raw.charAt(cursor)
+ if (character === '\\') cursor += 1
+ else if (character === '(') parens += 1
+ else if (character === ')') {
+ if (parens === 0) break
+ parens -= 1
+ } else if (WHITESPACE.test(character)) break
+ }
+ return cursor === start ? undefined : raw.slice(start, cursor)
+}
+
+/**
+ * Undo the CommonMark escapes inside one authored destination.
+ * @param raw - Destination exactly as authored.
+ * @returns The destination the Markdown parser derives from it.
+ */
+function unescapeDestination(raw: string): string {
+ return raw.replace(BACKSLASH_ESCAPE, '$1')
+}
diff --git a/packages/client/ui-primitives/tests/markdown-windows-file-links.client.spec.tsx b/packages/client/ui-primitives/tests/markdown-windows-file-links.client.spec.tsx
new file mode 100644
index 0000000000..ec53fb2b96
--- /dev/null
+++ b/packages/client/ui-primitives/tests/markdown-windows-file-links.client.spec.tsx
@@ -0,0 +1,157 @@
+// @vitest-environment jsdom
+import { cleanup, fireEvent, render } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import type { Link, Root, RootContent } from 'mdast'
+import { MarkdownDelegateProvider } from '../src/index.ts'
+import { IncrementalMarkdownParser } from '../src/markdown/incremental.ts'
+import { parseGfm } from '../src/markdown/parse.ts'
+import { recoverWindowsFileLinks } from '../src/markdown/windows-file-link-syntax.ts'
+import { MarkdownText } from './markdown-test-components.tsx'
+
+afterEach(cleanup)
+
+/**
+ * Render one settled Markdown document and click its only file link.
+ * @param text - Markdown source.
+ * @returns The link title and the arguments the opener received.
+ */
+function openFileLink(text: string): { title: string | null; opened: unknown[] } {
+ const openFile = vi.fn()
+ const view = render(
+ <MarkdownDelegateProvider openFile={openFile}>
+ <MarkdownText text={text} />
+ </MarkdownDelegateProvider>,
+ )
+ const link = view.getByRole('button')
+ fireEvent.click(link)
+ return { title: link.getAttribute('title'), opened: openFile.mock.calls[0] ?? [] }
+}
+
+/**
+ * Render one settled Markdown document and click every file link in it.
+ * @param text - Markdown source.
+ * @returns One opened-path argument list per link, in document order.
+ */
+function openEveryFileLink(text: string): unknown[][] {
+ const openFile = vi.fn()
+ const view = render(
+ <MarkdownDelegateProvider openFile={openFile}>
+ <MarkdownText text={text} />
+ </MarkdownDelegateProvider>,
+ )
+ for (const link of view.getAllByRole('button')) fireEvent.click(link)
+ return openFile.mock.calls
+}
+
+/**
+ * Find the first link node in a parsed document.
+ * @param node - Parsed node to search from.
+ * @returns The first link below `node`, or undefined when the document has none.
+ */
+function firstLink(node: Root | RootContent): Link | undefined {
+ if (node.type === 'link') return node
+ if (!('children' in node)) return undefined
+ for (const child of node.children) {
+ const found = firstLink(child)
+ if (found !== undefined) return found
+ }
+ return undefined
+}
+
+describe('Windows file-link destinations', () => {
+ it.each([
+ ['[AGENTS.md](C:\\Users\\黄\\.dsh\\AGENTS.md)', 'C:\\Users\\黄\\.dsh\\AGENTS.md', undefined],
+ ['[notes](C:\\Users\\me\\.dsh\\AGENTS.md)', 'C:\\Users\\me\\.dsh\\AGENTS.md', undefined],
+ ['[notes](C:\\Users\\me\\.dsh\\AGENTS.md#L12)', 'C:\\Users\\me\\.dsh\\AGENTS.md', { line: 12 }],
+ ['[notes](<C:\\Users\\me\\.dsh\\AGENTS.md>)', 'C:\\Users\\me\\.dsh\\AGENTS.md', undefined],
+ ['[notes](C:\\Users\\me\\.dsh\\AGENTS.md "the config")', 'C:\\Users\\me\\.dsh\\AGENTS.md', undefined],
+ ['[notes]( C:\\Users\\me\\.dsh\\AGENTS.md )', 'C:\\Users\\me\\.dsh\\AGENTS.md', undefined],
+ ['[notes](C:\\work\\a(1)\\.md)', 'C:\\work\\a(1)\\.md', undefined],
+ ['[see [notes] here](C:\\Users\\me\\.dsh\\AGENTS.md)', 'C:\\Users\\me\\.dsh\\AGENTS.md', undefined],
+ ['[a\\].ts](C:\\a\\.b)', 'C:\\a\\.b', undefined],
+ ['[notes](C:\\Users\\me\\AGENTS.md)', 'C:\\Users\\me\\AGENTS.md', undefined],
+ ])('opens %s as authored', (text, path, options) => {
+ const { title, opened } = openFileLink(text)
+ expect(title).toBe(path)
+ expect(opened).toEqual([path, options])
+ })
+
+ it.each([
+ // Doubled separators are how a destination spells one backslash, so the parser's reading is the path.
+ ['[a](C:\\\\Users\\\\me\\\\AGENTS.md)', 'C:\\Users\\me\\AGENTS.md'],
+ // An escaped parenthesis is a spelled character, not a separator; this one is also unnecessary.
+ ['[a](C:\\work\\a\\(1\\).md)', 'C:\\work\\a(1).md'],
+ ['[a](C:\\dir\\a\\)b.md)', 'C:\\dir\\a)b.md'],
+ ])('keeps the parsed path of deliberately escaped %s', (text, path) => {
+ const { title, opened } = openFileLink(text)
+ expect(title).toBe(path)
+ expect(opened).toEqual([path, undefined])
+ })
+
+ it('leaves a non-Windows destination to Markdown escaping', () => {
+ const { title, opened } = openFileLink('[notes](/home/me/\\(draft\\)/a.md)')
+ expect(title).toBe('/home/me/(draft)/a.md')
+ expect(opened).toEqual(['/home/me/(draft)/a.md', undefined])
+ })
+
+ it.each([
+ ['[notes](C:\Users\me\.dsh)', 'C:\\Users\\me\\.dsh'],
+ ['[notes](C:\\Users\\me\\.dsh\AGENTS.md)', 'C:\\Users\\me.dsh\\AGENTS.md'],
+ ])('leaves %s at the path the parser resolved', (text, path) => {
+ const { title, opened } = openFileLink(text)
+ expect(title).toBe(path)
+ expect(opened).toEqual([path, undefined])
+ })
+
+ it('recovers every link in a paragraph that starts after other text', () => {
+ expect(openEveryFileLink('before [a](C:\\a\\.b) and [c](C:\\c\\.d)'))
+ .toEqual([['C:\\a\\.b', undefined], ['C:\\c\\.d', undefined]])
+ })
+
+ it('recovers a reference link through its definition', () => {
+ const { title, opened } = openFileLink('[notes][target]\n\n[target]: C:\\Users\\me\\.dsh\\AGENTS.md')
+ expect(title).toBe('C:\\Users\\me\\.dsh\\AGENTS.md')
+ expect(opened).toEqual(['C:\\Users\\me\\.dsh\\AGENTS.md', undefined])
+ })
+
+ it.each(['see https://example.com/docs', 'see https://example.com/a]b'])('leaves the autolink in %s to the external-link path', (text) => {
+ const view = render(
+ <MarkdownDelegateProvider openFile={vi.fn()}>
+ <MarkdownText text={text} />
+ </MarkdownDelegateProvider>,
+ )
+ // The anchor href is percent-normalized, so compare the decoded destination.
+ expect(decodeURIComponent(view.container.querySelector('a')?.getAttribute('href') ?? '')).toBe(text.slice('see '.length))
+ expect(view.container.querySelector('button')).toBeNull()
+ })
+
+ it('keeps an empty destination inert', () => {
+ const view = render(
+ <MarkdownDelegateProvider openFile={vi.fn()}>
+ <MarkdownText text={'[notes]()'} />
+ </MarkdownDelegateProvider>,
+ )
+ expect(view.getByText('notes')).toBeTruthy()
+ expect(view.container.querySelector('button')).toBeNull()
+ })
+
+ it('ignores a link without source positions', () => {
+ const root: Root = {
+ type: 'root',
+ children: [{ type: 'link', url: 'C:\\a.b', children: [{ type: 'text', value: 'notes' }] }],
+ }
+ expect(recoverWindowsFileLinks(root, 'C:\\a\\.b')).toBe(root)
+ expect(root.children[0]).toMatchObject({ url: 'C:\\a.b' })
+ })
+
+ it('recovers a link the streaming parser keeps in a tail slice with a nonzero base', () => {
+ const parser = new IncrementalMarkdownParser(parseGfm)
+ parser.update('one\n\ntwo\n\nthree\n\n')
+ const blocks = parser.update('one\n\ntwo\n\nthree\n\n[notes](C:\\Users\\me\\.dsh\\AGENTS.md)\n\n')
+ expect(blocks.frozen).toHaveLength(2)
+ const urls = [...blocks.frozen, ...blocks.tail]
+ .map(block => firstLink(block.node)?.url)
+ .filter(url => url !== undefined)
+ expect(urls).toEqual(['C:\\Users\\me\\.dsh\\AGENTS.md'])
+ })
+})
--
2.48.1.windows.1验证数据:新增 23 个用例;该文件语句、分支、函数、行覆盖均 100%; 如果更希望以 PR 形式收,我可以随时改成 PR。 |
|
你定位对了,而且「坏在解析阶段」有两个明确落点。 CommonMark 的 backslash escape 在解析时就把 今天能用的写法还是那两个:反斜杠换成正斜杠,或者 关于你说的「解析后用源码恢复作者写法」:可行,但它依赖 mdast 节点的 position 去切原文,对引用式链接( 我只读了源码(22ddee3),没跑渲染;你那版 mdast 实测就是判据。 |
|
这个已经修了,而且就是修在你选的那一层。
版本口径:这个提交在 你提的「解析后从源码恢复作者写法」和最终落地的做法是同一个方向,官方版额外覆盖了相对路径。 只读了提交和源码( |
Uh oh!
There was an error while loading. Please reload this page.
Summary
回复里的 Markdown 文件链接,如果目标写成 Windows 原生路径(反斜杠分隔),点击后会打开一个不存在的路径:分隔符被吃掉了。
Reproduction
[AGENTS.md](C:\Users\me\.dsh\AGENTS.md)C:/Users/me.dsh/AGENTS.md,正确应为C:\Users\me\.dsh\AGENTS.mdCause
CommonMark 把反斜杠加 ASCII 标点当作转义,链接目标里的
\.被解析成.。用mdast-util-from-markdown实测:fileAddressFor只做\→/的归一化,地址在解析阶段就已经错了,不是路径拼接的问题。Expected
点击后打开
C:\Users\me\.dsh\AGENTS.md。Environment
@deepseek-ai/dsh-client-ui-primitives(Markdown 渲染与文件链接)Possible fix
本地已有一版验证过的实现:在解析完成之后、渲染之前,从源码恢复作者写法。
^[a-z]:[\\/]),避免误伤 POSIX 路径;\\、\(、\)三种故意转义保持解析结果。实现上是新增一个解析后处理模块,在两个语法入口各调用一次,行内链接和引用式定义都覆盖。新增 23 个用例,该文件语句、分支、函数、行覆盖均为 100%,
ui-primitives整包 1833 个测试通过,并在 0.2.1-rc.3 的构建里用真实历史消息验证过(链接 tooltip 与点击目标都正确)。需要的话我可以把 diff 贴出来。
All reactions