fix(ui-primitives): keep backslashes in Windows-path Markdown links- #7098
* fix(ui-primitives): keep backslashes in Windows-path Markdown destinations CommonMark reads \. in a link destination as an escaped dot, so [x](D:\repo\.github\a.md) opened D:\repo.github\a.md. Destinations that look like Windows paths now keep their authored text. Fixes #7095 * fix(ui-primitives): cover relative paths and character references Treat a backslash destination without a URL scheme as a Windows path when it has no slash or a backslash before a letter or digit, keep every backslash literal there, and still decode character references. Pin the behavior in the Markdown file-links Web snapshot. * chore: list micromark-util-decode-string in third-party notices --------- Co-authored-by: Turtle <[email protected]>
This commit is contained in:
1 parent
92b011e57c
commit
c262a09fd0
11 files changed
+85
-7
No files matched your search
@@ -112,6 +112,7 @@ External packages installed for runtime use or embedded in distributed Host or b
|
||||
| [`micromark-factory-space`](https://github.com/micromark/micromark/tree/main/packages/micromark-factory-space) | MIT |
|
||||
| [`micromark-util-character`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-character) | MIT |
|
||||
| [`micromark-util-classify-character`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-classify-character) | MIT |
|
||||
| [`micromark-util-decode-string`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-decode-string) | MIT |
|
||||
| [`micromark-util-sanitize-uri`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-sanitize-uri) | MIT |
|
||||
| [`micromark-util-symbol`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-symbol) | MIT |
|
||||
| [`mime`](https://github.com/broofa/mime) | MIT |
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
en: b3744373b4b2c1fb
|
||||
zh: 2dbde8ee0bfa5f28
|
||||
/deepseek-ai-dsh-client-ui-primitives/use-this-package/rendering-agent-output:
|
||||
en: 31ed1a6239427512
|
||||
zh: cb489d77de109d3f
|
||||
en: 2daec758b1f35563
|
||||
zh: 32684be1f8b1b4c3
|
||||
/deepseek-ai-dsh-client-ui-primitives/use-this-package/localizing-copy:
|
||||
en: a943603b74080ef5
|
||||
zh: 43f7a0188aff271b
|
||||
|
||||
@@ -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. A destination with a backslash and no URL scheme (a drive letter is not one) is a Windows path when it has no `/` or has a backslash before a letter or digit. Such a path keeps every backslash, including one before punctuation, and still decodes character references, so `D:\repo\.github` is not read as an escaped `.`. 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.
|
||||
|
||||
`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).
|
||||
|
||||
|
||||
@@ -108,7 +108,7 @@ kind: "package-library"
|
||||
|
||||
纯 Markdown 工具 `formatQuoteText` 规范化 CRLF 和 CR 换行符,再为每行添加 `> ` 前缀。
|
||||
|
||||
最近的 `MarkdownDelegateProvider` 提供可选的 `openExternalLink` 和 `openFile` 导航回调。嵌套 Provider 替换外层能力,回调变化无需重新构建 Markdown 即可到达已渲染链接。其 `openFile` 使本地 Markdown 链接在落定后可点击。绝对路径和工作区相对路径支持百分号转义以及 `#L24` / `#L24-L30` 片段;范围定位到起始行。文件名中的字面 `?` 和 `#` 必须百分号编码。悬停提示使用解码后的路径,并在标签为空时提供可访问名称。回调接收解码后的路径和可选行号,渲染器保留标签并显示文件图标。不传回调时,本地链接仍为文本。URL 协议、查询串、不支持的片段及格式错误的目标不会传给文件打开器。
|
||||
最近的 `MarkdownDelegateProvider` 提供可选的 `openExternalLink` 和 `openFile` 导航回调。嵌套 Provider 替换外层能力,回调变化无需重新构建 Markdown 即可到达已渲染链接。其 `openFile` 使本地 Markdown 链接在落定后可点击。绝对路径和工作区相对路径支持百分号转义以及 `#L24` / `#L24-L30` 片段;范围定位到起始行。含反斜杠且无 URL 协议(盘符不算 URL 协议)的目标,若不含 `/`,或在字母或数字前有反斜杠,即为 Windows 路径。此类路径保留所有反斜杠(包括标点前的反斜杠),仍会解码字符引用,因此 `D:\repo\.github` 不会被读作转义的 `.`。文件名中的字面 `?` 和 `#` 必须百分号编码。悬停提示使用解码后的路径,并在标签为空时提供可访问名称。回调接收解码后的路径和可选行号,渲染器保留标签并显示文件图标。不传回调时,本地链接仍为文本。URL 协议、查询串、不支持的片段及格式错误的目标不会传给文件打开器。
|
||||
|
||||
`MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。删除线需要 `~~`;单个 `~`(如 `10~20`)保持字面文本。外层 `MarkdownDelegateProvider` 会接收普通点击产生的已净化 HTTP(S) URL;带修饰键的点击和 Provider 外的链接保留原生外部 anchor 行为。`omitHtml` 不渲染原始 HTML,而不是将其显示为原样文本,用于为 HTML 渲染器编写的文档。当 owner 传入 `pathImages` 词表时,本地媒体路径的图片目标只在落定渲染阶段重写为可展示 URL(与 file mentions 相同的流式门);不传词表时本地目标保持惰性 alt 文本。重写后的图片支持 HTTP(S)、data、blob 及桌面端 `dsh-app://app/api/file`、`dsh-app://app/api/plugin-market/detail-file` 与 `dsh-app://app/api/plugin-manager/detail-file` 路由;正文直接书写的桌面端 URL 仍保持惰性。加载或解码失败后,图片替换为作者的 alt 文本;alt 为空时显示原始目标路径;`fileImages` 还提供本地化的失败提示前缀。图片源变化后可重新加载。每个挂载的图片会为解析得到的 Host `api/file` 地址追加 `load` 查询参数,因为浏览器在同一文档内会无视 `no-store` 复用相同 URL 的图片;因此覆盖后的文件在新渲染的图片中会加载当前内容,每次重新挂载都会重新请求该文件;Host 文件路由会把该请求重定向到当前文件版本的可缓存地址。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`UserTextRun` 渲染不做解析的文本,`ReferenceChip` 渲染业务已解析的标签。显式传入 `onActivate` 后,胶囊成为可由键盘操作的按钮;重复指针点击和已有文本选区不触发激活,键盘激活仍然可用。标签继承所属容器的换行规则。识别和 JSON 变换由 [ui-conversation](../ui-conversation/README.zh.md#sent-user-text) 负责。
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"katex": "^0.18.7",
|
||||
"micromark-core-commonmark": "^2.0.3",
|
||||
"micromark-util-character": "^2.1.1",
|
||||
"micromark-util-decode-string": "^2.0.1",
|
||||
"micromark-util-classify-character": "^2.0.1",
|
||||
"micromark-util-symbol": "^2.0.1",
|
||||
"micromark-util-types": "^2.0.2",
|
||||
|
||||
@@ -17,6 +17,7 @@ import { gfm } from 'micromark-extension-gfm'
|
||||
import { math } from 'micromark-extension-math'
|
||||
import { cjkFriendlyStrong } from './cjkFriendlyStrong.ts'
|
||||
import { mathCompatibility } from './mathCompatibility.ts'
|
||||
import { windowsPathDestinations } from './windows-path-destinations.ts'
|
||||
|
||||
/** GFM strikethrough requires `~~`; a single `~` stays literal, as in ranges such as `10~20`. */
|
||||
const gfmOptions = { singleTilde: false }
|
||||
@@ -30,7 +31,7 @@ const gfmOptions = { singleTilde: false }
|
||||
export function parseGfm(text: string): Root {
|
||||
return recoverLocalImages(fromMarkdown(text, {
|
||||
extensions: [gfm(gfmOptions), cjkFriendlyStrong()],
|
||||
mdastExtensions: [gfmFromMarkdown()],
|
||||
mdastExtensions: [gfmFromMarkdown(), windowsPathDestinations()],
|
||||
}), text)
|
||||
}
|
||||
|
||||
@@ -46,6 +47,6 @@ export function parseGfmWithMath(text: string): Root {
|
||||
// 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()],
|
||||
mdastExtensions: [gfmFromMarkdown(), mathFromMarkdown(), windowsPathDestinations()],
|
||||
}), text)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/** Keep backslash separators in Markdown destinations written as Windows paths. */
|
||||
import type { Definition, Image, Link } from 'mdast'
|
||||
import type { CompileContext, Extension, Token } from 'mdast-util-from-markdown'
|
||||
import { decodeString } from 'micromark-util-decode-string'
|
||||
|
||||
/**
|
||||
* A raw destination is a Windows path when it has a backslash and no URL
|
||||
* scheme, and either has no `/` or has a backslash before a letter or digit,
|
||||
* which CommonMark cannot read as an escape. A drive letter is not a scheme.
|
||||
*/
|
||||
function isWindowsPath(raw: string): boolean {
|
||||
return raw.includes('\\') && !/^[a-z][a-z\d+.-]+:/iu.test(raw)
|
||||
&& (!raw.includes('/') || /\\[\p{L}\p{N}]/u.test(raw))
|
||||
}
|
||||
|
||||
function exitDestination(this: CompileContext, token: Token): void {
|
||||
const parsed = this.resume()
|
||||
const raw = this.sliceSerialize(token)
|
||||
const node = this.stack.at(-1) as Link | Image | Definition
|
||||
// Doubling each backslash makes every one literal; character references still decode.
|
||||
node.url = isWindowsPath(raw) ? decodeString(raw.replaceAll('\\', '\\\\')) : parsed
|
||||
}
|
||||
|
||||
/**
|
||||
* CommonMark reads `\.` or `\_` in a destination as an escaped character, so
|
||||
* `D:\repo\.github` would parse as `D:\repo.github`. For a destination that
|
||||
* is a Windows path, this `fromMarkdown` extension keeps every backslash
|
||||
* literally, including one before punctuation such as `\(`, while character
|
||||
* references such as `&` still decode; other destinations parse unchanged.
|
||||
* @returns The mdast extension.
|
||||
*/
|
||||
export function windowsPathDestinations(): Extension {
|
||||
return { exit: { resourceDestinationString: exitDestination, definitionDestinationString: exitDestination } }
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
import { cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { MarkdownDelegateProvider } from '../src/index.ts'
|
||||
import { parseGfm, parseGfmWithMath } from '../src/markdown/parse.ts'
|
||||
import { MarkdownText } from './markdown-test-components.tsx'
|
||||
|
||||
afterEach(cleanup)
|
||||
@@ -29,6 +30,41 @@ describe('Markdown file links', () => {
|
||||
expect(view.container.querySelector('a')).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['D:\\dsh\\toc-agent\\.deploy\\notes.md', 'D:\\dsh\\toc-agent\\.deploy\\notes.md', undefined],
|
||||
['<D:\\My Files\\.config\\a_b.md>', 'D:\\My Files\\.config\\a_b.md', undefined],
|
||||
['D:\\.deploy\\notes.md#L4', 'D:\\.deploy\\notes.md', { line: 4 }],
|
||||
['D:/repo\\.github\\ci.yml', 'D:/repo\\.github\\ci.yml', undefined],
|
||||
['D:\\R&D\\a b.md', 'D:\\R&D\\a b.md', undefined],
|
||||
['D:\\a\\(b\\).md', 'D:\\a\\(b\\).md', undefined],
|
||||
['src\\.github\\ci.yml', 'src\\.github\\ci.yml', undefined],
|
||||
['.\\.env', '.\\.env', undefined],
|
||||
['..\\.gitignore', '..\\.gitignore', undefined],
|
||||
['docs\\_draft.md', 'docs\\_draft.md', undefined],
|
||||
['docs/\\_draft.md', 'docs/_draft.md', undefined],
|
||||
['C:/work/\\_draft.md', 'C:/work/_draft.md', undefined],
|
||||
])('keeps backslashes only where %s is a Windows path', (target, path, options) => {
|
||||
const openFile = vi.fn()
|
||||
const view = render(
|
||||
<MarkdownDelegateProvider openFile={openFile}>
|
||||
<MarkdownText text={`[source](${target})\n\n[ref][r]\n\n[r]: ${target}`} />
|
||||
</MarkdownDelegateProvider>,
|
||||
)
|
||||
expect(view.getAllByRole('button').map(button => button.getAttribute('title'))).toEqual([path, path])
|
||||
fireEvent.click(view.getByRole('button', { name: 'source' }))
|
||||
expect(openFile).toHaveBeenCalledExactlyOnceWith(path, options)
|
||||
})
|
||||
|
||||
it.each([parseGfm, parseGfmWithMath])('keeps Windows image destinations in %o', (parse) => {
|
||||
const [paragraph] = parse('').children
|
||||
expect(paragraph).toMatchObject({ children: [{ type: 'image', url: 'D:\\img\\.cache\\a.png' }] })
|
||||
})
|
||||
|
||||
it('reads backslash escapes in URLs', () => {
|
||||
const view = render(<MarkdownText text={'[source](https://example.com/a\\b\\_c)'} />)
|
||||
expect(view.getByRole('link').getAttribute('href')).toBe('https://example.com/a%5Cb_c')
|
||||
})
|
||||
|
||||
it.each(['/workspace/chart.png', 'C:/work/chart.png'])('keeps %s accessible when inline images are unavailable', (path) => {
|
||||
const openFile = vi.fn()
|
||||
const view = render(
|
||||
|
||||
Generated
+3
@@ -5141,6 +5141,9 @@ importers:
|
||||
micromark-util-classify-character:
|
||||
specifier: ^2.0.1
|
||||
version: 2.0.1
|
||||
micromark-util-decode-string:
|
||||
specifier: ^2.0.1
|
||||
version: 2.0.1
|
||||
micromark-util-sanitize-uri:
|
||||
specifier: ^2.0.1
|
||||
version: 2.0.1
|
||||
|
||||
@@ -9,3 +9,5 @@
|
||||
- text: ·
|
||||
- link "Website":
|
||||
- /url: https://example.com/
|
||||
- text: ·
|
||||
- button "D:\\repo\\.github\\a.md"
|
||||
@@ -5,6 +5,6 @@
|
||||
{"type":"user/message","data":{"content":[{"type":"text","text":"Link src/example.txt at lines 24–30 and line 30 (absolute path), other/example.txt, missing.txt, and https://example.com/."}],"source":{"kind":"user","rpcId":"{{rpc:1}}"},"role":"user","id":"{{message:2}}"},"surfaceOp":"append"}
|
||||
{"type":"session/title","data":{"title":"File references","messageSeqs":[3],"source":{"kind":"fallback"}}}
|
||||
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}}
|
||||
{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"[src/example.txt:24–30](src/example.txt#L24-L30) · [src/example.txt:30]({{cwd}}/src/example.txt#L30) · [other/example.txt](other/example.txt) · [Missing file](missing.txt) · [Website](https://example.com/)"}],"source":{"provider":"deepseek-official","model":"deepseek-v4-flash","kind":"model"},"id":"{{message:3}}"},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["[src/example.txt:24–30](src/example.txt#L24-L30) · [src/example.txt:30]({{cwd}}/src/example.txt#L30) · [other/example.txt](other/example.txt) · [Missing file](missing.txt) · [Website](https://example.com/)"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"[src/example.txt:24–30](src/example.txt#L24-L30) · [src/example.txt:30]({{cwd}}/src/example.txt#L30) · [other/example.txt](other/example.txt) · [Missing file](missing.txt) · [Website](https://example.com/)"}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}
|
||||
{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"[src/example.txt:24–30](src/example.txt#L24-L30) · [src/example.txt:30]({{cwd}}/src/example.txt#L30) · [other/example.txt](other/example.txt) · [Missing file](missing.txt) · [Website](https://example.com/) · [](<D:\\repo\\.github\\a.md>)"}],"source":{"provider":"deepseek-official","model":"deepseek-v4-flash","kind":"model"},"id":"{{message:3}}"},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["[src/example.txt:24–30](src/example.txt#L24-L30) · [src/example.txt:30]({{cwd}}/src/example.txt#L30) · [other/example.txt](other/example.txt) · [Missing file](missing.txt) · [Website](https://example.com/) · [](<D:\\repo\\.github\\a.md>)"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"[src/example.txt:24–30](src/example.txt#L24-L30) · [src/example.txt:30]({{cwd}}/src/example.txt#L30) · [other/example.txt](other/example.txt) · [Missing file](missing.txt) · [Website](https://example.com/) · [](<D:\\repo\\.github\\a.md>)"}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}
|
||||
{"type":"step/end","data":{"turn":1,"step":1}}
|
||||
{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
Reference in new issue
Block a user