diff --git a/.agents/notes/implemented/feature/2026-09-15-docs-page-markdown-actions.i18n.yaml b/.agents/notes/implemented/feature/2026-09-15-docs-page-markdown-actions.i18n.yaml index cf809b41f5..c049ed916a 100644 --- a/.agents/notes/implemented/feature/2026-09-15-docs-page-markdown-actions.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-15-docs-page-markdown-actions.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-15-docs-page-markdown-actions.md -2026-09-15-docs-page-markdown-actions.md: c7b250d2b5b8285d947f9a5308e938119cc59064 -2026-09-15-docs-page-markdown-actions.zh.md: a9af5150649407efb30de02cc227ce40e77b5fd6 +2026-09-15-docs-page-markdown-actions.md: 61ddd46c802201f37fa0974561158f91cfb9de2d +2026-09-15-docs-page-markdown-actions.zh.md: 2fd373688cce97053779a602352bcb0d1e90bd7b diff --git a/.agents/notes/implemented/feature/2026-09-15-docs-page-markdown-actions.md b/.agents/notes/implemented/feature/2026-09-15-docs-page-markdown-actions.md index c7b250d2b5..61ddd46c80 100644 --- a/.agents/notes/implemented/feature/2026-09-15-docs-page-markdown-actions.md +++ b/.agents/notes/implemented/feature/2026-09-15-docs-page-markdown-actions.md @@ -12,11 +12,11 @@ Readers need a visible way to obtain one documentation page as Markdown. The pub The [projector](../../../../scripts/project-doc-site.ts) supplies ordinary content pages with a `rawMarkdownPath` from the publication manifest. The [theme](../../../../website/.vitepress/theme/index.ts) combines that path with the site base and exposes copy and view actions above the document. Directory pages use their full `index.md` route, which preserves the same relative-link location in development and static builds. Redirect homes and missing pages have no actions. Raw output excludes projection metadata. -The primary button copies the page directly. Its adjacent toggle opens a menu with icons, action titles, and explanatory text, keeping the common action visible while grouping Markdown options. Menu items support arrow keys, Home, End, and activation; Escape restores toggle focus, while outside pointers and focus leaving dismiss the menu. Menu state and its outside-pointer listener belong to the page instance. +Server-rendered pages expose a raw-Markdown link. Client mounting replaces it with the copy button and menu, so MPA builds and pages without JavaScript retain a usable action. The primary button copies the page directly and keeps keyboard focus during copying through `aria-disabled` and `aria-busy`. Its adjacent toggle opens a menu with icons, action titles, and explanatory text, keeping the common action visible while grouping Markdown options. Menu items support arrow keys, Home, End, and activation; Escape restores toggle focus, while outside pointers and focus leaving dismiss the menu. Menu state and its outside-pointer listener belong to the page instance. The [development middleware](../../../../website/raw-markdown.ts) admits explicitly marked `?dsh-raw=1` browser fetches and returns 404 for unpublished raw routes. Script imports always reach Vite. The view link opens the ordinary raw URL in a new tab; copy reads that same projected body on demand. Emitted Markdown files carry a UTF-8 BOM because static hosts can omit the response charset, causing direct browser navigation to misdecode Chinese and other non-ASCII characters. Fetch decoding removes the BOM before copying. Neither operation reconstructs Markdown from the rendered DOM. -The [copy component](../../../../website/.vitepress/theme/page-markdown-actions.ts) calls `clipboard.write` within the click gesture and supplies a promise-backed `text/plain` ClipboardItem. Awaiting the network first would lose user activation in browsers that require it. Each route and language gets its own keyed component instance; disposal aborts unfinished data reads and prevents old writes from changing new-page feedback. A system clipboard write that has already consumed its data cannot be withdrawn. Request and clipboard failures provide localized manual-copy guidance, and success follows the completed write. Denied writes may never consume their data promise, so that promise has its own rejection observer. +The [copy component](../../../../website/.vitepress/theme/page-markdown-actions.ts) calls `clipboard.write` within the click gesture and supplies a promise-backed `text/plain` ClipboardItem. Awaiting the network first would lose user activation in browsers that require it. Each route and language gets its own keyed component instance, so old writes cannot change new-page feedback. Disposal aborts unfinished data reads. A system clipboard write that has already consumed its data cannot be withdrawn. Request and clipboard failures provide localized manual-copy guidance, and success follows the completed write. Denied writes may never consume their data promise, so that promise has its own rejection observer. ## Alternatives considered @@ -28,7 +28,7 @@ The [copy component](../../../../website/.vitepress/theme/page-markdown-actions. ## Consequences -Automatic copying requires the browser's asynchronous ClipboardItem write API in a secure context. Unsupported or denied writes retain the view link for manual copying. Page copy state is transient and belongs to one route; it creates no Session data or model request. +Automatic copying accepts `text/markdown` or `text/plain` responses. Hosts must assign one of these content types to `.md` files; missing types and `application/octet-stream` fail with manual-copy guidance. The allowlist also rejects HTML fallbacks and JavaScript page modules. Automatic copying requires the browser's asynchronous ClipboardItem write API in a secure context. Unsupported or denied writes retain the view link for manual copying. Page copy state is transient and belongs to one route; it creates no Session data or model request. The [component tests](../../../../website/tests/page-markdown-actions.spec.ts) pin localized accessible output beside their owner and exercise deferred reads, denied writes, and navigation disposal. The [middleware tests](../../../../website/tests/raw-markdown.spec.ts) cover request dispatch. Both run through the unit suite, `docs:check`, and `doc-sync`. The [Mermaid viewer decision](2026-09-14-docs-mermaid-viewer.md) retains its independent rendering and resource-lifetime rules. diff --git a/.agents/notes/implemented/feature/2026-09-15-docs-page-markdown-actions.zh.md b/.agents/notes/implemented/feature/2026-09-15-docs-page-markdown-actions.zh.md index a9af515064..2fd373688c 100644 --- a/.agents/notes/implemented/feature/2026-09-15-docs-page-markdown-actions.zh.md +++ b/.agents/notes/implemented/feature/2026-09-15-docs-page-markdown-actions.zh.md @@ -12,11 +12,11 @@ Status: implemented [投影器](../../../../scripts/project-doc-site.ts) 根据发布 manifest(元数据清单)为普通内容页提供 `rawMarkdownPath`。[主题](../../../../website/.vitepress/theme/index.ts) 将该路径与站点 base 组合,在正文上方提供复制和查看操作。目录页使用完整的 `index.md` 路由,使相对链接在开发环境和静态构建中保持相同的位置。首页重定向和缺失页面不显示操作。原文输出不包含投影元数据。 -主按钮直接复制页面,旁边的展开按钮打开带图标、操作标题和说明文字的菜单,使常用操作保持可见,并将 Markdown 选项放在一起。菜单项支持方向键、Home、End 和激活操作;Escape 将焦点恢复到展开按钮,外部指针操作和焦点离开会关闭菜单。菜单状态及其外部指针监听器归属于页面实例。 +服务端渲染的页面提供原文链接。客户端挂载后将其替换为复制按钮和菜单,使 MPA 构建及未运行 JavaScript 的页面保留可用操作。主按钮直接复制页面,并通过 `aria-disabled` 和 `aria-busy` 在复制期间保留键盘焦点。旁边的展开按钮打开带图标、操作标题和说明文字的菜单,使常用操作保持可见,并将 Markdown 选项放在一起。菜单项支持方向键、Home、End 和激活操作;Escape 将焦点恢复到展开按钮,外部指针操作和焦点离开会关闭菜单。菜单状态及其外部指针监听器归属于页面实例。 [开发中间件](../../../../website/raw-markdown.ts) 接受带有显式 `?dsh-raw=1` 标记的浏览器请求,对未发布的原文路由返回 404。脚本导入始终交给 Vite。查看链接在新标签页打开普通原文 URL;复制按需读取相同的投影正文。生成的 Markdown 文件携带 UTF-8 BOM,因为静态托管可能省略响应字符集,导致浏览器直接访问时错误解码中文等非 ASCII 字符。Fetch 解码会在复制前移除 BOM。两种操作都不从渲染后的 DOM 重建 Markdown。 -[复制组件](../../../../website/.vitepress/theme/page-markdown-actions.ts) 在点击手势内调用 `clipboard.write`,并提供由 Promise 承载数据的 `text/plain` ClipboardItem。先等待网络会在要求用户激活的浏览器中丢失该激活状态。每个路由和语言组合都有独立的、带 key 的组件实例;释放时取消未完成的数据读取,防止旧写入改变新页面的反馈。已经消费数据的系统剪贴板写入无法撤回。请求失败和剪贴板失败提供本地化的手动复制指引,成功提示在写入完成后显示。被拒绝的写入可能完全不消费数据 Promise,因此该 Promise 有独立的拒绝处理器。 +[复制组件](../../../../website/.vitepress/theme/page-markdown-actions.ts) 在点击手势内调用 `clipboard.write`,并提供由 Promise 承载数据的 `text/plain` ClipboardItem。先等待网络会在要求用户激活的浏览器中丢失该激活状态。每个路由和语言组合都有独立的、带 key 的组件实例,因此旧写入无法改变新页面的反馈。释放时取消未完成的数据读取。已经消费数据的系统剪贴板写入无法撤回。请求失败和剪贴板失败提供本地化的手动复制指引,成功提示在写入完成后显示。被拒绝的写入可能完全不消费数据 Promise,因此该 Promise 有独立的拒绝处理器。 ## 考虑过的替代方案 @@ -28,7 +28,7 @@ Status: implemented ## 影响 -自动复制需要浏览器在安全上下文中提供异步 ClipboardItem 写入 API。写入不受支持或被拒绝时,查看链接仍可用于手动复制。页面复制状态是临时状态,归属于单一路由;它不创建 Session 数据或模型请求。 +自动复制接受 `text/markdown` 或 `text/plain` 响应。托管方必须为 `.md` 文件指定其中一种内容类型;缺失类型和 `application/octet-stream` 会触发失败及手动复制指引。白名单还会拒绝 HTML 回退页面和 JavaScript 页面模块。自动复制需要浏览器在安全上下文中提供异步 ClipboardItem 写入 API。写入不受支持或被拒绝时,查看链接仍可用于手动复制。页面复制状态是临时状态,归属于单一路由;它不创建 Session 数据或模型请求。 [组件测试](../../../../website/tests/page-markdown-actions.spec.ts) 在所属目录中固定本地化的可访问输出,并验证延迟读取、写入拒绝和导航时的资源清理。[中间件测试](../../../../website/tests/raw-markdown.spec.ts) 覆盖请求分发。两者都通过单元测试、`docs:check` 和 `doc-sync` 执行。[Mermaid 查看器决策](2026-09-14-docs-mermaid-viewer.zh.md) 保留独立的渲染与资源生命周期规则。 diff --git a/scripts/project-doc-site.spec.ts b/scripts/project-doc-site.spec.ts index 4ac9faefd1..6f22a0f1e0 100644 --- a/scripts/project-doc-site.spec.ts +++ b/scripts/project-doc-site.spec.ts @@ -776,7 +776,7 @@ describe('raw Markdown projection of the published manifest', () => { it('emits home pages with their bodies instead of the frontmatter stub', () => { for (const route of ['index.md', 'en/index.md']) { - const home = readFileSync(join(mirror, route), 'utf8') + const home = new TextDecoder().decode(readFileSync(join(mirror, route))) expect(home.startsWith('---'), route).toBe(false) expect(home, route).toContain('# DeepSeek Harness') } diff --git a/website/.vitepress/theme/page-markdown-actions.css b/website/.vitepress/theme/page-markdown-actions.css index ff46ccafc6..8e00adfbb2 100644 --- a/website/.vitepress/theme/page-markdown-actions.css +++ b/website/.vitepress/theme/page-markdown-actions.css @@ -60,7 +60,6 @@ background: var(--vp-c-default-soft); } -.page-markdown-actions [disabled], .page-markdown-actions [aria-disabled="true"] { cursor: wait; opacity: 0.65; @@ -132,3 +131,14 @@ .page-markdown-actions-status:not(:empty) { margin-top: 8px; } + +.page-markdown-static { + display: inline-flex; + align-items: center; + gap: 4px; + min-height: 36px; + padding: 6px 12px; + border: 1px solid var(--vp-c-divider); + border-radius: 12px; + background: var(--vp-c-bg); +} diff --git a/website/.vitepress/theme/page-markdown-actions.ts b/website/.vitepress/theme/page-markdown-actions.ts index df8e03fc6a..f1ad4aa82e 100644 --- a/website/.vitepress/theme/page-markdown-actions.ts +++ b/website/.vitepress/theme/page-markdown-actions.ts @@ -1,5 +1,5 @@ /** Current-page Markdown actions; each mounted instance owns one page's copy request. */ -import { defineComponent, h, nextTick, onBeforeUnmount, ref, useId, watch } from 'vue' +import { defineComponent, h, nextTick, onBeforeUnmount, onMounted, ref, useId, watch } from 'vue' type CopyState = 'idle' | 'copying' | 'copied' | 'requestFailed' | 'clipboardFailed' @@ -47,6 +47,7 @@ function icon(name: keyof typeof icons) { /** * Actions keyed by the owning layout to the current route and language. + * Server rendering exposes the raw link; mounting enables copying and the menu. * Clipboard writes begin in the click gesture; their data resolves on demand. */ export const PageMarkdownActions = defineComponent({ @@ -56,6 +57,8 @@ export const PageMarkdownActions = defineComponent({ lang: { type: String, required: true }, }, setup(props) { + const interactive = ref(false) + onMounted(() => { interactive.value = true }) const state = ref('idle') const open = ref(false) const root = ref() @@ -101,9 +104,7 @@ export const PageMarkdownActions = defineComponent({ } } let controller: AbortController | undefined - let disposed = false onBeforeUnmount(() => { - disposed = true open.value = false controller?.abort() }) @@ -139,10 +140,10 @@ export const PageMarkdownActions = defineComponent({ }) try { await clipboard.write([new ClipboardItem({ 'text/plain': content })]) - if (!disposed) state.value = 'copied' + state.value = 'copied' } catch (_error) { // Browser failures use localized feedback; their implementation-specific text is not user copy. - if (!disposed) state.value = outcome.requestFailed ? 'requestFailed' : 'clipboardFailed' + state.value = outcome.requestFailed ? 'requestFailed' : 'clipboardFailed' } finally { request.abort() if (controller === request) controller = undefined @@ -151,6 +152,12 @@ export const PageMarkdownActions = defineComponent({ return () => { const text = messages[props.lang.startsWith('zh') ? 'zh' : 'en'] + if (!interactive.value) { + return h('div', { class: 'page-markdown-actions' }, [ + h('a', { class: 'page-markdown-static', href: props.path, target: '_blank', rel: 'noopener', 'aria-label': text.newTab }, + [text.view, icon('external')]), + ]) + } return h('div', { ref: root, class: 'page-markdown-actions', onFocusout: (event: FocusEvent) => { @@ -166,8 +173,9 @@ export const PageMarkdownActions = defineComponent({ }, [ h('div', { class: 'page-markdown-actions-dropdown' }, [ h('div', { class: 'page-markdown-actions-controls' }, [ - h('button', { class: 'page-markdown-copy', type: 'button', disabled: state.value === 'copying', onClick: copy }, - [icon('copy'), state.value === 'copying' ? text.copying : text.copy]), + h('button', { class: 'page-markdown-copy', type: 'button', + 'aria-disabled': state.value === 'copying', 'aria-busy': state.value === 'copying', onClick: copy }, + [icon('copy'), state.value === 'copying' ? text.copying : text.copy]), h('button', { ref: toggle, class: 'page-markdown-toggle', type: 'button', 'aria-label': text.more, 'aria-haspopup': 'menu', 'aria-expanded': open.value, 'aria-controls': menuId, onClick: () => { if (open.value) closeMenu(); else openMenu() }, @@ -182,7 +190,8 @@ export const PageMarkdownActions = defineComponent({ open.value ? h('div', { id: menuId, role: 'menu', 'aria-label': text.menu, class: 'page-markdown-menu', onKeydown: menuKeydown }, [ h('button', { ref: menuCopy, role: 'menuitem', type: 'button', tabindex: -1, - 'aria-label': text.copy, 'aria-describedby': `${menuId}-copy`, 'aria-disabled': state.value === 'copying', onClick: copy }, [ + 'aria-label': text.copy, 'aria-describedby': `${menuId}-copy`, 'aria-disabled': state.value === 'copying', + 'aria-busy': state.value === 'copying', onClick: copy }, [ h('span', { class: 'page-markdown-menu-icon' }, [icon('copy')]), h('span', { class: 'page-markdown-menu-text' }, [h('span', { class: 'page-markdown-menu-title' }, text.copy), h('span', { id: `${menuId}-copy`, class: 'page-markdown-menu-description' }, text.copyDescription)]), diff --git a/website/raw-markdown.ts b/website/raw-markdown.ts index 939b4c3c07..9a0f1330db 100644 --- a/website/raw-markdown.ts +++ b/website/raw-markdown.ts @@ -16,7 +16,14 @@ export function rawMarkdownMiddleware(base: string, index: () => string): Connec next() return } - const url = new URL(req.url, 'http://docs.local') + let url: URL + try { + url = new URL(req.url, 'http://docs.local') + } catch (_error) { + // Malformed targets belong to Vite's request handling, not raw-route lookup. + next() + return + } if (!url.pathname.startsWith(base)) { next() return diff --git a/website/tests/expected/page-markdown-actions.en-US.html b/website/tests/expected/page-markdown-actions.en-US.html index c84b518c59..054b554092 100644 --- a/website/tests/expected/page-markdown-actions.en-US.html +++ b/website/tests/expected/page-markdown-actions.en-US.html @@ -1 +1 @@ -

+

diff --git a/website/tests/expected/page-markdown-actions.zh-CN.html b/website/tests/expected/page-markdown-actions.zh-CN.html index 0c01c7f0be..4da9f5374a 100644 --- a/website/tests/expected/page-markdown-actions.zh-CN.html +++ b/website/tests/expected/page-markdown-actions.zh-CN.html @@ -1 +1 @@ -

+

diff --git a/website/tests/expected/page-markdown-menu.en-US.html b/website/tests/expected/page-markdown-menu.en-US.html index a2a869da2e..6e04a95cae 100644 --- a/website/tests/expected/page-markdown-menu.en-US.html +++ b/website/tests/expected/page-markdown-menu.en-US.html @@ -1 +1 @@ -

+

diff --git a/website/tests/expected/page-markdown-menu.zh-CN.html b/website/tests/expected/page-markdown-menu.zh-CN.html index 4bf39a03eb..217628e548 100644 --- a/website/tests/expected/page-markdown-menu.zh-CN.html +++ b/website/tests/expected/page-markdown-menu.zh-CN.html @@ -1 +1 @@ -

+

diff --git a/website/tests/expected/page-markdown-static.en-US.html b/website/tests/expected/page-markdown-static.en-US.html new file mode 100644 index 0000000000..a9f320e3c8 --- /dev/null +++ b/website/tests/expected/page-markdown-static.en-US.html @@ -0,0 +1 @@ +
diff --git a/website/tests/expected/page-markdown-static.zh-CN.html b/website/tests/expected/page-markdown-static.zh-CN.html new file mode 100644 index 0000000000..2be6a3c7f1 --- /dev/null +++ b/website/tests/expected/page-markdown-static.zh-CN.html @@ -0,0 +1 @@ +
diff --git a/website/tests/page-markdown-actions.spec.ts b/website/tests/page-markdown-actions.spec.ts index 21c31d9a7a..6112bf8651 100644 --- a/website/tests/page-markdown-actions.spec.ts +++ b/website/tests/page-markdown-actions.spec.ts @@ -1,8 +1,9 @@ // @vitest-environment jsdom /** Browser-action feedback and route-owned asynchronous clipboard work. */ import assert from 'node:assert/strict' -import { createApp, h, nextTick, reactive, ref, type App, type Slots } from 'vue' -import { fireEvent, getByRole, queryByRole, waitFor } from '@testing-library/dom' +import { createApp, createSSRApp, h, nextTick, reactive, ref, type App, type Slots } from 'vue' +import { renderToString } from 'vue/server-renderer' +import { fireEvent, getByRole, queryAllByRole, queryByRole, waitFor } from '@testing-library/dom' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import Theme from '../.vitepress/theme/index.ts' @@ -45,9 +46,10 @@ function response(text = '# Quickstart\n\n[Reference](../reference/index.md)\n', return new Response(text, { status, headers: { 'content-type': `${type}; charset=utf-8` } }) } -function mount() { +async function mount() { app = createApp(Theme.Layout) app.mount(host) + await nextTick() } async function openMenu() { @@ -94,15 +96,31 @@ describe('page Markdown actions', () => { it.each(['en-US', 'zh-CN'])('preserves the accessible controls and status in %s', async (lang) => { data.lang.value = lang data.frontmatter.value = { rawMarkdownPath: `${lang === 'en-US' ? 'en/' : ''}guide/quickstart.md` } - mount() + await mount() await expect(`${host.innerHTML}\n`).toMatchFileSnapshot(`./expected/page-markdown-actions.${lang}.html`) fireEvent.click(getByRole(host, 'button', { name: lang === 'en-US' ? 'More page actions' : '更多页面操作' })) await nextTick() await expect(`${host.innerHTML}\n`).toMatchFileSnapshot(`./expected/page-markdown-menu.${lang}.html`) }) + it.each(['en-US', 'zh-CN'])('offers a working raw link before hydration in %s', async (lang) => { + data.lang.value = lang + data.site.value = { base: '/deepseek-harness/' } + const path = `${lang === 'en-US' ? 'en/' : ''}guide/quickstart.md` + data.frontmatter.value = { rawMarkdownPath: path } + host.innerHTML = await renderToString(createSSRApp(Theme.Layout)) + expect(queryAllByRole(host, 'button')).toHaveLength(0) + const link = getByRole(host, 'link') + expect(link.getAttribute('href')).toBe(`/deepseek-harness/${path}`) + expect(link.getAttribute('target')).toBe('_blank') + expect(link.getAttribute('aria-label')).toBe(lang === 'en-US' + ? 'View as Markdown (opens in a new tab)' : '以 Markdown 格式查看(在新标签页打开)') + await expect(`${host.innerHTML}\n`).toMatchFileSnapshot(`./expected/page-markdown-static.${lang}.html`) + expect(fetchMock).not.toHaveBeenCalled() + }) + it('opens from the keyboard, moves between actions, and restores focus on Escape', async () => { - mount() + await mount() const toggle = getByRole(host, 'button', { name: 'More page actions' }) toggle.focus() fireEvent.keyDown(toggle, { key: 'ArrowDown' }) @@ -131,7 +149,7 @@ describe('page Markdown actions', () => { }) it('dismisses on an outside pointer, focus leaving, and a second toggle click', async () => { - mount() + await mount() const toggle = await openMenu() fireEvent.pointerDown(document.body) await nextTick() @@ -149,7 +167,7 @@ describe('page Markdown actions', () => { it('copies from the menu in the same click and prevents another copy while it is pending', async () => { const pending = deferred() fetchMock.mockReturnValueOnce(pending.promise) - mount() + await mount() const toggle = await openMenu() fireEvent.click(getByRole(host, 'menuitem', { name: 'Copy page' })) expect(write).toHaveBeenCalledOnce() @@ -167,7 +185,7 @@ describe('page Markdown actions', () => { }) it('opens the raw link from the menu and returns focus to the toggle', async () => { - mount() + await mount() const toggle = await openMenu() const view = getByRole(host, 'menuitem', { name: 'View as Markdown (opens in a new tab)' }) view.addEventListener('click', (event) => { event.preventDefault() }, { once: true }) @@ -179,7 +197,7 @@ describe('page Markdown actions', () => { }) it('removes outside-pointer listeners and closes its menu when the route changes', async () => { - mount() + await mount() const add = vi.spyOn(document, 'addEventListener') const remove = vi.spyOn(document, 'removeEventListener') try { @@ -207,7 +225,7 @@ describe('page Markdown actions', () => { data.site.value = { base } data.frontmatter.value = { rawMarkdownPath: 'en/reference/index.md' } route.path = `${base}en/reference/index.html?from=nav#api` - mount() + await mount() await openMenu() const link = getByRole(host, 'menuitem', { name: 'View as Markdown (opens in a new tab)' }) expect(link.getAttribute('href')).toBe(`${base}en/reference/index.md`) @@ -215,10 +233,10 @@ describe('page Markdown actions', () => { expect(fetchMock).not.toHaveBeenCalled() }) - it.each(['home', '404'])('omits actions on %s', (kind) => { + it.each(['home', '404'])('omits actions on %s', async (kind) => { if (kind === 'home') data.frontmatter.value = { layout: false } else data.page.value = { isNotFound: true } - mount() + await mount() expect(queryByRole(host, 'button')).toBeNull() expect(queryByRole(host, 'link')).toBeNull() }) @@ -226,8 +244,9 @@ describe('page Markdown actions', () => { it('starts clipboard.write during the click and copies the fetched plain text before reporting success', async () => { const pending = deferred() fetchMock.mockReturnValue(pending.promise) - mount() + await mount() const button = getByRole(host, 'button', { name: 'Copy page' }) + button.focus() fireEvent.click(button) expect(write).toHaveBeenCalledOnce() expect(copied).toBeUndefined() @@ -236,7 +255,10 @@ describe('page Markdown actions', () => { expect(fetchMock.mock.calls[0]?.[0]).toBe('/en/guide/quickstart.md?dsh-raw=1') expect(fetchMock.mock.calls[0]?.[1]?.signal).toBeInstanceOf(AbortSignal) await nextTick() - expect(button).toHaveProperty('disabled', true) + expect(button).toHaveProperty('disabled', false) + expect(button.getAttribute('aria-disabled')).toBe('true') + expect(button.getAttribute('aria-busy')).toBe('true') + expect(document.activeElement).toBe(button) expect(getByRole(host, 'status').textContent).toBe('Copying…') pending.resolve(response()) await waitFor(() => { expect(getByRole(host, 'status').textContent).toBe('Markdown copied.') }) @@ -246,13 +268,15 @@ describe('page Markdown actions', () => { assert(copied !== undefined) reader.readAsText(copied) expect(await text).toBe('# Quickstart\n\n[Reference](../reference/index.md)\n') - expect(button).toHaveProperty('disabled', false) + expect(button.getAttribute('aria-disabled')).toBe('false') + expect(button.getAttribute('aria-busy')).toBe('false') + expect(document.activeElement).toBe(button) }) it.each(['network', '404', 'html'])('reports a %s response failure without success and allows retry', async (failure) => { if (failure === 'network') fetchMock.mockRejectedValueOnce(new Error('offline')) else fetchMock.mockResolvedValueOnce(response('not Markdown', failure === '404' ? 404 : 200, failure === 'html' ? 'text/html' : 'text/markdown')) - mount() + await mount() fireEvent.click(getByRole(host, 'button', { name: 'Copy page' })) await waitFor(() => { expect(getByRole(host, 'status').textContent).toContain('Could not load Markdown.') }) expect(copied).toBeUndefined() @@ -262,25 +286,35 @@ describe('page Markdown actions', () => { it('accepts static hosting that serves Markdown as plain text', async () => { fetchMock.mockResolvedValueOnce(response('# Plain text hosting\n', 200, 'text/plain')) - mount() + await mount() fireEvent.click(getByRole(host, 'button', { name: 'Copy page' })) await waitFor(() => { expect(getByRole(host, 'status').textContent).toBe('Markdown copied.') }) }) + it.each(['application/javascript', 'application/octet-stream', undefined])('rejects a %s content type without copying it', async (type) => { + fetchMock.mockResolvedValueOnce(new Response(new TextEncoder().encode('# Body\n'), { + headers: type === undefined ? {} : { 'content-type': type }, + })) + await mount() + fireEvent.click(getByRole(host, 'button', { name: 'Copy page' })) + await waitFor(() => { expect(getByRole(host, 'status').textContent).toContain('Could not load Markdown.') }) + expect(copied).toBeUndefined() + }) + it('reports failure when reading the response body rejects', async () => { const raw = response() vi.spyOn(raw, 'text').mockRejectedValueOnce(new Error('Connection closed')) fetchMock.mockResolvedValueOnce(raw) - mount() + await mount() fireEvent.click(getByRole(host, 'button', { name: 'Copy page' })) await waitFor(() => { expect(getByRole(host, 'status').textContent).toContain('Could not load Markdown.') }) expect(copied).toBeUndefined() }) - it('ignores a late successful write after its page has been replaced', async () => { + it('keeps new-page feedback independent of a previous page clipboard write', async () => { const writeDone = deferred() write.mockImplementationOnce(() => writeDone.promise) - mount() + await mount() fireEvent.click(getByRole(host, 'button', { name: 'Copy page' })) route.path = '/en/reference/' data.frontmatter.value = { rawMarkdownPath: 'en/reference/index.md' } @@ -294,7 +328,7 @@ describe('page Markdown actions', () => { it.each(['write', 'item'])('provides manual-copy feedback when the %s API is missing', async (missing) => { if (missing === 'item') vi.stubGlobal('ClipboardItem', undefined) else vi.stubGlobal('navigator', {}) - mount() + await mount() fireEvent.click(getByRole(host, 'button', { name: 'Copy page' })) await nextTick() expect(getByRole(host, 'status').textContent).toContain('Could not copy.') @@ -307,7 +341,7 @@ describe('page Markdown actions', () => { const pending = deferred() fetchMock.mockReturnValue(pending.promise) write.mockRejectedValueOnce(new DOMException('Denied', 'NotAllowedError')) - mount() + await mount() fireEvent.click(getByRole(host, 'button', { name: 'Copy page' })) await waitFor(() => { expect(getByRole(host, 'status').textContent).toContain('Could not copy.') }) expect(fetchMock.mock.calls[0]?.[1]?.signal?.aborted).toBe(true) @@ -320,7 +354,7 @@ describe('page Markdown actions', () => { it.each(['route', 'locale', 'unmount'])('cancels old data and ignores its completion after %s changes', async (change) => { const pending = deferred() fetchMock.mockReturnValueOnce(pending.promise) - mount() + await mount() fireEvent.click(getByRole(host, 'button', { name: 'Copy page' })) const oldWrite = activeWrite assert(oldWrite !== undefined) diff --git a/website/tests/raw-markdown.spec.ts b/website/tests/raw-markdown.spec.ts index c868cc002c..e25f3abab3 100644 --- a/website/tests/raw-markdown.spec.ts +++ b/website/tests/raw-markdown.spec.ts @@ -45,7 +45,7 @@ describe('raw Markdown development middleware', () => { expect(rawMarkdownRoute).not.toHaveBeenCalled() }) - it.each(['/missing.md', '/en/reference.md', '/%2e%2e/secrets.md'])('returns 404 for explicit unpublished route %s', (url) => { + it.each(['/missing.md', '/en/reference.md'])('returns 404 for explicit unpublished route %s', (url) => { const { res, next } = request(`${url}?dsh-raw=1`, 'empty') expect(res.statusCode).toBe(404) expect(res.end).toHaveBeenCalledExactlyOnceWith() @@ -69,6 +69,13 @@ describe('raw Markdown development middleware', () => { ]) expect(next).toHaveBeenCalledExactlyOnceWith() }) + it.each(['http://[', 'http://[::1'])('delegates an unparseable request target %s', (url) => { + const { res, next } = request(url, 'empty') + expect(next).toHaveBeenCalledExactlyOnceWith() + expect(res.end).not.toHaveBeenCalled() + expect(rawMarkdownRoute).not.toHaveBeenCalled() + }) + it('does not turn a projection error into a successful raw response', () => { vi.mocked(rawMarkdownRoute).mockImplementation(() => { throw new Error('Source missing') }) expect(() => request('/en/reference/index.md?dsh-raw=1', 'empty')).toThrow('Source missing')