Explorar o código

Merge pull request #4254 from deepseek-harness/feat/docs-page-markdown-actions

feat(docs): add page Markdown copy and view actions
Yichen Jiang hai 4 días
pai
achega
43e1e241ee

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

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# 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: ff4002fd69d670b743b7394544cf8486b141b5ce
+2026-09-15-docs-page-markdown-actions.zh.md: 4e2db5e55f47104d1b2f401099648cc1d6a7dc34

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

@@ -0,0 +1,35 @@
+# Agent Note: Page Markdown actions in the documentation site
+
+Status: implemented
+
+English | [中文](2026-09-15-docs-page-markdown-actions.zh.md)
+
+## Problem
+
+Readers need a visible way to obtain one documentation page as Markdown. The published text must retain its current language and projected links. A browser fetch also shares its development URL with Vite's page-module imports, so treating every Markdown request as source text breaks navigation.
+
+## Decision
+
+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.
+
+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 presses preserve focus until click activation, including in browsers that do not focus pressed controls. 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, 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
+
+**Derive the raw URL from the browser location.** Clean URLs, directory indexes, language prefixes, and deployment bases make this less reliable than the projector's known route.
+
+**Serve every Markdown fetch as source text.** Vite imports the same URLs as JavaScript modules. An explicit request marker keeps source retrieval separate while static hosting still serves the emitted file.
+
+**Fetch first, then call `writeText`.** This can work in Chromium but loses the initiating gesture across the network wait in other browsers. Promise-backed clipboard data preserves that gesture without prefetching pages.
+
+## Consequences
+
+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.
+
+**CI coverage gap.** DOM tests simulate the clipboard and do not execute native user-activation rules, actual paste, or responsive layout. Development and static-preview browser verification remains necessary, including both locales and site bases. No real model round participates in this static-document feature.

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

@@ -0,0 +1,35 @@
+# Agent Note: 文档站页面 Markdown 操作
+
+Status: implemented
+
+[English](2026-09-15-docs-page-markdown-actions.md) | 中文
+
+## 问题
+
+读者需要可见的入口来获取单页文档的 Markdown。发布的文本必须保留当前语言和投影后的链接。浏览器请求在开发环境中还与 Vite 页面模块导入共用 URL,因此将所有 Markdown 请求都作为原文处理会破坏导航。
+
+## 决策
+
+[投影器](../../../../scripts/project-doc-site.ts) 根据发布 manifest(元数据清单)为普通内容页提供 `rawMarkdownPath`。[主题](../../../../website/.vitepress/theme/index.ts) 将该路径与站点 base 组合,在正文上方提供复制和查看操作。目录页使用完整的 `index.md` 路由,使相对链接在开发环境和静态构建中保持相同的位置。首页重定向和缺失页面不显示操作。原文输出不包含投影元数据。
+
+服务端渲染的页面提供原文链接。客户端挂载后将其替换为复制按钮和菜单,使 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 有独立的拒绝处理器。
+
+## 考虑过的替代方案
+
+**根据浏览器地址推导原文 URL。** 简洁 URL、目录索引、语言前缀和部署 base 使这种方式不如投影器已知的路由可靠。
+
+**将所有 Markdown 请求都作为原文处理。** Vite 将相同的 URL 导入为 JavaScript 模块。显式请求标记区分原文获取,同时静态托管仍可直接返回已生成的文件。
+
+**先获取正文,再调用 `writeText`。** 这种方式可能在 Chromium 中有效,但其他浏览器会在网络等待后丢失最初的手势。由 Promise 承载剪贴板数据可保留该手势,无需预取页面。
+
+## 影响
+
+自动复制接受 `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) 保留独立的渲染与资源生命周期规则。
+
+**CI 覆盖缺口。** DOM 测试模拟剪贴板,不执行原生用户激活规则、实际粘贴或响应式布局。因此仍需在开发服务和静态预览中进行浏览器验证,覆盖两种语言和站点 base。此静态文档功能不涉及真实模型轮次。

+ 1 - 1
package.json

@@ -123,7 +123,7 @@
     "docs:build": "tsx website/build.ts && pnpm run verify-doc-site-fragments",
     "docs:build:mpa": "tsx website/build.ts --mpa && pnpm run verify-doc-site-fragments",
     "docs:preview": "pnpm --filter @deepseek-ai/website run preview",
-    "docs:check": "pnpm exec vitest run scripts/project-doc-site.spec.ts scripts/verify-doc-site-fragments.spec.ts website/tests/mermaid-viewer.spec.ts && pnpm run docs:build",
+    "docs:check": "pnpm exec vitest run scripts/project-doc-site.spec.ts scripts/verify-doc-site-fragments.spec.ts website/tests/mermaid-viewer.spec.ts website/tests/page-markdown-actions.spec.ts website/tests/raw-markdown.spec.ts && pnpm run docs:build",
     "website:dev": "pnpm run docs:dev",
     "website:build": "pnpm run docs:build",
     "verify-package-readme-limitations": "tsx scripts/verify-package-readme-limitations.ts",

+ 25 - 11
scripts/project-doc-site.spec.ts

@@ -566,13 +566,13 @@ describe('sidebar ordering', () => {
 
 describe('addProjectionFrontmatter', () => {
   it('adds frontmatter to an ordinary Markdown page', () => {
-    expect(addProjectionFrontmatter('# Guide\n', { source: 'docs/guide.md' })).toBe(
-      '---\neditSource: "docs/guide.md"\n---\n\n# Guide\n',
+    expect(addProjectionFrontmatter('# Guide\n', { source: 'docs/guide.md', route: 'en/guide.md', sidebar: 'en-guide' })).toBe(
+      '---\neditSource: "docs/guide.md"\nrawMarkdownPath: "en/guide.md"\n---\n\n# Guide\n',
     )
   })
 
   it('extends existing VitePress frontmatter', () => {
-    expect(addProjectionFrontmatter('---\nlayout: home\n---\n', { source: 'docs/index.md' })).toBe(
+    expect(addProjectionFrontmatter('---\nlayout: home\n---\n', { source: 'docs/index.md', route: 'index.md', sidebar: null })).toBe(
       '---\neditSource: "docs/index.md"\nlayout: home\n---\n',
     )
   })
@@ -580,9 +580,11 @@ describe('addProjectionFrontmatter', () => {
   it('adds the page-specific outline depth from the publication manifest', () => {
     expect(addProjectionFrontmatter('# Catalog\n', {
       source: 'docs/catalog.md',
+      route: 'reference/index.md',
+      sidebar: 'zh-reference',
       outline: [2, 4],
     })).toBe(
-      '---\neditSource: "docs/catalog.md"\noutline: [2,4]\n---\n\n# Catalog\n',
+      '---\neditSource: "docs/catalog.md"\nrawMarkdownPath: "reference/index.md"\noutline: [2,4]\n---\n\n# Catalog\n',
     )
   })
 })
@@ -673,9 +675,9 @@ describe('emitRawMarkdownPages', () => {
     // The real path, because image placement proves containment via realpath.
     emitRawMarkdownPages(out, { pages, repoRoot: realpathSync(root), repositoryRef: 'abc123' })
 
-    expect(readFileSync(join(out, 'a.md'), 'utf8')).toBe('[B](./reference-root/b.md) ![logo](./logo.svg)\n')
-    expect(readFileSync(join(out, 'en/a.md'), 'utf8')).toBe('[B](./reference/b.md) ![logo](./logo.svg)\n')
-    expect(readFileSync(join(out, 'reference-root/b.md'), 'utf8')).toBe('# B\n')
+    expect(readFileSync(join(out, 'a.md'), 'utf8')).toBe('\uFEFF[B](./reference-root/b.md) ![logo](./logo.svg)\n')
+    expect(readFileSync(join(out, 'en/a.md'), 'utf8')).toBe('\uFEFF[B](./reference/b.md) ![logo](./logo.svg)\n')
+    expect(readFileSync(join(out, 'reference-root/b.md'), 'utf8')).toBe('\uFEFF# B\n')
     expect(existsSync(join(out, 'logo.svg'))).toBe(true)
     expect(existsSync(join(out, 'en/logo.svg'))).toBe(true)
   })
@@ -691,7 +693,7 @@ describe('emitRawMarkdownPages', () => {
 
     emitRawMarkdownPages(out, { pages, repoRoot: root, repositoryRef: 'abc123' })
 
-    expect(readFileSync(join(out, 'index.md'), 'utf8')).toBe('# Home\n\n[A](./a.md)\n')
+    expect(readFileSync(join(out, 'index.md'), 'utf8')).toBe('\uFEFF# Home\n\n[A](./a.md)\n')
   })
 
   it('emits a parent-level alias for an index route with links recomputed', () => {
@@ -707,8 +709,20 @@ describe('emitRawMarkdownPages', () => {
 
     emitRawMarkdownPages(out, { pages, repoRoot: root, repositoryRef: 'abc123' })
 
-    expect(readFileSync(join(out, 'guide/index.md'), 'utf8')).toBe('# C\n\n[A](../a.md)\n')
-    expect(readFileSync(join(out, 'guide.md'), 'utf8')).toBe('# C\n\n[A](./a.md)\n')
+    expect(readFileSync(join(out, 'guide/index.md'), 'utf8')).toBe('\uFEFF# C\n\n[A](../a.md)\n')
+    expect(readFileSync(join(out, 'guide.md'), 'utf8')).toBe('\uFEFF# C\n\n[A](./a.md)\n')
+  })
+
+  it('identifies UTF-8 to document readers while fetch decoding preserves the Markdown body', async () => {
+    const { root, pages } = fixture()
+    const markdown = '# 中文 → Markdown\n'
+    writeFileSync(join(root, 'docs/a.md'), markdown)
+    const out = mirrorDir()
+    emitRawMarkdownPages(out, { pages, repoRoot: root, repositoryRef: 'abc123' })
+    const bytes = readFileSync(join(out, 'a.md'))
+    expect([...bytes.subarray(0, 3)]).toEqual([0xef, 0xbb, 0xbf])
+    expect(await new Response(bytes).text()).toBe(markdown)
+    expect(fromMarkdown(bytes.toString('utf8')).children[0]?.type).toBe('heading')
   })
 
   it('refuses to overwrite a file the build already carries', () => {
@@ -762,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')
     }

+ 6 - 4
scripts/project-doc-site.ts

@@ -202,15 +202,16 @@ export function rewriteMarkdown(source: string, options: RewriteMarkdownOptions)
 }
 
 /**
- * Record the canonical edit target in VitePress frontmatter.
+ * Record canonical edit and raw-Markdown targets in VitePress frontmatter.
  *
  * @param markdown Projected Markdown content.
  * @param page Publication manifest entry for the content.
  * @returns Markdown with projection-owned frontmatter fields.
  */
-export function addProjectionFrontmatter(markdown: string, page: Pick<DocsPage, 'source' | 'outline'>): string {
+export function addProjectionFrontmatter(markdown: string, page: Pick<DocsPage, 'source' | 'outline' | 'route' | 'sidebar'>): string {
   const fields = [
     `editSource: ${JSON.stringify(page.source)}`,
+    ...(page.sidebar === null ? [] : [`rawMarkdownPath: ${JSON.stringify(page.route)}`]),
     ...(page.outline === undefined ? [] : [`outline: ${JSON.stringify(page.outline)}`]),
   ].join('\n')
   if (markdown.startsWith('---\n')) return markdown.replace('---\n', `---\n${fields}\n`)
@@ -486,7 +487,8 @@ export function rawMarkdownFiles(pages: DocsPage[] = docsPages): string[] {
  * projected over the alias route so its relative links stay correct.
  * Referenced images are copied beside the pages, keeping the same relative
  * URLs valid in both trees. Existing build files stay in place, and a name
- * collision with one fails the emission.
+ * collision with one fails the emission. Markdown files carry a UTF-8 BOM so
+ * browser navigation decodes them even when static hosting omits a charset.
  *
  * @param outDir Build output directory to emit into.
  * @param context Manifest and repository inputs, defaulting to this repository.
@@ -499,7 +501,7 @@ export function emitRawMarkdownPages(outDir: string, context: ProjectionContext
   projectPagesInto(
     outDir,
     context,
-    (markdown, page) => rawMarkdownPageContent(markdown, page.source),
+    (markdown, page) => `\uFEFF${rawMarkdownPageContent(markdown, page.source)}`,
     [...context.pages, ...aliases],
   )
 }

+ 1 - 0
scripts/run-gates.ts

@@ -779,6 +779,7 @@ function docSyncLeafGates(options: {
     pnpmExec('docs-site-projection', [
       'vitest', 'run', 'scripts/project-doc-site.spec.ts', 'scripts/verify-doc-site-fragments.spec.ts',
       'website/tests/mermaid-viewer.spec.ts',
+      'website/tests/page-markdown-actions.spec.ts', 'website/tests/raw-markdown.spec.ts',
     ], {
       label: 'documentation site checks',
     }),

+ 3 - 33
website/.vitepress/config.ts

@@ -6,7 +6,8 @@ import type { DefaultTheme, PageData, SiteConfig } from 'vitepress'
 import type { ViteDevServer } from 'vite'
 import { withMermaid } from 'vitepress-plugin-mermaid'
 import { landingLink, localeCollections, orderedPages, routeLink, sectionSpec, type DocsLocale, type DocsPage, type DocsSidebar } from '../docs.ts'
-import { docsSourceFiles, emitRawMarkdownPages, llmsTxt, projectDocs, rawMarkdownRoute } from '../../scripts/project-doc-site.ts'
+import { docsSourceFiles, emitRawMarkdownPages, llmsTxt, projectDocs } from '../../scripts/project-doc-site.ts'
+import { rawMarkdownMiddleware } from '../raw-markdown.ts'
 
 projectDocs()
 
@@ -117,38 +118,7 @@ function watchCanonicalDocs(server: ViteDevServer): void {
  * their canonical sources per request, so an edit shows without a rebuild.
  */
 function serveRawMarkdown(server: ViteDevServer): void {
-  server.middlewares.use((req, res, next) => {
-    if (req.url === undefined || (req.method !== 'GET' && req.method !== 'HEAD')) {
-      next()
-      return
-    }
-    // The dev client imports page modules at these same `.md` URLs, and a
-    // module script must reach Vite's transform. Browsers declare the purpose:
-    // `script` for module imports, `document` for address-bar navigation.
-    // Header-less clients (curl, agents) read the raw twin. In-page fetch()
-    // (`empty`) also passes to Vite — a deliberate dev-only divergence that
-    // keeps Vite's own requests unbroken, while production static hosting
-    // answers such a fetch with the raw file.
-    const fetchDest = req.headers['sec-fetch-dest']
-    if (fetchDest !== undefined && fetchDest !== 'document') {
-      next()
-      return
-    }
-    const pathname = req.url.split(/[?#]/, 1)[0] ?? ''
-    const sitePath = pathname.startsWith(base) ? pathname.slice(base.length) : pathname.replace(/^\//, '')
-    if (sitePath === 'llms.txt') {
-      res.setHeader('Content-Type', 'text/plain; charset=utf-8')
-      res.end(llmsTxt({ base, ...siteIdentity }))
-      return
-    }
-    const content = sitePath.endsWith('.md') ? rawMarkdownRoute(sitePath) : undefined
-    if (content === undefined) {
-      next()
-      return
-    }
-    res.setHeader('Content-Type', 'text/markdown; charset=utf-8')
-    res.end(content)
-  })
+  server.middlewares.use(rawMarkdownMiddleware(base, () => llmsTxt({ base, ...siteIdentity })))
 }
 
 function escapeVueInterpolation(html: string): string {

+ 12 - 3
website/.vitepress/theme/index.ts

@@ -1,16 +1,18 @@
-/** Default documentation theme with a client-only Mermaid viewer. */
+/** Default documentation theme with Markdown actions and a client-only Mermaid viewer. */
 import DefaultTheme from 'vitepress/theme'
 import { useData, useRoute, type Theme } from 'vitepress'
 import { defineComponent, h, onBeforeUnmount, onMounted, watch } from 'vue'
 import type { MermaidViewer } from './mermaid-viewer.ts'
+import { PageMarkdownActions } from './page-markdown-actions.ts'
 import './mermaid-viewer.css'
+import './page-markdown-actions.css'
 
 export default {
   extends: DefaultTheme,
   Layout: defineComponent({
     name: 'DocsLayout',
     setup() {
-      const { lang, isDark } = useData()
+      const { lang, isDark, frontmatter, site, page } = useData()
       const route = useRoute()
       let viewer: MermaidViewer | undefined
       let disposed = false
@@ -23,7 +25,14 @@ export default {
         disposed = true
         viewer?.dispose()
       })
-      return () => h(DefaultTheme.Layout)
+      return () => h(DefaultTheme.Layout, null, {
+        'doc-before': () => {
+          const path: unknown = frontmatter.value.rawMarkdownPath
+          return !page.value.isNotFound && typeof path === 'string'
+            ? h(PageMarkdownActions, { key: `${route.path}:${lang.value}:${path}`, path: `${site.value.base}${path}`, lang: lang.value })
+            : null
+        },
+      })
     },
   }),
 } satisfies Theme

+ 144 - 0
website/.vitepress/theme/page-markdown-actions.css

@@ -0,0 +1,144 @@
+.page-markdown-actions {
+  display: flex;
+  flex-direction: column;
+  align-items: flex-end;
+  margin-bottom: 16px;
+  font-size: 14px;
+  line-height: 1.5;
+  color: var(--vp-c-text-1);
+}
+
+.page-markdown-actions-dropdown {
+  position: relative;
+}
+
+.page-markdown-actions-controls {
+  display: inline-flex;
+  border: 1px solid var(--vp-c-divider);
+  border-radius: 12px;
+  background: var(--vp-c-bg);
+}
+
+.page-markdown-actions-controls button {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  min-height: 36px;
+}
+
+.page-markdown-copy {
+  gap: 8px;
+  padding: 6px 12px;
+  border-radius: 11px 0 0 11px;
+  font-weight: 500;
+}
+
+.page-markdown-toggle {
+  width: 36px;
+  border-left: 1px solid var(--vp-c-divider);
+  border-radius: 0 11px 11px 0;
+  color: var(--vp-c-text-2);
+}
+
+.page-markdown-toggle[aria-expanded="true"] .page-markdown-icon {
+  transform: rotate(180deg);
+}
+
+.page-markdown-icon {
+  width: 18px;
+  height: 18px;
+  flex-shrink: 0;
+}
+
+.page-markdown-actions :is(button, a):focus-visible {
+  outline: 2px solid var(--vp-c-brand-1);
+  outline-offset: 2px;
+}
+
+.page-markdown-actions-controls button:hover,
+.page-markdown-menu > :is(button, a):is(:hover, :focus) {
+  background: var(--vp-c-default-soft);
+}
+
+.page-markdown-actions [aria-disabled="true"] {
+  cursor: wait;
+  opacity: 0.65;
+}
+
+.page-markdown-menu {
+  position: absolute;
+  z-index: 30;
+  top: calc(100% + 6px);
+  right: 0;
+  width: min(360px, calc(100vw - 48px));
+  padding: 6px;
+  border: 1px solid var(--vp-c-divider);
+  border-radius: 18px;
+  background: var(--vp-c-bg);
+  box-shadow: 0 8px 24px rgb(0 0 0 / 8%);
+}
+
+.page-markdown-menu > :is(button, a) {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  width: 100%;
+  padding: 10px;
+  border-radius: 12px;
+  text-align: left;
+}
+
+.page-markdown-menu-icon {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  width: 36px;
+  height: 36px;
+  flex-shrink: 0;
+  border: 1px solid var(--vp-c-divider);
+  border-radius: 8px;
+}
+
+.page-markdown-menu-text {
+  min-width: 0;
+}
+
+.page-markdown-menu-title {
+  display: flex;
+  align-items: center;
+  gap: 4px;
+  font-weight: 600;
+}
+
+.page-markdown-icon-external {
+  width: 14px;
+  height: 14px;
+}
+
+.page-markdown-menu-description {
+  display: block;
+  color: var(--vp-c-text-2);
+  font-size: 13px;
+  line-height: 20px;
+}
+
+.page-markdown-actions-status {
+  color: var(--vp-c-text-2);
+  font-size: 13px;
+  text-align: right;
+}
+
+.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);
+}

+ 215 - 0
website/.vitepress/theme/page-markdown-actions.ts

@@ -0,0 +1,215 @@
+/** Current-page Markdown actions; each mounted instance owns one page's copy request. */
+import { defineComponent, h, nextTick, onBeforeUnmount, onMounted, ref, useId, watch } from 'vue'
+
+type CopyState = 'idle' | 'copying' | 'copied' | 'requestFailed' | 'clipboardFailed'
+
+const messages = {
+  en: {
+    copy: 'Copy page',
+    copyDescription: 'Copy page as Markdown for LLMs',
+    view: 'View as Markdown',
+    viewDescription: 'View this page as plain text',
+    newTab: 'View as Markdown (opens in a new tab)',
+    menu: 'Page actions',
+    more: 'More page actions',
+    copying: 'Copying…',
+    copied: 'Markdown copied.',
+    requestFailed: 'Could not load Markdown. Open the page actions menu and choose View as Markdown to copy it manually.',
+    clipboardFailed: 'Could not copy. Open the page actions menu and choose View as Markdown to copy it manually.',
+  },
+  zh: {
+    copy: '复制页面',
+    copyDescription: '将页面以 Markdown 格式复制给 LLMs',
+    view: '以 Markdown 格式查看',
+    viewDescription: '以纯文本查看此页面',
+    newTab: '以 Markdown 格式查看(在新标签页打开)',
+    menu: '页面操作',
+    more: '更多页面操作',
+    copying: '正在复制…',
+    copied: '已复制 Markdown。',
+    requestFailed: '无法加载 Markdown,请打开页面操作菜单,选择“以 Markdown 格式查看”后手动复制。',
+    clipboardFailed: '复制失败,请打开页面操作菜单,选择“以 Markdown 格式查看”后手动复制。',
+  },
+} satisfies Record<'en' | 'zh', Record<Exclude<CopyState, 'idle'> | 'copy' | 'copyDescription' | 'view' | 'viewDescription' | 'newTab' | 'menu' | 'more', string>>
+
+const icons = {
+  copy: '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',
+  markdown: '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',
+  chevron: 'm6 9 6 6 6-6',
+  external: 'M7 17 17 7M7 7h10v10',
+}
+
+function icon(name: keyof typeof icons) {
+  return h('svg', { class: `page-markdown-icon page-markdown-icon-${name}`, viewBox: '0 0 24 24', fill: 'none',
+    stroke: 'currentColor', 'stroke-width': 1.7, 'stroke-linecap': 'round', 'stroke-linejoin': 'round',
+    'aria-hidden': 'true', focusable: 'false' }, [h('path', { d: icons[name] })])
+}
+
+/**
+ * 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({
+  name: 'PageMarkdownActions',
+  props: {
+    path: { type: String, required: true },
+    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>()
+    const toggle = ref<HTMLButtonElement>()
+    const menuCopy = ref<HTMLButtonElement>()
+    const menuView = ref<HTMLAnchorElement>()
+    const menuId = `page-markdown-${useId()}`
+
+    function closeMenu(restoreFocus = false) {
+      open.value = false
+      if (restoreFocus) toggle.value?.focus()
+    }
+
+    function openMenu(last = false) {
+      open.value = true
+      void nextTick(() => {
+        if (open.value) (last ? menuView.value : menuCopy.value)?.focus()
+      })
+    }
+
+    watch(open, (visible, _previous, onCleanup) => {
+      if (!visible) return
+      const dismiss = (event: PointerEvent) => {
+        if (event.target instanceof Node && !root.value?.contains(event.target)) closeMenu()
+      }
+      document.addEventListener('pointerdown', dismiss, true)
+      onCleanup(() => { document.removeEventListener('pointerdown', dismiss, true) })
+    })
+
+    function menuKeydown(event: KeyboardEvent) {
+      const items = [menuCopy.value, menuView.value]
+      const index = items.findIndex(item => item === event.target)
+      const movement: Partial<Record<string, number>> = {
+        ArrowDown: (index + 1) % items.length, ArrowUp: (index + items.length - 1) % items.length,
+        Home: 0, End: items.length - 1 }
+      const target = movement[event.key]
+      if (target !== undefined) {
+        event.preventDefault()
+        items[target]?.focus()
+      } else if (event.key === ' ' && event.target === menuView.value) {
+        event.preventDefault()
+        menuView.value.click()
+      }
+    }
+    let controller: AbortController | undefined
+    onBeforeUnmount(() => {
+      open.value = false
+      controller?.abort()
+    })
+
+    async function copy(): Promise<void> {
+      if (state.value === 'copying') return
+      if (open.value) closeMenu(true)
+      state.value = 'copying'
+      const clipboard: Partial<Clipboard> | undefined = (navigator as Partial<Navigator>).clipboard
+      if (typeof ClipboardItem === 'undefined' || clipboard?.write === undefined) {
+        state.value = 'clipboardFailed'
+        return
+      }
+      const request = new AbortController()
+      controller = request
+      const outcome = { requestFailed: false }
+      const content = fetch(`${props.path}?dsh-raw=1`, { signal: request.signal })
+        .then(async (response) => {
+          const type = response.headers.get('content-type')?.split(';')[0]?.trim().toLowerCase()
+          if (!response.ok || (type !== 'text/markdown' && type !== 'text/plain')) {
+            throw new Error('Markdown response unavailable')
+          }
+          const text = await response.text()
+          request.signal.throwIfAborted()
+          return new Blob([text], { type: 'text/plain' })
+        })
+        .catch((error: unknown) => {
+          outcome.requestFailed = true
+          throw error
+        })
+      void content.catch((_error: unknown) => {
+        // A denied write need not consume this promise; feedback belongs to the write outcome.
+      })
+      try {
+        await clipboard.write([new ClipboardItem({ 'text/plain': content })])
+        state.value = 'copied'
+      } catch (_error) {
+        // Browser failures use localized feedback; their implementation-specific text is not user copy.
+        state.value = outcome.requestFailed ? 'requestFailed' : 'clipboardFailed'
+      } finally {
+        request.abort()
+        if (controller === request) controller = undefined
+      }
+    }
+
+    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) => {
+          if (!(event.relatedTarget instanceof Node) || !root.value?.contains(event.relatedTarget)) closeMenu()
+        },
+        onKeydown: (event: KeyboardEvent) => {
+          if (open.value && event.key === 'Escape') {
+            event.preventDefault()
+            event.stopPropagation()
+            closeMenu(true)
+          }
+        },
+      }, [
+        h('div', { class: 'page-markdown-actions-dropdown' }, [
+          h('div', { class: 'page-markdown-actions-controls' }, [
+            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,
+              onMousedown: (event: MouseEvent) => { if (open.value) event.preventDefault() },
+              onClick: () => { if (open.value) closeMenu(true); else openMenu() },
+              onKeydown: (event: KeyboardEvent) => {
+                if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
+                  event.preventDefault()
+                  openMenu(event.key === 'ArrowUp')
+                }
+              },
+            }, [icon('chevron')]),
+          ]),
+          open.value ? h('div', { id: menuId, role: 'menu', 'aria-label': text.menu,
+            class: 'page-markdown-menu', onKeydown: menuKeydown,
+            // Keep focus until click activation in browsers that do not focus pressed controls.
+            onMousedown: (event: MouseEvent) => { event.preventDefault() } }, [
+            h('button', { ref: menuCopy, role: 'menuitem', type: 'button', tabindex: -1,
+              '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)]),
+            ]),
+            h('a', { ref: menuView, role: 'menuitem', tabindex: -1, href: props.path, target: '_blank', rel: 'noopener',
+              'aria-label': text.newTab, 'aria-describedby': `${menuId}-view`, onClick: () => { closeMenu(true) } }, [
+              h('span', { class: 'page-markdown-menu-icon' }, [icon('markdown')]),
+              h('span', { class: 'page-markdown-menu-text' }, [h('span', { class: 'page-markdown-menu-title' }, [text.view, icon('external')]),
+                h('span', { id: `${menuId}-view`, class: 'page-markdown-menu-description' }, text.viewDescription)]),
+            ]),
+          ]) : null,
+        ]),
+        h('p', { class: 'page-markdown-actions-status', role: 'status', 'aria-atomic': 'true' },
+          state.value === 'idle' ? '' : text[state.value]),
+      ])
+    }
+  },
+})

+ 2 - 0
website/AGENTS.md

@@ -16,4 +16,6 @@ The build also emits each route's raw-Markdown twin (with a parent-level alias p
 
 Run `pnpm docs:check` after changing this subtree; the gate rejects additional non-ignored Markdown under `website/`.
 
+Content-page Markdown actions use the projector's `rawMarkdownPath` and the site base, including full `index.md` routes. Copy fetches carry `?dsh-raw=1`; development script imports still belong to Vite. Keep clipboard work within its initiating gesture and page lifetime; the [Markdown actions decision](../.agents/notes/implemented/feature/2026-09-15-docs-page-markdown-actions.md) owns browser limitations and verification.
+
 The default-theme extension owns the Mermaid fullscreen viewer. Keep its enhancements separate from Markdown projection and preserve the original SVG. Route, language, theme, and rendered-SVG changes close the active view; theme disposal releases every observer, listener, and scroll lock. The [viewer decision](../.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.md) explains SVG isolation and verification.

+ 52 - 0
website/raw-markdown.ts

@@ -0,0 +1,52 @@
+/** Development responses for published Markdown and the documentation index. */
+import type { Connect } from 'vite'
+import { rawMarkdownRoute } from '../scripts/project-doc-site.ts'
+
+/**
+ * Serve published source text while preserving Vite's Markdown module imports.
+ * Explicit `dsh-raw=1` fetches return 404 for unpublished Markdown routes.
+ *
+ * @param base Site URL prefix with leading and trailing slashes.
+ * @param index Render the current llms.txt index.
+ * @returns Middleware for the Vite development server.
+ */
+export function rawMarkdownMiddleware(base: string, index: () => string): Connect.NextHandleFunction {
+  return (req, res, next) => {
+    if (req.url === undefined || (req.method !== 'GET' && req.method !== 'HEAD')) {
+      next()
+      return
+    }
+    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
+    }
+    const path = url.pathname.slice(base.length)
+    const explicit = path.endsWith('.md') && url.searchParams.get('dsh-raw') === '1'
+    const destination = req.headers['sec-fetch-dest']
+    // Script imports always belong to Vite, including imports carrying a query.
+    if (destination !== undefined && destination !== 'document' && !(destination === 'empty' && explicit)) {
+      next()
+      return
+    }
+    const content = path === 'llms.txt' ? index() : path.endsWith('.md') ? rawMarkdownRoute(path) : undefined
+    if (content === undefined) {
+      if (!explicit) {
+        next()
+        return
+      }
+      res.statusCode = 404
+      res.end()
+      return
+    }
+    res.setHeader('Content-Type', `${path === 'llms.txt' ? 'text/plain' : 'text/markdown'}; charset=utf-8`)
+    res.end(req.method === 'HEAD' ? undefined : content)
+  }
+}

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

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

@@ -0,0 +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" 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 diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
website/tests/expected/page-markdown-menu.en-US.html


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 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>

+ 413 - 0
website/tests/page-markdown-actions.spec.ts

@@ -0,0 +1,413 @@
+// @vitest-environment jsdom
+/** Browser-action feedback and route-owned asynchronous clipboard work. */
+import assert from 'node:assert/strict'
+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'
+
+vi.mock('vitepress', () => ({ useData: () => data, useRoute: () => route }))
+vi.mock('vitepress/theme', () => ({ default: { Layout: {
+  setup: (_props: unknown, { slots }: { slots: Slots }) => () => h('main', null, slots['doc-before']?.()),
+} } }))
+vi.mock('../.vitepress/theme/mermaid-viewer.ts', () => ({ installMermaidViewer: () => ({ refresh() {}, dispose() {} }) }))
+
+const data = {
+  lang: ref('en-US'), isDark: ref(false),
+  frontmatter: ref<Record<string, unknown>>({}),
+  site: ref({ base: '/' }), page: ref({ isNotFound: false }),
+}
+const route = reactive({ path: '/en/guide/quickstart' })
+const fetchMock = vi.fn<typeof fetch>()
+const write = vi.fn<(items: ClipboardItem[]) => Promise<void>>()
+let app: App | undefined
+let host: HTMLDivElement
+let copied: Blob | undefined
+let activeWrite: Promise<void> | undefined
+const deferredWork: { promise: Promise<unknown>; reject: (reason: unknown) => void }[] = []
+
+class TestClipboardItem {
+  constructor(readonly data: Record<string, Promise<Blob>>) {}
+}
+
+function deferred<T>() {
+  let resolve!: (value: T) => void
+  let reject!: (reason: unknown) => void
+  const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej })
+  deferredWork.push({ promise, reject })
+  void promise.catch((_error: unknown) => {
+    // Teardown can reject a barrier before an assertion reaches its consumer.
+  })
+  return { promise, resolve, reject }
+}
+
+function response(text = '# Quickstart\n\n[Reference](../reference/index.md)\n', status = 200, type = 'text/markdown') {
+  return new Response(text, { status, headers: { 'content-type': `${type}; charset=utf-8` } })
+}
+
+async function mount() {
+  app = createApp(Theme.Layout)
+  app.mount(host)
+  await nextTick()
+}
+
+async function openMenu() {
+  const toggle = getByRole(host, 'button', { name: 'More page actions' })
+  fireEvent.click(toggle)
+  await nextTick()
+  return toggle
+}
+
+beforeEach(() => {
+  host = document.createElement('div')
+  document.body.append(host)
+  data.lang.value = 'en-US'
+  data.site.value = { base: '/' }
+  data.frontmatter.value = { rawMarkdownPath: 'en/guide/quickstart.md' }
+  data.page.value = { isNotFound: false }
+  route.path = '/en/guide/quickstart'
+  copied = undefined
+  activeWrite = undefined
+  fetchMock.mockReset().mockResolvedValue(response())
+  write.mockReset().mockImplementation((items) => {
+    const item = items[0] as unknown as TestClipboardItem
+    const content = item.data['text/plain']
+    assert(content !== undefined)
+    activeWrite = content.then((blob) => { copied = blob })
+    return activeWrite
+  })
+  vi.stubGlobal('fetch', fetchMock)
+  vi.stubGlobal('ClipboardItem', TestClipboardItem)
+  vi.stubGlobal('navigator', { clipboard: { write } })
+})
+
+afterEach(async () => {
+  app?.unmount()
+  app = undefined
+  const work = deferredWork.splice(0)
+  for (const pending of work) pending.reject(new Error('Test disposed'))
+  await Promise.allSettled([...work.map(pending => pending.promise), activeWrite])
+  host.remove()
+  vi.unstubAllGlobals()
+})
+
+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` }
+    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 () => {
+    await mount()
+    const toggle = getByRole(host, 'button', { name: 'More page actions' })
+    toggle.focus()
+    fireEvent.keyDown(toggle, { key: 'ArrowDown' })
+    await nextTick()
+    const copy = getByRole(host, 'menuitem', { name: 'Copy page' })
+    const view = getByRole(host, 'menuitem', { name: 'View as Markdown (opens in a new tab)' })
+    expect(document.activeElement).toBe(copy)
+    expect(toggle.getAttribute('aria-expanded')).toBe('true')
+    fireEvent.keyDown(copy, { key: 'ArrowUp' })
+    expect(document.activeElement).toBe(view)
+    fireEvent.keyDown(view, { key: 'ArrowDown' })
+    expect(document.activeElement).toBe(copy)
+    fireEvent.keyDown(copy, { key: 'End' })
+    expect(document.activeElement).toBe(view)
+    fireEvent.keyDown(view, { key: 'Home' })
+    expect(document.activeElement).toBe(copy)
+    fireEvent.keyDown(copy, { key: 'Escape' })
+    await nextTick()
+    expect(queryByRole(host, 'menu')).toBeNull()
+    expect(document.activeElement).toBe(toggle)
+    expect(toggle.getAttribute('aria-expanded')).toBe('false')
+    fireEvent.keyDown(toggle, { key: 'ArrowUp' })
+    await nextTick()
+    expect(document.activeElement).toBe(getByRole(host, 'menuitem', { name: 'View as Markdown (opens in a new tab)' }))
+    expect(fetchMock).not.toHaveBeenCalled()
+  })
+
+  it('dismisses on an outside pointer, focus leaving, and a second toggle click', async () => {
+    await mount()
+    const toggle = await openMenu()
+    fireEvent.pointerDown(document.body)
+    await nextTick()
+    expect(queryByRole(host, 'menu')).toBeNull()
+    await openMenu()
+    fireEvent.focusOut(getByRole(host, 'menuitem', { name: 'Copy page' }), { relatedTarget: document.body })
+    await nextTick()
+    expect(queryByRole(host, 'menu')).toBeNull()
+    await openMenu()
+    fireEvent.click(toggle)
+    await nextTick()
+    expect(queryByRole(host, 'menu')).toBeNull()
+  })
+
+  it.each(['copy', 'view', 'toggle'])('keeps %s activation available when pointer clicks do not focus controls', async (action) => {
+    await mount()
+    const toggle = await openMenu()
+    const target = action === 'toggle' ? toggle : getByRole(host, 'menuitem', {
+      name: action === 'copy' ? 'Copy page' : 'View as Markdown (opens in a new tab)',
+    })
+    let activated = false
+    target.addEventListener('click', (event) => {
+      activated = true
+      if (action === 'view') event.preventDefault()
+    }, { once: true })
+    fireEvent.pointerDown(target)
+    // Safari can blur the focused menu item without focusing the pressed control.
+    if (fireEvent.mouseDown(target)) (document.activeElement as HTMLElement).blur()
+    await nextTick()
+    expect(target.isConnected).toBe(true)
+    expect(queryByRole(host, 'menu')).not.toBeNull()
+    fireEvent.mouseUp(target)
+    fireEvent.click(target)
+    await nextTick()
+    expect(activated).toBe(true)
+    expect(queryByRole(host, 'menu')).toBeNull()
+    expect(document.activeElement).toBe(toggle)
+    if (action === 'copy') {
+      await waitFor(() => { expect(getByRole(host, 'status').textContent).toBe('Markdown copied.') })
+      expect(write).toHaveBeenCalledOnce()
+    } else {
+      expect(write).not.toHaveBeenCalled()
+    }
+  })
+
+  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)
+    await mount()
+    const toggle = await openMenu()
+    fireEvent.click(getByRole(host, 'menuitem', { name: 'Copy page' }))
+    expect(write).toHaveBeenCalledOnce()
+    await nextTick()
+    expect(queryByRole(host, 'menu')).toBeNull()
+    expect(document.activeElement).toBe(toggle)
+    await openMenu()
+    const copy = getByRole(host, 'menuitem', { name: 'Copy page' })
+    expect(copy.getAttribute('aria-disabled')).toBe('true')
+    fireEvent.click(copy)
+    expect(write).toHaveBeenCalledOnce()
+    expect(getByRole(host, 'menuitem', { name: 'View as Markdown (opens in a new tab)' }).getAttribute('href')).toBe('/en/guide/quickstart.md')
+    pending.resolve(response())
+    await waitFor(() => { expect(getByRole(host, 'status').textContent).toBe('Markdown copied.') })
+  })
+
+  it('opens the raw link from the menu and returns focus to the toggle', async () => {
+    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 })
+    fireEvent.keyDown(view, { key: ' ' })
+    await nextTick()
+    expect(queryByRole(host, 'menu')).toBeNull()
+    expect(document.activeElement).toBe(toggle)
+    expect(fetchMock).not.toHaveBeenCalled()
+  })
+
+  it('removes outside-pointer listeners and closes its menu when the route changes', async () => {
+    await mount()
+    const add = vi.spyOn(document, 'addEventListener')
+    const remove = vi.spyOn(document, 'removeEventListener')
+    try {
+      await openMenu()
+      const listener = add.mock.calls.find(([name]) => name === 'pointerdown')?.[1]
+      expect(listener).toBeTypeOf('function')
+      route.path = '/en/reference/'
+      data.frontmatter.value = { rawMarkdownPath: 'en/reference/index.md' }
+      await nextTick()
+      expect(queryByRole(host, 'menu')).toBeNull()
+      expect(remove).toHaveBeenCalledWith('pointerdown', listener, true)
+      await openMenu()
+      app?.unmount()
+      app = undefined
+      expect(remove.mock.calls.filter(([name]) => name === 'pointerdown')).toHaveLength(2)
+      fireEvent.pointerDown(document.body)
+      expect(host.textContent).toBe('')
+    } finally {
+      add.mockRestore()
+      remove.mockRestore()
+    }
+  })
+
+  it.each(['/', '/deepseek-harness/'])('uses the manifest index route under base %s, independent of the visible URL', async (base) => {
+    data.site.value = { base }
+    data.frontmatter.value = { rawMarkdownPath: 'en/reference/index.md' }
+    route.path = `${base}en/reference/index.html?from=nav#api`
+    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`)
+    expect(link.getAttribute('target')).toBe('_blank')
+    expect(fetchMock).not.toHaveBeenCalled()
+  })
+
+  it.each(['home', '404'])('omits actions on %s', async (kind) => {
+    if (kind === 'home') data.frontmatter.value = { layout: false }
+    else data.page.value = { isNotFound: true }
+    await mount()
+    expect(queryByRole(host, 'button')).toBeNull()
+    expect(queryByRole(host, 'link')).toBeNull()
+  })
+
+  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)
+    await mount()
+    const button = getByRole(host, 'button', { name: 'Copy page' })
+    button.focus()
+    fireEvent.click(button)
+    expect(write).toHaveBeenCalledOnce()
+    expect(copied).toBeUndefined()
+    fireEvent.click(button)
+    expect(fetchMock).toHaveBeenCalledOnce()
+    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', 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.') })
+    expect(copied?.type).toBe('text/plain')
+    const reader = new FileReader()
+    const text = new Promise((resolve) => { reader.addEventListener('load', () => { resolve(reader.result) }, { once: true }) })
+    assert(copied !== undefined)
+    reader.readAsText(copied)
+    expect(await text).toBe('# Quickstart\n\n[Reference](../reference/index.md)\n')
+    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'))
+    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()
+    fireEvent.click(getByRole(host, 'button', { name: 'Copy page' }))
+    await waitFor(() => { expect(getByRole(host, 'status').textContent).toBe('Markdown copied.') })
+  })
+
+  it('accepts static hosting that serves Markdown as plain text', async () => {
+    fetchMock.mockResolvedValueOnce(response('# Plain text hosting\n', 200, 'text/plain'))
+    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)
+    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('keeps new-page feedback independent of a previous page clipboard write', async () => {
+    const writeDone = deferred<void>()
+    write.mockImplementationOnce(() => writeDone.promise)
+    await mount()
+    fireEvent.click(getByRole(host, 'button', { name: 'Copy page' }))
+    route.path = '/en/reference/'
+    data.frontmatter.value = { rawMarkdownPath: 'en/reference/index.md' }
+    await nextTick()
+    writeDone.resolve()
+    await writeDone.promise
+    await nextTick()
+    expect(getByRole(host, 'status').textContent).toBe('')
+  })
+
+  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', {})
+    await mount()
+    fireEvent.click(getByRole(host, 'button', { name: 'Copy page' }))
+    await nextTick()
+    expect(getByRole(host, 'status').textContent).toContain('Could not copy.')
+    await openMenu()
+    expect(getByRole(host, 'menuitem', { name: 'View as Markdown (opens in a new tab)' })).toBeTruthy()
+    expect(fetchMock).not.toHaveBeenCalled()
+  })
+
+  it('aborts an unconsumed fetch when the clipboard rejects before consuming its data', async () => {
+    const pending = deferred<Response>()
+    fetchMock.mockReturnValue(pending.promise)
+    write.mockRejectedValueOnce(new DOMException('Denied', 'NotAllowedError'))
+    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)
+    pending.reject(new DOMException('Aborted', 'AbortError'))
+    await pending.promise.catch((error: unknown) => { void error })
+    await nextTick()
+    expect(getByRole(host, 'status').textContent).toContain('Could not copy.')
+  })
+
+  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)
+    await mount()
+    fireEvent.click(getByRole(host, 'button', { name: 'Copy page' }))
+    const oldWrite = activeWrite
+    assert(oldWrite !== undefined)
+    const signal = fetchMock.mock.calls[0]?.[1]?.signal
+    if (change === 'unmount') { app?.unmount(); app = undefined }
+    else {
+      route.path = change === 'route' ? '/en/reference/' : '/guide/quickstart'
+      data.lang.value = change === 'route' ? 'en-US' : 'zh-CN'
+      data.frontmatter.value = { rawMarkdownPath: change === 'route' ? 'en/reference/index.md' : 'guide/quickstart.md' }
+    }
+    await nextTick()
+    expect(signal?.aborted).toBe(true)
+    pending.resolve(response('# Old page\n'))
+    await expect(oldWrite).rejects.toThrow()
+    await nextTick()
+    expect(copied).toBeUndefined()
+    if (change === 'unmount') expect(host.textContent).toBe('')
+    else {
+      expect(getByRole(host, 'status').textContent).toBe('')
+      fireEvent.click(getByRole(host, 'button', { name: change === 'route' ? 'Copy page' : '复制页面' }))
+      await waitFor(() => { expect(getByRole(host, 'status').textContent).toBe(change === 'route' ? 'Markdown copied.' : '已复制 Markdown。') })
+      expect(fetchMock.mock.calls[1]?.[0]).toBe(change === 'route' ? '/en/reference/index.md?dsh-raw=1' : '/guide/quickstart.md?dsh-raw=1')
+    }
+  })
+})

+ 83 - 0
website/tests/raw-markdown.spec.ts

@@ -0,0 +1,83 @@
+/** Raw requests must not replace Vite's page-module responses. */
+import type { IncomingMessage, ServerResponse } from 'node:http'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { rawMarkdownRoute } from '../../scripts/project-doc-site.ts'
+import { rawMarkdownMiddleware } from '../raw-markdown.ts'
+
+vi.mock('../../scripts/project-doc-site.ts', () => ({ rawMarkdownRoute: vi.fn() }))
+
+function request(url: string | undefined, destination?: string, method = 'GET', base = '/') {
+  const req = { url, method, headers: { 'sec-fetch-dest': destination } } as IncomingMessage
+  const res = { statusCode: 200, setHeader: vi.fn(), end: vi.fn() }
+  const next = vi.fn()
+  rawMarkdownMiddleware(base, () => '# Index\n')(req, res as unknown as ServerResponse, next)
+  return { res, next }
+}
+
+beforeEach(() => {
+  vi.mocked(rawMarkdownRoute).mockReset().mockImplementation(path => path === 'en/reference/index.md' ? '# Reference\n' : undefined)
+})
+
+describe('raw Markdown development middleware', () => {
+  it.each([undefined, 'document', 'empty'])('serves an explicit raw fetch with destination %s', (destination) => {
+    const { res, next } = request('/deepseek-harness/en/reference/index.md?dsh-raw=1#section', destination, 'GET', '/deepseek-harness/')
+    expect(rawMarkdownRoute).toHaveBeenCalledWith('en/reference/index.md')
+    expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'text/markdown; charset=utf-8')
+    expect(res.end).toHaveBeenCalledWith('# Reference\n')
+    expect(next).not.toHaveBeenCalled()
+  })
+
+  it.each([undefined, 'document'])('retains ordinary raw navigation with destination %s', (destination) => {
+    expect(request('/en/reference/index.md', destination).res.end).toHaveBeenCalledWith('# Reference\n')
+  })
+
+  it.each([
+    ['/en/reference/index.md', 'script'],
+    ['/en/reference/index.md?dsh-raw=1', 'script'],
+    ['/en/reference/index.md', 'empty'],
+    ['/en/reference/index.md?dsh-raw=0', 'empty'],
+    ['/en/reference/index.md?dsh-raw=1', 'style'],
+    ['/en/reference/index.md?dsh-raw=1', 'image'],
+  ])('delegates %s (%s) to Vite', (url, destination) => {
+    const { res, next } = request(url, destination)
+    expect(next).toHaveBeenCalledExactlyOnceWith()
+    expect(res.end).not.toHaveBeenCalled()
+    expect(rawMarkdownRoute).not.toHaveBeenCalled()
+  })
+
+  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()
+    expect(next).not.toHaveBeenCalled()
+  })
+
+  it('preserves llms.txt and omits bodies for HEAD', () => {
+    expect(request('/llms.txt').res.end).toHaveBeenCalledWith('# Index\n')
+    for (const url of ['/llms.txt', '/en/reference/index.md?dsh-raw=1']) {
+      const { res } = request(url, undefined, 'HEAD')
+      expect(res.setHeader).toHaveBeenCalledOnce()
+      expect(res.end).toHaveBeenCalledWith(undefined)
+    }
+  })
+
+  it('delegates missing paths, unrelated routes, other bases and non-read methods', () => {
+    for (const { next } of [
+      request(undefined), request('/missing.md'), request('/guide.html'),
+      request('/en/reference/index.md?dsh-raw=1', 'empty', 'GET', '/deepseek-harness/'),
+      request('/en/reference/index.md?dsh-raw=1', 'empty', 'POST'),
+    ]) 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')
+  })
+})

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio