fix(docs): preserve UTF-8 in raw Markdown navigation

This commit is contained in:
gengruilin committed 2026-09-15 16:15:11 +08:00
1 parent 23135e62dc
commit cb464f1894
5 files changed
+25 -12

No files matched your search

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-15-docs-page-markdown-actions.md
2026-09-15-docs-page-markdown-actions.md: d89b248496a9b80801f4d213ec2f845c7758cb52
2026-09-15-docs-page-markdown-actions.zh.md: 6d2ebc9d72c3fadd46064034d67970723179ccba
2026-09-15-docs-page-markdown-actions.md: cd8791f9491a0cbbe653d09753a4c0c7594970f4
2026-09-15-docs-page-markdown-actions.zh.md: 211c0c069e4a4c8556458db2d4d226426f419af5
@@ -12,7 +12,7 @@ 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 [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 [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.
@@ -12,7 +12,7 @@ Status: implemented
[投影器](../../../../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/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 有独立的拒绝处理器。
+18 -6
View File
@@ -675,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)
})
@@ -693,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', () => {
@@ -709,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', () => {
+3 -2
View File
@@ -487,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.
@@ -500,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],
)
}