fix(baoyu-post-to-x): 修复紧贴中文的加粗/斜体在 X Articles 渲染为字面 ** 的问题 (#189)

* fix(baoyu-post-to-x): render CJK-adjacent bold/italics in articles

md-to-html.ts preprocessed markdown with remark + remark-cjk-friendly, then re-stringified and re-parsed with marked. remark-cjk-friendly parses emphasis adjacent to CJK correctly, but remark-stringify re-emits the **bold*/*italic* markers, which marked then FAILS to parse: its flanking test does not treat a CJK character after the closing delimiter as valid punctuation/whitespace. Result: literal **...** asterisks leak into the pasted X Article body whenever the closing delimiter is directly followed by a CJK character.

Fix: convert strong/emphasis mdast nodes (already identified correctly by remark-cjk-friendly) into raw inline <strong>/<em> HTML before stringify, so marked passes them through untouched. A small recursive serializer preserves nested links, inline code, images, etc.

- add remarkStrongEmToHtml remark plugin + inline mdast serializer
- add unist-util-visit as an explicit dependency
- add regression test covering CJK-adjacent bold and italics

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(baoyu-post-to-x): 修复紧贴中文的加粗/斜体在 X Articles 渲染为字面 ** 的问题

按维护者建议改用 remark-cjk-friendly 标准用法,替代自定义插件/序列化器:

- 删除 remarkStrongEmToHtml 插件与 serializeMdastNode 序列化器
- preprocessCjkMarkdown 简化为 remarkParse + remarkCjkFriendly + remarkStringify 往返
- 移除原 markdown 阶段的 &#x...; 实体解码(该解码会还原 cjk-friendly 为
  骗过 marked flanking 判定而生成的实体,正是 CJK 加粗失败的根因)
- 改在 marked 渲染出最终 HTML 后再 decodeHtmlEntities,此时 markdown 语法
  已不存在,解码安全且保持输出干净
- 移除不再使用的 unist-util-visit 显式依赖
- 测试补充引用式链接 **[docs][d]** 在强调内的回归断言(顺带修复 Codex
  指出的 linkReference 渲染为纯文本的 P2 问题)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(baoyu-post-to-x): 移除渲染后无差别实体解码,避免篡改作者字面实体

回应 Codex review (P2):decodeHtmlEntities 对整个渲染 HTML 做无差别
&#x...; 解码,会把作者为显示字面 HTML 而写的实体(如 &#x3C;b&#x3E;)
解码成真实标签,存在内容篡改与 HTML 注入风险。

该解码本就只是为了让输出 HTML「好看」,而 remark-cjk-friendly 引入的
实体是合法 HTML 字符引用,粘进 X 编辑器时能正确渲染;且这些实体只会
出现在强调 span 的外侧(边界字符),不会出现在 span 内部。因此直接
移除解码,既消除过度解码风险,又无功能损失。

- 删除 decodeHtmlEntities 及其在 convertMarkdownToHtml 的调用
- 新增测试:作者字面实体 &#x3C;b&#x3E; 不被解码为真实 <b>,CJK 加粗
  仍正常渲染
- bun test 8 个用例全部通过;真实文章端到端验证 17 处加粗、0 字面 **、
  0 真实标签注入

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: ruofeng <ruofeng.wang@rd.group>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
wangruofeng
2026-06-28 14:42:07 +08:00
committed by GitHub
parent 348dc82612
commit c9a50cc908
2 changed files with 81 additions and 2 deletions
@@ -98,3 +98,68 @@ test('parseMarkdown resolves encoded spaces and literal percent image paths', as
assert.equal(result.contentImages[0]?.localPath, path.join(root, 'Pasted image.png'));
assert.equal(result.contentImages[1]?.localPath, path.join(root, '100%.png'));
});
test('parseMarkdown renders CJK-adjacent bold and italics (no literal asterisks)', async (t) => {
const root = await makeTempDir('x-md-to-html-cjk-bold-');
t.after(() => fs.rm(root, { recursive: true, force: true }));
const markdownPath = path.join(root, 'post.md');
const tempDir = path.join(root, 'tmp');
await fs.mkdir(tempDir, { recursive: true });
await fs.writeFile(
markdownPath,
[
'# 标题',
'',
'分工在变细。**国际大厂卷基础设施,中文项目卷场景落地。**这其实是生态成熟的表现。',
'',
'半角场景 **Top 10 里平均有 8 个** 项目。',
'',
'斜体 *数据来源 GitHub* 收尾。',
'',
'参考 **[docs][d]** 了解更多。',
'',
'[d]: https://example.com',
].join('\n'),
);
const result = await parseMarkdown(markdownPath, { tempDir });
// Bold directly adjacent to CJK (closing ** followed by CJK) must render.
assert.match(result.html, /<strong>国际大厂卷基础设施,中文项目卷场景落地。<\/strong>/);
assert.match(result.html, /<strong>Top 10 里平均有 8 个<\/strong>/);
// Italics.
assert.match(result.html, /<em>数据来源 GitHub<\/em>/);
// Reference-style links inside emphasis must render as links, not plain text.
assert.match(result.html, /<strong><a href="https:\/\/example\.com" rel="noopener noreferrer nofollow">docs<\/a><\/strong>/);
// No literal emphasis delimiters should leak into the output.
assert.doesNotMatch(result.html, /\*\*/);
assert.doesNotMatch(result.html, /(?<!\*)\*(?!\*)[^*\n]+\*(?!\*)/);
});
test('parseMarkdown does not decode author-written literal HTML entities into tags', async (t) => {
const root = await makeTempDir('x-md-to-html-entities-');
t.after(() => fs.rm(root, { recursive: true, force: true }));
const markdownPath = path.join(root, 'post.md');
const tempDir = path.join(root, 'tmp');
await fs.mkdir(tempDir, { recursive: true });
await fs.writeFile(
markdownPath,
[
'# 标题',
'',
'正文中写 &#x3C;b&#x3E;literal&#x3C;/b&#x3E; 想显示字面标签。**加粗**收尾。',
'',
'代码里写 `&#x3C;b&#x3E;` 同样保留。',
].join('\n'),
);
const result = await parseMarkdown(markdownPath, { tempDir });
// CJK-adjacent bold still renders.
assert.match(result.html, /<strong>加粗<\/strong>/);
// Author-written literal entities must NOT be decoded into real tags.
assert.doesNotMatch(result.html, /<b>literal<\/b>/);
assert.match(result.html, /&lt;b&gt;literal&lt;\/b&gt;/);
});
+16 -2
View File
@@ -136,6 +136,21 @@ function highlightCode(code: string, lang: string): string {
}
}
// Normalize CJK-adjacent emphasis so `marked` renders it correctly.
//
// `marked`'s emphasis tokenizer treats a closing `**`/`*` directly followed by a
// CJK character as not right-flanking, so it leaves the delimiters literal
// (e.g. `**加粗**这` renders as plain text with the asterisks intact). We round-trip
// the markdown through `remark-cjk-friendly`, whose stringify serializes the
// boundary character as an HTML entity (`&#x8FD9;`); the entity is treated as
// punctuation by `marked`'s flanking rules, so emphasis parses as expected.
//
// We deliberately do NOT decode the entities afterward. They are valid HTML
// character references that render correctly when the article HTML is pasted into
// the X editor, and `marked` only emits them for characters outside the emphasis
// span (the boundary char), never inside it. A blanket decode of the rendered
// HTML would risk turning author-written literal entities (e.g. `&#x3C;b&#x3E;`
// meant to display `<b>` as text) into real tags, so we leave them intact.
function preprocessCjkMarkdown(markdown: string): string {
try {
const processor = unified()
@@ -143,8 +158,7 @@ function preprocessCjkMarkdown(markdown: string): string {
.use(remarkCjkFriendly)
.use(remarkStringify);
const result = String(processor.processSync(markdown));
return result.replace(/&#x([0-9A-Fa-f]+);/g, (_, hex: string) => String.fromCodePoint(parseInt(hex, 16)));
return String(processor.processSync(markdown));
} catch {
return markdown;
}