Forráskód Böngészése

fix(docs): address Markdown actions review feedback

gengruilin 1 hete
szülő
commit
38b2bf2f6c

+ 2 - 2
.agents/notes/implemented/feature/2026-09-15-docs-page-markdown-actions.i18n.yaml

@@ -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

+ 3 - 3
.agents/notes/implemented/feature/2026-09-15-docs-page-markdown-actions.md

@@ -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.
 

+ 3 - 3
.agents/notes/implemented/feature/2026-09-15-docs-page-markdown-actions.zh.md

@@ -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
scripts/project-doc-site.spec.ts

@@ -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')
     }

+ 11 - 1
website/.vitepress/theme/page-markdown-actions.css

@@ -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);
+}

+ 17 - 8
website/.vitepress/theme/page-markdown-actions.ts

@@ -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
website/raw-markdown.ts

@@ -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
website/tests/expected/page-markdown-actions.en-US.html

@@ -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
website/tests/expected/page-markdown-actions.zh-CN.html

@@ -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>

A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 0 - 0
website/tests/expected/page-markdown-menu.en-US.html


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 0 - 0
website/tests/expected/page-markdown-menu.zh-CN.html


+ 1 - 0
website/tests/expected/page-markdown-static.en-US.html

@@ -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>

+ 1 - 0
website/tests/expected/page-markdown-static.zh-CN.html

@@ -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
website/tests/page-markdown-actions.spec.ts

@@ -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
website/tests/raw-markdown.spec.ts

@@ -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')

Nem az összes módosított fájl került megjelenítésre, mert túl sok fájl változott