mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-07-12 05:51:44 +08:00
c9a50cc908
* 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 而写的实体(如 <b>) 解码成真实标签,存在内容篡改与 HTML 注入风险。 该解码本就只是为了让输出 HTML「好看」,而 remark-cjk-friendly 引入的 实体是合法 HTML 字符引用,粘进 X 编辑器时能正确渲染;且这些实体只会 出现在强调 span 的外侧(边界字符),不会出现在 span 内部。因此直接 移除解码,既消除过度解码风险,又无功能损失。 - 删除 decodeHtmlEntities 及其在 convertMarkdownToHtml 的调用 - 新增测试:作者字面实体 <b> 不被解码为真实 <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>
166 lines
5.5 KiB
TypeScript
166 lines
5.5 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs/promises';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import test from 'node:test';
|
|
|
|
import { parseMarkdown } from './md-to-html.ts';
|
|
|
|
async function makeTempDir(prefix: string): Promise<string> {
|
|
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
|
}
|
|
|
|
test('parseMarkdown preserves mixed markdown and Obsidian wikilink image order', async (t) => {
|
|
const root = await makeTempDir('x-md-to-html-wikilinks-');
|
|
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
|
|
|
const articleDir = path.join(root, 'article');
|
|
const attachmentsDir = path.join(articleDir, 'Attachments');
|
|
const tempDir = path.join(root, 'tmp');
|
|
await fs.mkdir(attachmentsDir, { recursive: true });
|
|
await fs.mkdir(tempDir, { recursive: true });
|
|
await fs.writeFile(path.join(articleDir, 'a.png'), 'a');
|
|
await fs.writeFile(path.join(articleDir, 'b.jpg'), 'b');
|
|
await fs.writeFile(path.join(attachmentsDir, 'c.webp'), 'c');
|
|
|
|
const markdownPath = path.join(articleDir, 'post.md');
|
|
await fs.writeFile(
|
|
markdownPath,
|
|
[
|
|
'# Title',
|
|
'',
|
|
'![[a.png]]',
|
|
'',
|
|
'',
|
|
'',
|
|
'![[c.webp|C alt]]',
|
|
'',
|
|
'![[note]]',
|
|
].join('\n'),
|
|
);
|
|
|
|
const result = await parseMarkdown(markdownPath, { tempDir });
|
|
|
|
assert.deepEqual(
|
|
result.contentImages.map(({ placeholder, originalPath, alt, localPath }) => ({
|
|
placeholder,
|
|
originalPath,
|
|
alt,
|
|
localPath,
|
|
})),
|
|
[
|
|
{
|
|
placeholder: 'XIMGPH_1',
|
|
originalPath: 'a.png',
|
|
alt: '',
|
|
localPath: path.join(articleDir, 'a.png'),
|
|
},
|
|
{
|
|
placeholder: 'XIMGPH_2',
|
|
originalPath: 'b.jpg',
|
|
alt: 'B alt',
|
|
localPath: path.join(articleDir, 'b.jpg'),
|
|
},
|
|
{
|
|
placeholder: 'XIMGPH_3',
|
|
originalPath: 'c.webp',
|
|
alt: 'C alt',
|
|
localPath: path.join(attachmentsDir, 'c.webp'),
|
|
},
|
|
],
|
|
);
|
|
assert.match(result.html, /XIMGPH_1[\s\S]*XIMGPH_2[\s\S]*XIMGPH_3/);
|
|
assert.match(result.html, /!\[\[note\]\]/);
|
|
});
|
|
|
|
test('parseMarkdown resolves encoded spaces and literal percent image paths', async (t) => {
|
|
const root = await makeTempDir('baoyu-post-to-x-images-');
|
|
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
|
|
|
const articlePath = path.join(root, 'article.md');
|
|
const tempDir = path.join(root, 'tmp');
|
|
await fs.mkdir(tempDir, { recursive: true });
|
|
await fs.writeFile(path.join(root, 'Pasted image.png'), 'png');
|
|
await fs.writeFile(path.join(root, '100%.png'), 'png');
|
|
await fs.writeFile(
|
|
articlePath,
|
|
[
|
|
'# Title',
|
|
'',
|
|
'',
|
|
'',
|
|
'',
|
|
].join('\n'),
|
|
);
|
|
|
|
const result = await parseMarkdown(articlePath, { tempDir });
|
|
|
|
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,
|
|
[
|
|
'# 标题',
|
|
'',
|
|
'正文中写 <b>literal</b> 想显示字面标签。**加粗**收尾。',
|
|
'',
|
|
'代码里写 `<b>` 同样保留。',
|
|
].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, /<b>literal<\/b>/);
|
|
});
|