首页
/ Basic Text Formatting

Basic Text Formatting

2026-09-04 10:33:14作者:侯霆垣

Basic Text Formatting

Strong text Also Strong

strike and inline code

under line and 43 H2O

this is in italic and so is this

this is in bold and so is this

this is bold and italic and so is this

this will be bold

this will be bold

this is strike through text

So a single word followed _b_y _a_nother

So a single word followed __b__y __a__nother

Some markdown extensions

This is emoji :man:

This is inline math aba \ne b

Paragraph

A two trailing spaces and a new line
makes a line break.

Two new lines make a new paragraph.


它对行内语法的覆盖是刻意的"全量枚举":

- **强调(emphasize)**:`**`/`__` 加粗、`*`/`_` 斜体、`***`/`___` 加粗+斜体三种叠加形式;
- **GFM 扩展**:`~~删除线~~`、`:man:` 表情短代码;
- **Muya 私有扩展**:行内数学 `$a \ne b$`;
- **原始 HTML**:`<u>`、`<sup>`、`<sub>`、`<b>`、`<i>`、`<s>`;
- **边界用例**:`_b_y`、`__b__y` 这类"下划线嵌在单词中间"的 CommonMark 边界规则;
- **段落与换行**:两个尾部空格触发的强制换行(hard line break)与空行触发的新段落。

## 往返测试:为什么只断言"收敛"而不要求"逐字节相等"

这份夹具由 marktext 旧版测试 `test/unit/specs/markdown-basic.spec.js` 迁移(backport)而来,迁移逻辑写在 [roundTrip.spec.ts](https://gitcode.com/gh_mirrors/ma/marktext/blob/43bd8b77795fb27b1a9512737c000f7362031ea0/packages/muya/test/spec/roundTrip.spec.ts?utm_source=gitcode_repo_files) 中。测试流程是:

1. 用 `MarkdownToState` 把夹具文本解析为状态树,参数为 `footnote: false, math: true, isGitlabCompatibilityEnabled: true, frontMatter: true`;
2. 再用 `StateToMarkdown({ listIndentation: 1 })` 重新序列化回 Markdown;
3. 断言**第二轮往返的输出与第一轮输出完全相同**(收敛性),而不是要求第一轮输出与原文逐字节一致——注释里明确说明,后者过于严格且旧实现本身就不确定(例如列表缩进与导出器的规范选择不同)。

```ts
function isStableUnderRoundTrip(markdown: string): boolean {
    const once = roundTrip(markdown);
    const twice = roundTrip(once);
    return normalise(once) === normalise(twice);
}

比较前有一个 normalise 归一化步骤,它只做两件事:把 CRLF 统一为 LF、去掉末尾的换行。一个值得注意的细节是:它刻意不去掉每行行尾的空格,因为 CommonMark §6.7 规定行尾两个空格就是强制换行标记,折叠掉尾部空白会掩盖真实的往返不稳定。这条设计直接对应夹具最后一段 A two trailing spaces and a new line 行尾的两个空格。

在这 11 个夹具中,common/BasicTextFormatting.md 并不在"恒等往返"白名单里(白名单为 Images、Escapes、GFM BasicTextFormatting、GFM Tables 四份)——因为其中的 HTML 标签、表情等语法在序列化器侧有自身的规范化选择,逐字节保持原文无法保证,只需保证稳定收敛。

逐语法拆解:夹具中每个记号对应的 muya 实现

muya 的内联解析器规则集中定义在 rules.ts,按来源分为 commonMarkRulesgfmRulesinlineExtensionRules 三组。下面按夹具出现顺序逐一映射。

加粗与斜体:strong / em 规则及嵌套

strong: /^(\*\*|__)(?=\S)([\s\S]*?[^\s\\])(\\*)\1(?!(\*|_))/, // can nest
em:   /^(\*|_)(?=\S)([\s\S]*?[^\s*\\])(\\*)\1(?!\1)/,          // can nest

**Strong**__Also Strong__*this is in italic****this is bold and italic*** 全部命中这两条规则:***x*** 会被 strong 规则先匹配出外层 **...**,其内容 *x* 再递归匹配 em,从而渲染出 <strong><em>x</em></strong>。渲染端由 strong.ts 通过共用的 delEmStrongFac 工厂生成节点。

夹具中 So _a_ single _word_ followed _b_y _a_nother 一行是 CommonMark 下划线强调的边界用例:下划线只有在"flanking"(前后边界符合条件)时才能构成强调,因此 _b_y 中间的 _ 不会生效,只有 _a__word_ 是真正的斜体;__b__y 同理,只有 __b__ 是加粗。词法器在匹配到候选后,会调用 validateEmphasize(见 lexer.tstryStrongEm 的调用)按左右 flanking 规则做最终校验,而不依赖正则本身。

删除线与表情:GFM 扩展

del:   /^(~{2})(?=\S)([\s\S]*?\S)(\\*)\1/, // can nest
emoji: /^(:)([a-z_\d+-]+)\1/,

~~strike~~ 命中 del 规则,::man: 命中 emoji 规则。表情规则有一个词边界保护:在 lexer.tstryChunks 中,若 : 前一个字符是 \w(例如 12:00-14:00 里的冒号),则拒绝匹配,避免把时间格式误解析成表情(对应上游 issue #1677)。

行内代码

inline_code: /^(`{1,3})([^`]+|.{2,})\1/,

`inline code` 被解析为不可再嵌套的原子 token,其内容 to[2] 直接作为 content,不再递归 tokenize。

原始 HTML:<u><sup><sub><b><i><s>

html_tag: /^(<!--[\s\S]*?-->|(<([a-z][a-z\d-]*)[^\n<>]*>)(?:([\s\S]*?)(<\/\3 *>))?)/i, // raw html

<u>under line</u>4<sup>3</sup>H<sub>2</sub>O 等全部走 html_tag 规则。渲染端 htmlTag.ts 有两个关键处理:

  • 块级标签名或经 sanitize() 判定危险的标签会被降级为 spanselector = BLOCK_TYPE6.includes(tag) || !sanitize(<${tag}>) ? 'span' : tag),保证段落行内结构不被破坏;
  • 开闭标签被包在可被导出的 marker 中,token 的 rangeraw 保留在 dataset 里,这是序列化回 Markdown 时能原样还原 <u>...</u> 的依据——即"收敛往返"中 HTML 标签逐字保留的底层机制。

行内数学:muya 私有扩展

inline_math: /^(\$)((?:[^$\\]|\\.)+)(\\*)\1(?!\1)/,

$a \ne b$ 命中 inlineExtensionRules.inline_math。测试中 MarkdownToState 显式开启 math: true,序列化时按相同标记原样输出,因此往返稳定。

强制换行与新段落

soft_line_break: /^(\n)(?!\n)/,
hard_line_break: /^( {2,})(\n)(?!\n)/,

夹具末段 A two trailing spaces and a new line␠␠\nmakes a line break. 行尾两个空格命中 hard_line_break(捕获组 1 保存空格、捕获组 2 保存换行),序列化时原样写回两个空格加换行;而 Two new lines make a new paragraph. 前的空行则使状态树生成两个独立的 paragraph 块,由块级解析(markdownToState.ts)而非内联层处理。

词法器:规则优先级是一份"契约"

上面所有规则的尝试顺序固化在 lexer.tsINLINE_HANDLERS 数组中:

const INLINE_HANDLERS: ReadonlyArray<(state: ILexState) => boolean> = [
    tryBacklash,        // 转义 \x
    tryStrongEm,        // strong / em(含嵌套校验)
    tryChunks,          // inline_code / del / emoji / inline_math
    trySuperSubScript,  // ^sup^ / ~sub~(可选项)
    tryFootnote,
    tryImage,
    tryLink,
    tryReferenceLink,
    tryReferenceImage,
    tryHtmlEscape,      // &amp; 等 HTML 转义
    tryAutoLinkExtension,
    tryAutoLink,
    tryHtmlTag,
    trySoftLineBreak,
    tryHardLineBreak,
    tryTailHeader,
];
登录后查看全文
热门项目推荐
相关项目推荐