修复 Hexo 中中文标点附近粗体语法失效的问题
本文大部分是 AIGC + 人工修缮表达。
问题
Hexo 的默认 markdown 渲染一直会存在如标题所示的问题:
典型例子是这种:
**Java字符串的一个重要特点就是字符串不可变。**这种不可变性是通过内部字段实现的。原本应被渲染成:
<strong>Java字符串的一个重要特点就是字符串不可变。</strong>这种不可变性是通过内部字段实现的。但实际生成的 HTML 里,** 会原样残留在正文中:
<p>**Java字符串的一个重要特点就是字符串不可变。**这种不可变性是通过内部字段实现的。</p>这个问题在中文文章里比较容易出现,因为我们经常会写:
**一句完整的话。**下一句继续。
中文**“条件”**在序列中一定如何如何。
中文**<u>重点内容</u>**后面继续接正文。准确概括问题:是 ** 的开闭分隔符附近出现中文、全角标点、中文引号、HTML 标签、行内代码等混排内容时,markdown-it 可能不会把它们识别为合法的 emphasis delimiter。
先定位问题
这个博客使用的是 Hexo 7.3.0,Markdown 渲染器是 hexo-renderer-markdown-it。排查的第一步直接用 markdown-it 复现。
例如:
const MarkdownIt = require('markdown-it');
const md = new MarkdownIt({ html: true });
console.log(md.renderInline('**Java字符串的一个重要特点就是字符串不可变。**这种不可变性'));
console.log(md.renderInline('中文**“条件”**在序列中'));
console.log(md.renderInline('中文**<u>粗体</u>**后面'));结果可以看到,很多情况下 **...** 没有被转成 <strong>...</strong>。
进一步看 markdown-it 的实现,会发现关键逻辑在 StateInline.prototype.scanDelims。它会根据 ** 前后的字符判断这串星号能不能作为开分隔符或者闭分隔符。
比如这段:
**abc。**中文闭合的 ** 前面是 。,后面是 中。。 是标点,中 既不是空白也不是标点,于是默认规则下这串 ** 可能不能作为闭合分隔符。
再比如:
中文**“条件”**在序列中开头的 ** 前面是 文,后面是 “。后面是标点,但前面不是空白或标点,默认规则下这串 ** 可能不能作为 opening delimiter。最后的表现就是整段粗体失效,或者在连续粗体里错误配对。
总结:是 Markdown inline parser 的分隔符判定问题。
用 Hexo filter 修
Hexo 官方文档里说,filter 用来修改某类指定数据,多个 filter 会按顺序执行。hexo-renderer-markdown-it 提供了 markdown-it:renderer 这个 filter,可以拿到正在使用的 markdown-it 实例。
解决方案:在 markdown-it:renderer 里 patch markdown-it 的 delimiter 判断。
原因:这个 bug 发生在 Markdown inline parser 判断 ** 能不能开闭的时候。直接改 delimiter 判断,比在 Markdown 字符串或 HTML 字符串上写正则更贴近问题本身。
这样也能避免几个麻烦:
- 不需要改所有旧文章。
- 不需要碰
themes/fluid/。 - 不需要在渲染后的 HTML 里猜哪些
**是正文、哪些**是代码。 - 能同时覆盖
<u>...</u>、行内代码、中文引号、全角括号等混排情况。
插件代码
这是一个放在 Hexo scripts/ 目录下的本地脚本。Hexo 启动时会自动加载这里的脚本。
文件是:
scripts/cjk-strong-emphasis.js核心入口很短:
hexo.extend.filter.register('markdown-it:renderer', patchCjkStrongEmphasis);patchCjkStrongEmphasis 拿到 markdownIt 实例之后,修改它的 inline parser:
代码
function patchCjkStrongEmphasis(markdownIt) {
if (!markdownIt || !markdownIt.inline || !markdownIt.inline.State) return;
const inlineState = markdownIt.inline.State.prototype;
if (inlineState[PATCH_FLAG]) return;
const scanDelims = inlineState.scanDelims;
inlineState.scanDelims = function patchedScanDelims(start, canSplitWord) {
const result = scanDelims.call(this, start, canSplitWord);
// 只处理 *,并且只处理长度至少为 2 的 ** / ****。
if (this.src.charCodeAt(start) !== ASTERISK || !canSplitWord || result.length < 2) {
return result;
}
const previousChar = charBefore(this.src, start);
const nextChar = charAt(this.src, start + result.length);
if (!result.can_open && shouldOpenStrongAfterCjk(this, previousChar, nextChar)) {
result.can_open = true;
}
if (!result.can_close && shouldCloseStrongNearCjk(this, previousChar, nextChar)) {
result.can_close = true;
}
return result;
};
inlineState[PATCH_FLAG] = true;
}这里有几个细节。
第一,只处理星号,不处理下划线。
if (marker !== ASTERISK || !canSplitWord || result.length < 2) {
return result;
}原因是中文文章里最常用的是 **粗体**。而 _ / __ 在英文单词、变量名、双下划线标识符里更常见,贸然放宽规则可能引入更多误伤。
第二,先调用原始 scanDelims。
const result = scanDelims.call(this, start, canSplitWord);这意味着原本能正确解析的 Markdown 不受影响。脚本只在默认规则给出 can_open: false 或 can_close: false 的时候,针对中文/全角上下文补一次判断。
第三,用 Symbol.for 做幂等保护。
const PATCH_FLAG = Symbol.for('hexo-blog.cjkStrongEmphasisPatched');Hexo 渲染多篇文章时,markdown-it:renderer filter 会被反复执行。如果每次都 patch 一层,最后调用链会越来越长。用 flag 标记后,脚本只 patch 一次。
怎么判断 CJK 和全角上下文
脚本里有一个函数:
function isCjkOrFullwidth(char) {
return /[\u1100-\u11FF\u2018-\u201F\u2E80-\u303F\u3040-\u30FF\u3100-\u312F\u31A0-\u31BF\u31F0-\u31FF\u3400-\u4DBF\u4E00-\u9FFF\uA960-\uA97F\uAC00-\uD7AF\uF900-\uFAFF\uFE10-\uFE1F\uFE30-\uFE6F\uFF00-\uFFEF]/u.test(char);
}它覆盖了常见中文、日文、韩文、中文标点、全角字符,以及中文引号所在的一些区间。
另外,判断标点时没有自己硬编码一个标点表,而是复用了 markdown-it 的工具函数:
function isPunct(state, char) {
if (!char) return false;
const codePoint = char.codePointAt(0);
return state.md.utils.isMdAsciiPunct(codePoint) || state.md.utils.isPunctChar(char);
}这样可以尽量和 markdown-it 原本的 punctuation 判断保持一致。
开分隔符和闭分隔符分别放宽
开分隔符的补丁逻辑是:
function shouldOpenStrongAfterCjk(state, previousChar, nextChar) {
return isCjkOrFullwidth(previousChar) && isPunct(state, nextChar);
}它解决的是这种:
中文**“条件”**在序列中** 前面是中文,后面是中文引号。默认规则可能认为这个 opening delimiter 不合法,但对中文写作来说,这是很自然的写法。
闭分隔符的补丁逻辑是:
function shouldCloseStrongNearCjk(state, previousChar, nextChar) {
if (!isPunct(state, previousChar)) return false;
return isCjkOrFullwidth(previousChar)
|| isCjkOrFullwidth(nextChar)
|| previousChar === '>'
|| previousChar === '`';
}它解决的是几类情况:
**一句话。**下一句
中文**“条件”**在序列中
中文**<u>重点</u>**后面
中文**`code`**后面其中 previousChar === '>' 是为了覆盖 </u>**后面 这种 HTML 标签结尾的情况,previousChar === ''是为了覆盖 ``code`**后面 `` 这种行内代码结尾的情况。
验证
我用了两层验证。
第一层是直接用 Hexo renderer 渲染样例:
const Hexo = require('hexo');
const hexo = new Hexo(process.cwd(), { silent: true });
await hexo.init();
const html = await hexo.render.render({
text: '中文**“条件”**在序列中',
engine: 'md'
});修复后输出是:
<p>中文<strong>“条件”</strong>在序列中</p>我还验证了这些情况:
**Java字符串的一个重要特点就是字符串不可变。**这种不可变性
中文**“条件”**在序列中
中文**<u>粗体</u>**后面
中文**`code`**后面
中文**左值(lvalue)**和**右值(rvalue)**是
中文****粗体。****后面第二层是跑 Hexo 构建:
.\node_modules\.bin\hexo.cmd generate
.\node_modules\.bin\hexo.cmd generate --force两个命令都可以正常通过。
不过这里有一个小坑:public/ 里的旧 HTML 不一定会立刻体现新规则,因为 Hexo 可能会复用 db.json 里的缓存。要完全刷新旧文章,可以在确认无误后执行:
.\node_modules\.bin\hexo.cmd clean
.\node_modules\.bin\hexo.cmd generatehexo clean 会清理生成文件和缓存,所以我没有在修脚本时随手运行它,避免不必要地扰动本地生成目录。
总结
这次修复的关键点不是在 HTML 上补救,而是回到 Markdown inline parser 的 delimiter 判断本身。
最终改动很小,只新增了一个本地脚本:
scripts/cjk-strong-emphasis.js它做的事情也很克制:
- 只 patch
markdown-it的scanDelims。 - 只处理
*,不处理_。 - 只在 CJK / 全角上下文里放宽
**的开闭判断。 - 原本能正常解析的 Markdown 仍然走原逻辑。
这类问题如果只用正则去扫 Markdown 或 HTML,很容易越修越乱。放到解析器的分隔符阶段解决,反而是更小、更稳的改动。
参考: