修复 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:

代码JS · 32 行
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: falsecan_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 generate

hexo clean 会清理生成文件和缓存,所以我没有在修脚本时随手运行它,避免不必要地扰动本地生成目录。

总结

这次修复的关键点不是在 HTML 上补救,而是回到 Markdown inline parser 的 delimiter 判断本身。

最终改动很小,只新增了一个本地脚本:

scripts/cjk-strong-emphasis.js

它做的事情也很克制:

  1. 只 patch markdown-itscanDelims
  2. 只处理 *,不处理 _
  3. 只在 CJK / 全角上下文里放宽 ** 的开闭判断。
  4. 原本能正常解析的 Markdown 仍然走原逻辑。

这类问题如果只用正则去扫 Markdown 或 HTML,很容易越修越乱。放到解析器的分隔符阶段解决,反而是更小、更稳的改动。

参考: