Просмотр исходного кода

feat(docs): add page Markdown copy and view actions

gengruilin 2 недель назад
Родитель
Сommit
23135e62dc

+ 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: d89b248496a9b80801f4d213ec2f845c7758cb52
+2026-09-15-docs-page-markdown-actions.zh.md: 6d2ebc9d72c3fadd46064034d67970723179ccba

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

@@ -0,0 +1,33 @@
+# 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.
+
+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. 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.
+
+## 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 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.

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

@@ -0,0 +1,33 @@
+# 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` 路由,使相对链接在开发环境和静态构建中保持相同的位置。首页重定向和缺失页面不显示操作。原文输出不包含投影元数据。
+
+[开发中间件](../../../../website/raw-markdown.ts) 接受带有显式 `?dsh-raw=1` 标记的浏览器请求,对未发布的原文路由返回 404。脚本导入始终交给 Vite。查看链接在新标签页打开普通原文 URL;复制按需读取相同的投影正文。两种操作都不从渲染后的 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 承载剪贴板数据可保留该手势,无需预取页面。
+
+## 影响
+
+自动复制需要浏览器在安全上下文中提供异步 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",

+ 6 - 4
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',
     )
   })
 })

+ 3 - 2
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`)

+ 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

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

@@ -0,0 +1,48 @@
+.page-markdown-actions {
+  margin-bottom: 16px;
+  font-size: 13px;
+  line-height: 1.5;
+  color: var(--vp-c-text-2);
+}
+
+.page-markdown-actions-controls {
+  display: flex;
+  flex-wrap: wrap;
+  justify-content: flex-end;
+  gap: 4px 12px;
+}
+
+.page-markdown-actions-controls :is(button, a) {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  min-height: 36px;
+  padding: 4px 8px;
+  border: 1px solid var(--vp-c-divider);
+  border-radius: 6px;
+  color: var(--vp-c-text-2);
+  background: var(--vp-c-bg);
+}
+
+.page-markdown-actions-controls :is(button, a):hover {
+  color: var(--vp-c-brand-1);
+  border-color: var(--vp-c-brand-1);
+}
+
+.page-markdown-actions-controls :is(button, a):focus-visible {
+  outline: 2px solid var(--vp-c-brand-1);
+  outline-offset: 3px;
+}
+
+.page-markdown-actions-controls button:disabled {
+  cursor: wait;
+  opacity: 0.65;
+}
+
+.page-markdown-actions-status {
+  text-align: right;
+}
+
+.page-markdown-actions-status:not(:empty) {
+  margin-top: 8px;
+}

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

@@ -0,0 +1,101 @@
+/** Current-page Markdown actions; each mounted instance owns one page's copy request. */
+import { defineComponent, h, onBeforeUnmount, ref } from 'vue'
+
+type CopyState = 'idle' | 'copying' | 'copied' | 'requestFailed' | 'clipboardFailed'
+
+const messages = {
+  en: {
+    copy: 'Copy Markdown',
+    view: 'View Markdown',
+    newTab: 'View Markdown (opens in a new tab)',
+    copying: 'Copying…',
+    copied: 'Markdown copied.',
+    requestFailed: 'Could not load Markdown. Open View Markdown to copy it manually.',
+    clipboardFailed: 'Could not copy. Open View Markdown to copy it manually.',
+  },
+  zh: {
+    copy: '复制 Markdown',
+    view: '查看 Markdown',
+    newTab: '查看 Markdown(在新标签页打开)',
+    copying: '正在复制…',
+    copied: '已复制 Markdown。',
+    requestFailed: '无法加载 Markdown,请打开“查看 Markdown”手动复制。',
+    clipboardFailed: '复制失败,请打开“查看 Markdown”手动复制。',
+  },
+} satisfies Record<'en' | 'zh', Record<Exclude<CopyState, 'idle'> | 'copy' | 'view' | 'newTab', string>>
+
+/**
+ * Actions keyed by the owning layout to the current route and language.
+ * 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 state = ref<CopyState>('idle')
+    let controller: AbortController | undefined
+    let disposed = false
+    onBeforeUnmount(() => {
+      disposed = true
+      controller?.abort()
+    })
+
+    async function copy(): Promise<void> {
+      if (state.value === 'copying') return
+      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 })])
+        if (!disposed) 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'
+      } finally {
+        request.abort()
+        if (controller === request) controller = undefined
+      }
+    }
+
+    return () => {
+      const text = messages[props.lang.startsWith('zh') ? 'zh' : 'en']
+      return h('div', { class: 'page-markdown-actions' }, [
+        h('div', { class: 'page-markdown-actions-controls' }, [
+          h('button', { type: 'button', disabled: state.value === 'copying', onClick: copy },
+            state.value === 'copying' ? text.copying : text.copy),
+          h('a', { href: props.path, target: '_blank', rel: 'noopener', 'aria-label': text.newTab }, [
+            text.view, h('span', { 'aria-hidden': 'true' }, ' ↗'),
+          ]),
+        ]),
+        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.

+ 45 - 0
website/raw-markdown.ts

@@ -0,0 +1,45 @@
+/** 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
+    }
+    const url = new URL(req.url, 'http://docs.local')
+    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-controls"><button type="button">Copy Markdown</button><a href="/en/guide/quickstart.md" target="_blank" rel="noopener" aria-label="View Markdown (opens in a new tab)">View Markdown<span aria-hidden="true"> ↗</span></a></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-controls"><button type="button">复制 Markdown</button><a href="/guide/quickstart.md" target="_blank" rel="noopener" aria-label="查看 Markdown(在新标签页打开)">查看 Markdown<span aria-hidden="true"> ↗</span></a></div><p class="page-markdown-actions-status" role="status" aria-atomic="true"></p></div></main>

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

@@ -0,0 +1,234 @@
+// @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 { 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` } })
+}
+
+function mount() {
+  app = createApp(Theme.Layout)
+  app.mount(host)
+}
+
+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` }
+    mount()
+    await expect(`${host.innerHTML}\n`).toMatchFileSnapshot(`./expected/page-markdown-actions.${lang}.html`)
+  })
+
+  it.each(['/', '/deepseek-harness/'])('uses the manifest index route under base %s, independent of the visible URL', (base) => {
+    data.site.value = { base }
+    data.frontmatter.value = { rawMarkdownPath: 'en/reference/index.md' }
+    route.path = `${base}en/reference/index.html?from=nav#api`
+    mount()
+    const link = getByRole(host, 'link', { name: 'View 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', (kind) => {
+    if (kind === 'home') data.frontmatter.value = { layout: false }
+    else data.page.value = { isNotFound: true }
+    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)
+    mount()
+    const button = getByRole(host, 'button', { name: 'Copy Markdown' })
+    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', true)
+    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).toHaveProperty('disabled', false)
+  })
+
+  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()
+    fireEvent.click(getByRole(host, 'button'))
+    await waitFor(() => { expect(getByRole(host, 'status').textContent).toContain('Could not load Markdown.') })
+    expect(copied).toBeUndefined()
+    fireEvent.click(getByRole(host, 'button'))
+    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'))
+    mount()
+    fireEvent.click(getByRole(host, 'button'))
+    await waitFor(() => { expect(getByRole(host, 'status').textContent).toBe('Markdown copied.') })
+  })
+
+  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()
+    fireEvent.click(getByRole(host, 'button'))
+    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 () => {
+    const writeDone = deferred<void>()
+    write.mockImplementationOnce(() => writeDone.promise)
+    mount()
+    fireEvent.click(getByRole(host, 'button'))
+    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', {})
+    mount()
+    fireEvent.click(getByRole(host, 'button'))
+    await nextTick()
+    expect(getByRole(host, 'status').textContent).toContain('Could not copy.')
+    expect(getByRole(host, 'link')).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'))
+    mount()
+    fireEvent.click(getByRole(host, 'button'))
+    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)
+    mount()
+    fireEvent.click(getByRole(host, 'button'))
+    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 Markdown' : '复制 Markdown' }))
+      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')
+    }
+  })
+})

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

@@ -0,0 +1,76 @@
+/** 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', '/%2e%2e/secrets.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('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')
+  })
+})