From cb464f18943ad48080eabad94e00d61503717bb1 Mon Sep 17 00:00:00 2001 From: gengruilin Date: Tue, 15 Sep 2026 16:15:11 +0800 Subject: [PATCH] fix(docs): preserve UTF-8 in raw Markdown navigation --- ...09-15-docs-page-markdown-actions.i18n.yaml | 4 ++-- .../2026-09-15-docs-page-markdown-actions.md | 2 +- ...026-09-15-docs-page-markdown-actions.zh.md | 2 +- scripts/project-doc-site.spec.ts | 24 ++++++++++++++----- scripts/project-doc-site.ts | 5 ++-- 5 files changed, 25 insertions(+), 12 deletions(-) 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 d4f08d3dd5..08397a9489 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: 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 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 d89b248496..cd8791f949 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,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. 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 6d2ebc9d72..211c0c069e 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,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 有独立的拒绝处理器。 diff --git a/scripts/project-doc-site.spec.ts b/scripts/project-doc-site.spec.ts index 3947bcd967..4ac9faefd1 100644 --- a/scripts/project-doc-site.spec.ts +++ b/scripts/project-doc-site.spec.ts @@ -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', () => { diff --git a/scripts/project-doc-site.ts b/scripts/project-doc-site.ts index 6f491d3e70..d8535d8188 100644 --- a/scripts/project-doc-site.ts +++ b/scripts/project-doc-site.ts @@ -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], ) }