fix(docs): address Markdown actions review feedback

This commit is contained in:
gengruilin committed 2026-09-15 17:22:50 +08:00
1 parent eb59250ce9
commit 38b2bf2f6c
15 files changed
+116 -47

No files matched your search

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-15-docs-page-markdown-actions.md
2026-09-15-docs-page-markdown-actions.md: c7b250d2b5b8285d947f9a5308e938119cc59064
2026-09-15-docs-page-markdown-actions.zh.md: a9af5150649407efb30de02cc227ce40e77b5fd6
2026-09-15-docs-page-markdown-actions.md: 61ddd46c802201f37fa0974561158f91cfb9de2d
2026-09-15-docs-page-markdown-actions.zh.md: 2fd373688cce97053779a602352bcb0d1e90bd7b
@@ -12,11 +12,11 @@ Readers need a visible way to obtain one documentation page as Markdown. The pub
The [projector](../../../../scripts/project-doc-site.ts) supplies ordinary content pages with a `rawMarkdownPath` from the publication manifest. The [theme](../../../../website/.vitepress/theme/index.ts) combines that path with the site base and exposes copy and view actions above the document. Directory pages use their full `index.md` route, which preserves the same relative-link location in development and static builds. Redirect homes and missing pages have no actions. Raw output excludes projection metadata.
The primary button copies the page directly. Its adjacent toggle opens a menu with icons, action titles, and explanatory text, keeping the common action visible while grouping Markdown options. Menu items support arrow keys, Home, End, and activation; Escape restores toggle focus, while outside pointers and focus leaving dismiss the menu. Menu state and its outside-pointer listener belong to the page instance.
Server-rendered pages expose a raw-Markdown link. Client mounting replaces it with the copy button and menu, so MPA builds and pages without JavaScript retain a usable action. The primary button copies the page directly and keeps keyboard focus during copying through `aria-disabled` and `aria-busy`. Its adjacent toggle opens a menu with icons, action titles, and explanatory text, keeping the common action visible while grouping Markdown options. Menu items support arrow keys, Home, End, and activation; Escape restores toggle focus, while outside pointers and focus leaving dismiss the menu. Menu state and its outside-pointer listener belong to the page instance.
The [development middleware](../../../../website/raw-markdown.ts) admits explicitly marked `?dsh-raw=1` browser fetches and returns 404 for unpublished raw routes. Script imports always reach Vite. The view link opens the ordinary raw URL in a new tab; copy reads that same projected body on demand. Emitted Markdown files carry a UTF-8 BOM because static hosts can omit the response charset, causing direct browser navigation to misdecode Chinese and other non-ASCII characters. Fetch decoding removes the BOM before copying. Neither operation reconstructs Markdown from the rendered DOM.
The [copy component](../../../../website/.vitepress/theme/page-markdown-actions.ts) calls `clipboard.write` within the click gesture and supplies a promise-backed `text/plain` ClipboardItem. Awaiting the network first would lose user activation in browsers that require it. Each route and language gets its own keyed component instance; disposal aborts unfinished data reads and prevents old writes from changing new-page feedback. A system clipboard write that has already consumed its data cannot be withdrawn. Request and clipboard failures provide localized manual-copy guidance, and success follows the completed write. Denied writes may never consume their data promise, so that promise has its own rejection observer.
The [copy component](../../../../website/.vitepress/theme/page-markdown-actions.ts) calls `clipboard.write` within the click gesture and supplies a promise-backed `text/plain` ClipboardItem. Awaiting the network first would lose user activation in browsers that require it. Each route and language gets its own keyed component instance, so old writes cannot change new-page feedback. Disposal aborts unfinished data reads. A system clipboard write that has already consumed its data cannot be withdrawn. Request and clipboard failures provide localized manual-copy guidance, and success follows the completed write. Denied writes may never consume their data promise, so that promise has its own rejection observer.
## Alternatives considered
@@ -28,7 +28,7 @@ The [copy component](../../../../website/.vitepress/theme/page-markdown-actions.
## Consequences
Automatic copying requires the browser's asynchronous ClipboardItem write API in a secure context. Unsupported or denied writes retain the view link for manual copying. Page copy state is transient and belongs to one route; it creates no Session data or model request.
Automatic copying accepts `text/markdown` or `text/plain` responses. Hosts must assign one of these content types to `.md` files; missing types and `application/octet-stream` fail with manual-copy guidance. The allowlist also rejects HTML fallbacks and JavaScript page modules. Automatic copying requires the browser's asynchronous ClipboardItem write API in a secure context. Unsupported or denied writes retain the view link for manual copying. Page copy state is transient and belongs to one route; it creates no Session data or model request.
The [component tests](../../../../website/tests/page-markdown-actions.spec.ts) pin localized accessible output beside their owner and exercise deferred reads, denied writes, and navigation disposal. The [middleware tests](../../../../website/tests/raw-markdown.spec.ts) cover request dispatch. Both run through the unit suite, `docs:check`, and `doc-sync`. The [Mermaid viewer decision](2026-09-14-docs-mermaid-viewer.md) retains its independent rendering and resource-lifetime rules.
@@ -12,11 +12,11 @@ Status: implemented
[投影器](../../../../scripts/project-doc-site.ts) 根据发布 manifest(元数据清单)为普通内容页提供 `rawMarkdownPath`。[主题](../../../../website/.vitepress/theme/index.ts) 将该路径与站点 base 组合,在正文上方提供复制和查看操作。目录页使用完整的 `index.md` 路由,使相对链接在开发环境和静态构建中保持相同的位置。首页重定向和缺失页面不显示操作。原文输出不包含投影元数据。
主按钮直接复制页面,旁边的展开按钮打开带图标、操作标题和说明文字的菜单,使常用操作保持可见,并将 Markdown 选项放在一起。菜单项支持方向键、Home、End 和激活操作;Escape 将焦点恢复到展开按钮,外部指针操作和焦点离开会关闭菜单。菜单状态及其外部指针监听器归属于页面实例。
服务端渲染的页面提供原文链接。客户端挂载后将其替换为复制按钮和菜单,使 MPA 构建及未运行 JavaScript 的页面保留可用操作。主按钮直接复制页面,并通过 `aria-disabled` 和 `aria-busy` 在复制期间保留键盘焦点。旁边的展开按钮打开带图标、操作标题和说明文字的菜单,使常用操作保持可见,并将 Markdown 选项放在一起。菜单项支持方向键、Home、End 和激活操作;Escape 将焦点恢复到展开按钮,外部指针操作和焦点离开会关闭菜单。菜单状态及其外部指针监听器归属于页面实例。
[开发中间件](../../../../website/raw-markdown.ts) 接受带有显式 `?dsh-raw=1` 标记的浏览器请求,对未发布的原文路由返回 404。脚本导入始终交给 Vite。查看链接在新标签页打开普通原文 URL;复制按需读取相同的投影正文。生成的 Markdown 文件携带 UTF-8 BOM,因为静态托管可能省略响应字符集,导致浏览器直接访问时错误解码中文等非 ASCII 字符。Fetch 解码会在复制前移除 BOM。两种操作都不从渲染后的 DOM 重建 Markdown。
[复制组件](../../../../website/.vitepress/theme/page-markdown-actions.ts) 在点击手势内调用 `clipboard.write`,并提供由 Promise 承载数据的 `text/plain` ClipboardItem。先等待网络会在要求用户激活的浏览器中丢失该激活状态。每个路由和语言组合都有独立的、带 key 的组件实例;释放时取消未完成的数据读取,防止旧写入改变新页面的反馈。已经消费数据的系统剪贴板写入无法撤回。请求失败和剪贴板失败提供本地化的手动复制指引,成功提示在写入完成后显示。被拒绝的写入可能完全不消费数据 Promise,因此该 Promise 有独立的拒绝处理器。
[复制组件](../../../../website/.vitepress/theme/page-markdown-actions.ts) 在点击手势内调用 `clipboard.write`,并提供由 Promise 承载数据的 `text/plain` ClipboardItem。先等待网络会在要求用户激活的浏览器中丢失该激活状态。每个路由和语言组合都有独立的、带 key 的组件实例,因此旧写入无法改变新页面的反馈。释放时取消未完成的数据读取。已经消费数据的系统剪贴板写入无法撤回。请求失败和剪贴板失败提供本地化的手动复制指引,成功提示在写入完成后显示。被拒绝的写入可能完全不消费数据 Promise,因此该 Promise 有独立的拒绝处理器。
## 考虑过的替代方案
@@ -28,7 +28,7 @@ Status: implemented
## 影响
自动复制需要浏览器在安全上下文中提供异步 ClipboardItem 写入 API。写入不受支持或被拒绝时,查看链接仍可用于手动复制。页面复制状态是临时状态,归属于单一路由;它不创建 Session 数据或模型请求。
自动复制接受 `text/markdown` 或 `text/plain` 响应。托管方必须为 `.md` 文件指定其中一种内容类型;缺失类型和 `application/octet-stream` 会触发失败及手动复制指引。白名单还会拒绝 HTML 回退页面和 JavaScript 页面模块。自动复制需要浏览器在安全上下文中提供异步 ClipboardItem 写入 API。写入不受支持或被拒绝时,查看链接仍可用于手动复制。页面复制状态是临时状态,归属于单一路由;它不创建 Session 数据或模型请求。
[组件测试](../../../../website/tests/page-markdown-actions.spec.ts) 在所属目录中固定本地化的可访问输出,并验证延迟读取、写入拒绝和导航时的资源清理。[中间件测试](../../../../website/tests/raw-markdown.spec.ts) 覆盖请求分发。两者都通过单元测试、`docs:check` 和 `doc-sync` 执行。[Mermaid 查看器决策](2026-09-14-docs-mermaid-viewer.zh.md) 保留独立的渲染与资源生命周期规则。
+1 -1
View File
@@ -776,7 +776,7 @@ describe('raw Markdown projection of the published manifest', () => {
it('emits home pages with their bodies instead of the frontmatter stub', () => {
for (const route of ['index.md', 'en/index.md']) {
const home = readFileSync(join(mirror, route), 'utf8')
const home = new TextDecoder().decode(readFileSync(join(mirror, route)))
expect(home.startsWith('---'), route).toBe(false)
expect(home, route).toContain('# DeepSeek Harness')
}
@@ -60,7 +60,6 @@
background: var(--vp-c-default-soft);
}
.page-markdown-actions [disabled],
.page-markdown-actions [aria-disabled="true"] {
cursor: wait;
opacity: 0.65;
@@ -132,3 +131,14 @@
.page-markdown-actions-status:not(:empty) {
margin-top: 8px;
}
.page-markdown-static {
display: inline-flex;
align-items: center;
gap: 4px;
min-height: 36px;
padding: 6px 12px;
border: 1px solid var(--vp-c-divider);
border-radius: 12px;
background: var(--vp-c-bg);
}
@@ -1,5 +1,5 @@
/** Current-page Markdown actions; each mounted instance owns one page's copy request. */
import { defineComponent, h, nextTick, onBeforeUnmount, ref, useId, watch } from 'vue'
import { defineComponent, h, nextTick, onBeforeUnmount, onMounted, ref, useId, watch } from 'vue'
type CopyState = 'idle' | 'copying' | 'copied' | 'requestFailed' | 'clipboardFailed'
@@ -47,6 +47,7 @@ function icon(name: keyof typeof icons) {
/**
* Actions keyed by the owning layout to the current route and language.
* Server rendering exposes the raw link; mounting enables copying and the menu.
* Clipboard writes begin in the click gesture; their data resolves on demand.
*/
export const PageMarkdownActions = defineComponent({
@@ -56,6 +57,8 @@ export const PageMarkdownActions = defineComponent({
lang: { type: String, required: true },
},
setup(props) {
const interactive = ref(false)
onMounted(() => { interactive.value = true })
const state = ref<CopyState>('idle')
const open = ref(false)
const root = ref<HTMLElement>()
@@ -101,9 +104,7 @@ export const PageMarkdownActions = defineComponent({
}
}
let controller: AbortController | undefined
let disposed = false
onBeforeUnmount(() => {
disposed = true
open.value = false
controller?.abort()
})
@@ -139,10 +140,10 @@ export const PageMarkdownActions = defineComponent({
})
try {
await clipboard.write([new ClipboardItem({ 'text/plain': content })])
if (!disposed) state.value = 'copied'
state.value = 'copied'
} catch (_error) {
// Browser failures use localized feedback; their implementation-specific text is not user copy.
if (!disposed) state.value = outcome.requestFailed ? 'requestFailed' : 'clipboardFailed'
state.value = outcome.requestFailed ? 'requestFailed' : 'clipboardFailed'
} finally {
request.abort()
if (controller === request) controller = undefined
@@ -151,6 +152,12 @@ export const PageMarkdownActions = defineComponent({
return () => {
const text = messages[props.lang.startsWith('zh') ? 'zh' : 'en']
if (!interactive.value) {
return h('div', { class: 'page-markdown-actions' }, [
h('a', { class: 'page-markdown-static', href: props.path, target: '_blank', rel: 'noopener', 'aria-label': text.newTab },
[text.view, icon('external')]),
])
}
return h('div', {
ref: root, class: 'page-markdown-actions',
onFocusout: (event: FocusEvent) => {
@@ -166,8 +173,9 @@ export const PageMarkdownActions = defineComponent({
}, [
h('div', { class: 'page-markdown-actions-dropdown' }, [
h('div', { class: 'page-markdown-actions-controls' }, [
h('button', { class: 'page-markdown-copy', type: 'button', disabled: state.value === 'copying', onClick: copy },
[icon('copy'), state.value === 'copying' ? text.copying : text.copy]),
h('button', { class: 'page-markdown-copy', type: 'button',
'aria-disabled': state.value === 'copying', 'aria-busy': state.value === 'copying', onClick: copy },
[icon('copy'), state.value === 'copying' ? text.copying : text.copy]),
h('button', { ref: toggle, class: 'page-markdown-toggle', type: 'button', 'aria-label': text.more,
'aria-haspopup': 'menu', 'aria-expanded': open.value, 'aria-controls': menuId,
onClick: () => { if (open.value) closeMenu(); else openMenu() },
@@ -182,7 +190,8 @@ export const PageMarkdownActions = defineComponent({
open.value ? h('div', { id: menuId, role: 'menu', 'aria-label': text.menu,
class: 'page-markdown-menu', onKeydown: menuKeydown }, [
h('button', { ref: menuCopy, role: 'menuitem', type: 'button', tabindex: -1,
'aria-label': text.copy, 'aria-describedby': `${menuId}-copy`, 'aria-disabled': state.value === 'copying', onClick: copy }, [
'aria-label': text.copy, 'aria-describedby': `${menuId}-copy`, 'aria-disabled': state.value === 'copying',
'aria-busy': state.value === 'copying', onClick: copy }, [
h('span', { class: 'page-markdown-menu-icon' }, [icon('copy')]),
h('span', { class: 'page-markdown-menu-text' }, [h('span', { class: 'page-markdown-menu-title' }, text.copy),
h('span', { id: `${menuId}-copy`, class: 'page-markdown-menu-description' }, text.copyDescription)]),
+8 -1
View File
@@ -16,7 +16,14 @@ export function rawMarkdownMiddleware(base: string, index: () => string): Connec
next()
return
}
const url = new URL(req.url, 'http://docs.local')
let url: URL
try {
url = new URL(req.url, 'http://docs.local')
} catch (_error) {
// Malformed targets belong to Vite's request handling, not raw-route lookup.
next()
return
}
if (!url.pathname.startsWith(base)) {
next()
return
@@ -1 +1 @@
<main><div class="page-markdown-actions"><div class="page-markdown-actions-dropdown"><div class="page-markdown-actions-controls"><button class="page-markdown-copy" type="button"><svg class="page-markdown-icon page-markdown-icon-copy" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M9 8h9a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2V10a2 2 0 0 1 2-2ZM16 4V3a1 1 0 0 0-1-1H5a2 2 0 0 0-2 2v11"></path></svg>Copy page</button><button class="page-markdown-toggle" type="button" aria-label="More page actions" aria-haspopup="menu" aria-expanded="false" aria-controls="page-markdown-v-0"><svg class="page-markdown-icon page-markdown-icon-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m6 9 6 6 6-6"></path></svg></button></div><!----></div><p class="page-markdown-actions-status" role="status" aria-atomic="true"></p></div></main>
<main><div class="page-markdown-actions"><div class="page-markdown-actions-dropdown"><div class="page-markdown-actions-controls"><button class="page-markdown-copy" type="button" aria-disabled="false" aria-busy="false"><svg class="page-markdown-icon page-markdown-icon-copy" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M9 8h9a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2V10a2 2 0 0 1 2-2ZM16 4V3a1 1 0 0 0-1-1H5a2 2 0 0 0-2 2v11"></path></svg>Copy page</button><button class="page-markdown-toggle" type="button" aria-label="More page actions" aria-haspopup="menu" aria-expanded="false" aria-controls="page-markdown-v-0"><svg class="page-markdown-icon page-markdown-icon-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m6 9 6 6 6-6"></path></svg></button></div><!----></div><p class="page-markdown-actions-status" role="status" aria-atomic="true"></p></div></main>
@@ -1 +1 @@
<main><div class="page-markdown-actions"><div class="page-markdown-actions-dropdown"><div class="page-markdown-actions-controls"><button class="page-markdown-copy" type="button"><svg class="page-markdown-icon page-markdown-icon-copy" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M9 8h9a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2V10a2 2 0 0 1 2-2ZM16 4V3a1 1 0 0 0-1-1H5a2 2 0 0 0-2 2v11"></path></svg>复制页面</button><button class="page-markdown-toggle" type="button" aria-label="更多页面操作" aria-haspopup="menu" aria-expanded="false" aria-controls="page-markdown-v-0"><svg class="page-markdown-icon page-markdown-icon-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m6 9 6 6 6-6"></path></svg></button></div><!----></div><p class="page-markdown-actions-status" role="status" aria-atomic="true"></p></div></main>
<main><div class="page-markdown-actions"><div class="page-markdown-actions-dropdown"><div class="page-markdown-actions-controls"><button class="page-markdown-copy" type="button" aria-disabled="false" aria-busy="false"><svg class="page-markdown-icon page-markdown-icon-copy" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M9 8h9a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2V10a2 2 0 0 1 2-2ZM16 4V3a1 1 0 0 0-1-1H5a2 2 0 0 0-2 2v11"></path></svg>复制页面</button><button class="page-markdown-toggle" type="button" aria-label="更多页面操作" aria-haspopup="menu" aria-expanded="false" aria-controls="page-markdown-v-0"><svg class="page-markdown-icon page-markdown-icon-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m6 9 6 6 6-6"></path></svg></button></div><!----></div><p class="page-markdown-actions-status" role="status" aria-atomic="true"></p></div></main>
@@ -1 +1 @@
<main><div class="page-markdown-actions"><div class="page-markdown-actions-dropdown"><div class="page-markdown-actions-controls"><button class="page-markdown-copy" type="button"><svg class="page-markdown-icon page-markdown-icon-copy" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M9 8h9a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2V10a2 2 0 0 1 2-2ZM16 4V3a1 1 0 0 0-1-1H5a2 2 0 0 0-2 2v11"></path></svg>Copy page</button><button class="page-markdown-toggle" type="button" aria-label="More page actions" aria-haspopup="menu" aria-expanded="true" aria-controls="page-markdown-v-0"><svg class="page-markdown-icon page-markdown-icon-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m6 9 6 6 6-6"></path></svg></button></div><div id="page-markdown-v-0" role="menu" aria-label="Page actions" class="page-markdown-menu"><button role="menuitem" type="button" tabindex="-1" aria-label="Copy page" aria-describedby="page-markdown-v-0-copy" aria-disabled="false"><span class="page-markdown-menu-icon"><svg class="page-markdown-icon page-markdown-icon-copy" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M9 8h9a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2V10a2 2 0 0 1 2-2ZM16 4V3a1 1 0 0 0-1-1H5a2 2 0 0 0-2 2v11"></path></svg></span><span class="page-markdown-menu-text"><span class="page-markdown-menu-title">Copy page</span><span id="page-markdown-v-0-copy" class="page-markdown-menu-description">Copy page as Markdown for LLMs</span></span></button><a role="menuitem" tabindex="-1" href="/en/guide/quickstart.md" target="_blank" rel="noopener" aria-label="View as Markdown (opens in a new tab)" aria-describedby="page-markdown-v-0-view"><span class="page-markdown-menu-icon"><svg class="page-markdown-icon page-markdown-icon-markdown" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M4 4h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2Zm2 12V8l3 4 3-4v8m5-8v8m-2-2 2 2 2-2"></path></svg></span><span class="page-markdown-menu-text"><span class="page-markdown-menu-title">View as Markdown<svg class="page-markdown-icon page-markdown-icon-external" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M7 17 17 7M7 7h10v10"></path></svg></span><span id="page-markdown-v-0-view" class="page-markdown-menu-description">View this page as plain text</span></span></a></div></div><p class="page-markdown-actions-status" role="status" aria-atomic="true"></p></div></main>
<main><div class="page-markdown-actions"><div class="page-markdown-actions-dropdown"><div class="page-markdown-actions-controls"><button class="page-markdown-copy" type="button" aria-disabled="false" aria-busy="false"><svg class="page-markdown-icon page-markdown-icon-copy" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M9 8h9a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2V10a2 2 0 0 1 2-2ZM16 4V3a1 1 0 0 0-1-1H5a2 2 0 0 0-2 2v11"></path></svg>Copy page</button><button class="page-markdown-toggle" type="button" aria-label="More page actions" aria-haspopup="menu" aria-expanded="true" aria-controls="page-markdown-v-0"><svg class="page-markdown-icon page-markdown-icon-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m6 9 6 6 6-6"></path></svg></button></div><div id="page-markdown-v-0" role="menu" aria-label="Page actions" class="page-markdown-menu"><button role="menuitem" type="button" tabindex="-1" aria-label="Copy page" aria-describedby="page-markdown-v-0-copy" aria-disabled="false" aria-busy="false"><span class="page-markdown-menu-icon"><svg class="page-markdown-icon page-markdown-icon-copy" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M9 8h9a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2V10a2 2 0 0 1 2-2ZM16 4V3a1 1 0 0 0-1-1H5a2 2 0 0 0-2 2v11"></path></svg></span><span class="page-markdown-menu-text"><span class="page-markdown-menu-title">Copy page</span><span id="page-markdown-v-0-copy" class="page-markdown-menu-description">Copy page as Markdown for LLMs</span></span></button><a role="menuitem" tabindex="-1" href="/en/guide/quickstart.md" target="_blank" rel="noopener" aria-label="View as Markdown (opens in a new tab)" aria-describedby="page-markdown-v-0-view"><span class="page-markdown-menu-icon"><svg class="page-markdown-icon page-markdown-icon-markdown" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M4 4h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2Zm2 12V8l3 4 3-4v8m5-8v8m-2-2 2 2 2-2"></path></svg></span><span class="page-markdown-menu-text"><span class="page-markdown-menu-title">View as Markdown<svg class="page-markdown-icon page-markdown-icon-external" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M7 17 17 7M7 7h10v10"></path></svg></span><span id="page-markdown-v-0-view" class="page-markdown-menu-description">View this page as plain text</span></span></a></div></div><p class="page-markdown-actions-status" role="status" aria-atomic="true"></p></div></main>
@@ -1 +1 @@
<main><div class="page-markdown-actions"><div class="page-markdown-actions-dropdown"><div class="page-markdown-actions-controls"><button class="page-markdown-copy" type="button"><svg class="page-markdown-icon page-markdown-icon-copy" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M9 8h9a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2V10a2 2 0 0 1 2-2ZM16 4V3a1 1 0 0 0-1-1H5a2 2 0 0 0-2 2v11"></path></svg>复制页面</button><button class="page-markdown-toggle" type="button" aria-label="更多页面操作" aria-haspopup="menu" aria-expanded="true" aria-controls="page-markdown-v-0"><svg class="page-markdown-icon page-markdown-icon-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m6 9 6 6 6-6"></path></svg></button></div><div id="page-markdown-v-0" role="menu" aria-label="页面操作" class="page-markdown-menu"><button role="menuitem" type="button" tabindex="-1" aria-label="复制页面" aria-describedby="page-markdown-v-0-copy" aria-disabled="false"><span class="page-markdown-menu-icon"><svg class="page-markdown-icon page-markdown-icon-copy" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M9 8h9a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2V10a2 2 0 0 1 2-2ZM16 4V3a1 1 0 0 0-1-1H5a2 2 0 0 0-2 2v11"></path></svg></span><span class="page-markdown-menu-text"><span class="page-markdown-menu-title">复制页面</span><span id="page-markdown-v-0-copy" class="page-markdown-menu-description">将页面以 Markdown 格式复制给 LLMs</span></span></button><a role="menuitem" tabindex="-1" href="/guide/quickstart.md" target="_blank" rel="noopener" aria-label="以 Markdown 格式查看(在新标签页打开)" aria-describedby="page-markdown-v-0-view"><span class="page-markdown-menu-icon"><svg class="page-markdown-icon page-markdown-icon-markdown" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M4 4h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2Zm2 12V8l3 4 3-4v8m5-8v8m-2-2 2 2 2-2"></path></svg></span><span class="page-markdown-menu-text"><span class="page-markdown-menu-title">以 Markdown 格式查看<svg class="page-markdown-icon page-markdown-icon-external" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M7 17 17 7M7 7h10v10"></path></svg></span><span id="page-markdown-v-0-view" class="page-markdown-menu-description">以纯文本查看此页面</span></span></a></div></div><p class="page-markdown-actions-status" role="status" aria-atomic="true"></p></div></main>
<main><div class="page-markdown-actions"><div class="page-markdown-actions-dropdown"><div class="page-markdown-actions-controls"><button class="page-markdown-copy" type="button" aria-disabled="false" aria-busy="false"><svg class="page-markdown-icon page-markdown-icon-copy" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M9 8h9a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2V10a2 2 0 0 1 2-2ZM16 4V3a1 1 0 0 0-1-1H5a2 2 0 0 0-2 2v11"></path></svg>复制页面</button><button class="page-markdown-toggle" type="button" aria-label="更多页面操作" aria-haspopup="menu" aria-expanded="true" aria-controls="page-markdown-v-0"><svg class="page-markdown-icon page-markdown-icon-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m6 9 6 6 6-6"></path></svg></button></div><div id="page-markdown-v-0" role="menu" aria-label="页面操作" class="page-markdown-menu"><button role="menuitem" type="button" tabindex="-1" aria-label="复制页面" aria-describedby="page-markdown-v-0-copy" aria-disabled="false" aria-busy="false"><span class="page-markdown-menu-icon"><svg class="page-markdown-icon page-markdown-icon-copy" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M9 8h9a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2V10a2 2 0 0 1 2-2ZM16 4V3a1 1 0 0 0-1-1H5a2 2 0 0 0-2 2v11"></path></svg></span><span class="page-markdown-menu-text"><span class="page-markdown-menu-title">复制页面</span><span id="page-markdown-v-0-copy" class="page-markdown-menu-description">将页面以 Markdown 格式复制给 LLMs</span></span></button><a role="menuitem" tabindex="-1" href="/guide/quickstart.md" target="_blank" rel="noopener" aria-label="以 Markdown 格式查看(在新标签页打开)" aria-describedby="page-markdown-v-0-view"><span class="page-markdown-menu-icon"><svg class="page-markdown-icon page-markdown-icon-markdown" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M4 4h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2Zm2 12V8l3 4 3-4v8m5-8v8m-2-2 2 2 2-2"></path></svg></span><span class="page-markdown-menu-text"><span class="page-markdown-menu-title">以 Markdown 格式查看<svg class="page-markdown-icon page-markdown-icon-external" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M7 17 17 7M7 7h10v10"></path></svg></span><span id="page-markdown-v-0-view" class="page-markdown-menu-description">以纯文本查看此页面</span></span></a></div></div><p class="page-markdown-actions-status" role="status" aria-atomic="true"></p></div></main>
@@ -0,0 +1 @@
<main><div class="page-markdown-actions"><a class="page-markdown-static" href="/deepseek-harness/en/guide/quickstart.md" target="_blank" rel="noopener" aria-label="View as Markdown (opens in a new tab)">View as Markdown<svg class="page-markdown-icon page-markdown-icon-external" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M7 17 17 7M7 7h10v10"></path></svg></a></div></main>
@@ -0,0 +1 @@
<main><div class="page-markdown-actions"><a class="page-markdown-static" href="/deepseek-harness/guide/quickstart.md" target="_blank" rel="noopener" aria-label="以 Markdown 格式查看(在新标签页打开)">以 Markdown 格式查看<svg class="page-markdown-icon page-markdown-icon-external" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M7 17 17 7M7 7h10v10"></path></svg></a></div></main>
+57 -23
View File
@@ -1,8 +1,9 @@
// @vitest-environment jsdom
/** Browser-action feedback and route-owned asynchronous clipboard work. */
import assert from 'node:assert/strict'
import { createApp, h, nextTick, reactive, ref, type App, type Slots } from 'vue'
import { fireEvent, getByRole, queryByRole, waitFor } from '@testing-library/dom'
import { createApp, createSSRApp, h, nextTick, reactive, ref, type App, type Slots } from 'vue'
import { renderToString } from 'vue/server-renderer'
import { fireEvent, getByRole, queryAllByRole, queryByRole, waitFor } from '@testing-library/dom'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import Theme from '../.vitepress/theme/index.ts'
@@ -45,9 +46,10 @@ function response(text = '# Quickstart\n\n[Reference](../reference/index.md)\n',
return new Response(text, { status, headers: { 'content-type': `${type}; charset=utf-8` } })
}
function mount() {
async function mount() {
app = createApp(Theme.Layout)
app.mount(host)
await nextTick()
}
async function openMenu() {
@@ -94,15 +96,31 @@ describe('page Markdown actions', () => {
it.each(['en-US', 'zh-CN'])('preserves the accessible controls and status in %s', async (lang) => {
data.lang.value = lang
data.frontmatter.value = { rawMarkdownPath: `${lang === 'en-US' ? 'en/' : ''}guide/quickstart.md` }
mount()
await mount()
await expect(`${host.innerHTML}\n`).toMatchFileSnapshot(`./expected/page-markdown-actions.${lang}.html`)
fireEvent.click(getByRole(host, 'button', { name: lang === 'en-US' ? 'More page actions' : '更多页面操作' }))
await nextTick()
await expect(`${host.innerHTML}\n`).toMatchFileSnapshot(`./expected/page-markdown-menu.${lang}.html`)
})
it.each(['en-US', 'zh-CN'])('offers a working raw link before hydration in %s', async (lang) => {
data.lang.value = lang
data.site.value = { base: '/deepseek-harness/' }
const path = `${lang === 'en-US' ? 'en/' : ''}guide/quickstart.md`
data.frontmatter.value = { rawMarkdownPath: path }
host.innerHTML = await renderToString(createSSRApp(Theme.Layout))
expect(queryAllByRole(host, 'button')).toHaveLength(0)
const link = getByRole(host, 'link')
expect(link.getAttribute('href')).toBe(`/deepseek-harness/${path}`)
expect(link.getAttribute('target')).toBe('_blank')
expect(link.getAttribute('aria-label')).toBe(lang === 'en-US'
? 'View as Markdown (opens in a new tab)' : '以 Markdown 格式查看(在新标签页打开)')
await expect(`${host.innerHTML}\n`).toMatchFileSnapshot(`./expected/page-markdown-static.${lang}.html`)
expect(fetchMock).not.toHaveBeenCalled()
})
it('opens from the keyboard, moves between actions, and restores focus on Escape', async () => {
mount()
await mount()
const toggle = getByRole(host, 'button', { name: 'More page actions' })
toggle.focus()
fireEvent.keyDown(toggle, { key: 'ArrowDown' })
@@ -131,7 +149,7 @@ describe('page Markdown actions', () => {
})
it('dismisses on an outside pointer, focus leaving, and a second toggle click', async () => {
mount()
await mount()
const toggle = await openMenu()
fireEvent.pointerDown(document.body)
await nextTick()
@@ -149,7 +167,7 @@ describe('page Markdown actions', () => {
it('copies from the menu in the same click and prevents another copy while it is pending', async () => {
const pending = deferred<Response>()
fetchMock.mockReturnValueOnce(pending.promise)
mount()
await mount()
const toggle = await openMenu()
fireEvent.click(getByRole(host, 'menuitem', { name: 'Copy page' }))
expect(write).toHaveBeenCalledOnce()
@@ -167,7 +185,7 @@ describe('page Markdown actions', () => {
})
it('opens the raw link from the menu and returns focus to the toggle', async () => {
mount()
await mount()
const toggle = await openMenu()
const view = getByRole(host, 'menuitem', { name: 'View as Markdown (opens in a new tab)' })
view.addEventListener('click', (event) => { event.preventDefault() }, { once: true })
@@ -179,7 +197,7 @@ describe('page Markdown actions', () => {
})
it('removes outside-pointer listeners and closes its menu when the route changes', async () => {
mount()
await mount()
const add = vi.spyOn(document, 'addEventListener')
const remove = vi.spyOn(document, 'removeEventListener')
try {
@@ -207,7 +225,7 @@ describe('page Markdown actions', () => {
data.site.value = { base }
data.frontmatter.value = { rawMarkdownPath: 'en/reference/index.md' }
route.path = `${base}en/reference/index.html?from=nav#api`
mount()
await mount()
await openMenu()
const link = getByRole(host, 'menuitem', { name: 'View as Markdown (opens in a new tab)' })
expect(link.getAttribute('href')).toBe(`${base}en/reference/index.md`)
@@ -215,10 +233,10 @@ describe('page Markdown actions', () => {
expect(fetchMock).not.toHaveBeenCalled()
})
it.each(['home', '404'])('omits actions on %s', (kind) => {
it.each(['home', '404'])('omits actions on %s', async (kind) => {
if (kind === 'home') data.frontmatter.value = { layout: false }
else data.page.value = { isNotFound: true }
mount()
await mount()
expect(queryByRole(host, 'button')).toBeNull()
expect(queryByRole(host, 'link')).toBeNull()
})
@@ -226,8 +244,9 @@ describe('page Markdown actions', () => {
it('starts clipboard.write during the click and copies the fetched plain text before reporting success', async () => {
const pending = deferred<Response>()
fetchMock.mockReturnValue(pending.promise)
mount()
await mount()
const button = getByRole(host, 'button', { name: 'Copy page' })
button.focus()
fireEvent.click(button)
expect(write).toHaveBeenCalledOnce()
expect(copied).toBeUndefined()
@@ -236,7 +255,10 @@ describe('page Markdown actions', () => {
expect(fetchMock.mock.calls[0]?.[0]).toBe('/en/guide/quickstart.md?dsh-raw=1')
expect(fetchMock.mock.calls[0]?.[1]?.signal).toBeInstanceOf(AbortSignal)
await nextTick()
expect(button).toHaveProperty('disabled', true)
expect(button).toHaveProperty('disabled', false)
expect(button.getAttribute('aria-disabled')).toBe('true')
expect(button.getAttribute('aria-busy')).toBe('true')
expect(document.activeElement).toBe(button)
expect(getByRole(host, 'status').textContent).toBe('Copying…')
pending.resolve(response())
await waitFor(() => { expect(getByRole(host, 'status').textContent).toBe('Markdown copied.') })
@@ -246,13 +268,15 @@ describe('page Markdown actions', () => {
assert(copied !== undefined)
reader.readAsText(copied)
expect(await text).toBe('# Quickstart\n\n[Reference](../reference/index.md)\n')
expect(button).toHaveProperty('disabled', false)
expect(button.getAttribute('aria-disabled')).toBe('false')
expect(button.getAttribute('aria-busy')).toBe('false')
expect(document.activeElement).toBe(button)
})
it.each(['network', '404', 'html'])('reports a %s response failure without success and allows retry', async (failure) => {
if (failure === 'network') fetchMock.mockRejectedValueOnce(new Error('offline'))
else fetchMock.mockResolvedValueOnce(response('not Markdown', failure === '404' ? 404 : 200, failure === 'html' ? 'text/html' : 'text/markdown'))
mount()
await mount()
fireEvent.click(getByRole(host, 'button', { name: 'Copy page' }))
await waitFor(() => { expect(getByRole(host, 'status').textContent).toContain('Could not load Markdown.') })
expect(copied).toBeUndefined()
@@ -262,25 +286,35 @@ describe('page Markdown actions', () => {
it('accepts static hosting that serves Markdown as plain text', async () => {
fetchMock.mockResolvedValueOnce(response('# Plain text hosting\n', 200, 'text/plain'))
mount()
await mount()
fireEvent.click(getByRole(host, 'button', { name: 'Copy page' }))
await waitFor(() => { expect(getByRole(host, 'status').textContent).toBe('Markdown copied.') })
})
it.each(['application/javascript', 'application/octet-stream', undefined])('rejects a %s content type without copying it', async (type) => {
fetchMock.mockResolvedValueOnce(new Response(new TextEncoder().encode('# Body\n'), {
headers: type === undefined ? {} : { 'content-type': type },
}))
await mount()
fireEvent.click(getByRole(host, 'button', { name: 'Copy page' }))
await waitFor(() => { expect(getByRole(host, 'status').textContent).toContain('Could not load Markdown.') })
expect(copied).toBeUndefined()
})
it('reports failure when reading the response body rejects', async () => {
const raw = response()
vi.spyOn(raw, 'text').mockRejectedValueOnce(new Error('Connection closed'))
fetchMock.mockResolvedValueOnce(raw)
mount()
await mount()
fireEvent.click(getByRole(host, 'button', { name: 'Copy page' }))
await waitFor(() => { expect(getByRole(host, 'status').textContent).toContain('Could not load Markdown.') })
expect(copied).toBeUndefined()
})
it('ignores a late successful write after its page has been replaced', async () => {
it('keeps new-page feedback independent of a previous page clipboard write', async () => {
const writeDone = deferred<void>()
write.mockImplementationOnce(() => writeDone.promise)
mount()
await mount()
fireEvent.click(getByRole(host, 'button', { name: 'Copy page' }))
route.path = '/en/reference/'
data.frontmatter.value = { rawMarkdownPath: 'en/reference/index.md' }
@@ -294,7 +328,7 @@ describe('page Markdown actions', () => {
it.each(['write', 'item'])('provides manual-copy feedback when the %s API is missing', async (missing) => {
if (missing === 'item') vi.stubGlobal('ClipboardItem', undefined)
else vi.stubGlobal('navigator', {})
mount()
await mount()
fireEvent.click(getByRole(host, 'button', { name: 'Copy page' }))
await nextTick()
expect(getByRole(host, 'status').textContent).toContain('Could not copy.')
@@ -307,7 +341,7 @@ describe('page Markdown actions', () => {
const pending = deferred<Response>()
fetchMock.mockReturnValue(pending.promise)
write.mockRejectedValueOnce(new DOMException('Denied', 'NotAllowedError'))
mount()
await mount()
fireEvent.click(getByRole(host, 'button', { name: 'Copy page' }))
await waitFor(() => { expect(getByRole(host, 'status').textContent).toContain('Could not copy.') })
expect(fetchMock.mock.calls[0]?.[1]?.signal?.aborted).toBe(true)
@@ -320,7 +354,7 @@ describe('page Markdown actions', () => {
it.each(['route', 'locale', 'unmount'])('cancels old data and ignores its completion after %s changes', async (change) => {
const pending = deferred<Response>()
fetchMock.mockReturnValueOnce(pending.promise)
mount()
await mount()
fireEvent.click(getByRole(host, 'button', { name: 'Copy page' }))
const oldWrite = activeWrite
assert(oldWrite !== undefined)
+8 -1
View File
@@ -45,7 +45,7 @@ describe('raw Markdown development middleware', () => {
expect(rawMarkdownRoute).not.toHaveBeenCalled()
})
it.each(['/missing.md', '/en/reference.md', '/%2e%2e/secrets.md'])('returns 404 for explicit unpublished route %s', (url) => {
it.each(['/missing.md', '/en/reference.md'])('returns 404 for explicit unpublished route %s', (url) => {
const { res, next } = request(`${url}?dsh-raw=1`, 'empty')
expect(res.statusCode).toBe(404)
expect(res.end).toHaveBeenCalledExactlyOnceWith()
@@ -69,6 +69,13 @@ describe('raw Markdown development middleware', () => {
]) expect(next).toHaveBeenCalledExactlyOnceWith()
})
it.each(['http://[', 'http://[::1'])('delegates an unparseable request target %s', (url) => {
const { res, next } = request(url, 'empty')
expect(next).toHaveBeenCalledExactlyOnceWith()
expect(res.end).not.toHaveBeenCalled()
expect(rawMarkdownRoute).not.toHaveBeenCalled()
})
it('does not turn a projection error into a successful raw response', () => {
vi.mocked(rawMarkdownRoute).mockImplementation(() => { throw new Error('Source missing') })
expect(() => request('/en/reference/index.md?dsh-raw=1', 'empty')).toThrow('Source missing')