JavaScript Library – Markdown

介绍

什么是 Markdown?

Markdown 是一种极简化版的 HTML 表达语言。

直接看例子就能体会了

图1

图2

简单说,它就是用一种更简单,更简短的纯文本去表达 HTML。

很多地方都可以看到 Markdown 的运用,比如 Github 提交 Issue 采用的就是 Markdown 语法

还有,我们经常看的 CHANGELOG.md,这个 .md 指的就是 Markdown 文档格式。

 

主要参考

YouTube – The Only Markdown Crash Course You Will Ever Need

官网 – Markdown Guide

JavaScript Library – marked

.NET Library – markdig

List of Markdown Libraries

 

Basic Syntax

Markdown 有分 basic syntax 和 extended syntax。

extended syntax 不一定所以的 library 都支持,或统一,所以我们在使用时要自行确认一下。

注:不熟悉 HTML tags 的可以参考这篇 HTML – HTML Tags & Semantic HTML 语义化 HTML

<p>

paragraph

<!-- parse to -->

<p>paragraph</p>

上半段是 Markdown 语法,下半段是 HTML。

普通一个 text 会被 parse to <p>。

<br> 分段

Hello  <!--注:这里结尾有 2 个 space 哦 -->
World

<!-- parse to -->

<p>Hello<br>World</p>

注意,Hello 的后面放了 2 个 space,然后 new line。

这表示要 <br>。

如果你觉得 2 个 space 不容易被看见,怕出错,它也支持直接写 <br>。

Hello<br>World

<!-- parse to -->

<p>Hello<br>World</p>

如果只是 new line 没有 2 个 space 会 parse 成这样

Hello
World

<!-- parse to -->

<p>Hello
World</p>

需要搭配 style white-space: pre-line

如果要大分段,则使用 empty line

Hello

World

<!-- parse to -->

<p>Hello</p>
<p>World</p>

<h1> – <h6>

# Title
## Title
### Title
#### Title
##### Title
###### Title

<!-- parse to -->

<h1>Title</h1>
<h2>Title</h2>
<h3>Title</h3>
<h4>Title</h4>
<h5>Title</h5>
<h6>Title</h6>

就是一路加井号就对了。

<strong> & <em>

bold 和 italics

**bold**               <!-- parse to --> <p><strong>bold</strong></p>
*italics*              <!-- parse to --> <p><em>italics</em></p>
***italics and bold*** <!-- parse to --> <p><em><strong>italics and bold</strong></em></p>

如果我们想要 asterisk 符号,那就用反斜杠破掉它。

\*pure asterisk\* <!-- parse to --> <p>*pure asterisk*</p>

<blockquotes>

> blockquote

<!-- parse to -->

<blockquote>
  <p>blockquote</p>
</blockquote>

想要 multiple line,就继续加 >

> # Title
> description...

<!-- parse to -->

<blockquote>
  <h1>Title</h1>
  <p>description...</p>
</blockquote>

注:有些 Markdown Library 比较 smart (e.g. JS -- marked),即使没有 > 它也知道是 multiple line,只要 new line 就可以了,但也有些比较不 smart (.NET -- markdig),所以最好还是给每行加上 > 吧。

要 nested 就 double >>

> blockquote
>> nested blockquote

<!-- parse to -->

<blockquote>
  <p>blockquote</p>
  <blockquote>
    <p>nested blockquote</p>
  </blockquote>
</blockquote>

<ol>, <ul>, <li>

ol > li

1. item 1
2. item 2
3. item 3
3. item 4
3. item 5

<!-- parse to-->

<ol>
  <li>item 1</li>
  <li>item 2</li>
  <li>item 3</li>
  <li>item 4</li>
  <li>item 5</li>
</ol>

号码不需要顺序,只要是号码就可以了

另外,号码必须是 positive,如果是 starts with zero,它会 parse to <ol start="0">

ul > li

* item 1
* item 2

<!-- parse to-->

<ul>
  <li>item 1</li>
  <li>item 2</li>
</ul>

除了 asterisk * 符号,用 dash - 或 plus + 符号也可以,效果一样。

nested 

* item 1
  0. item 1.0
  1. item 1.1
* item 2

<!-- parse to-->

<ul>
  <li>item 1
    <ol start="0">
      <li>item 1.0</li>
      <li>item 1.1</li>
    </ol>
  </li>
  <li>item 2</li>
</ul>

加 2 个 space 在前面 (术语叫 indentation,用 Material Design 话术叫 inset 也可以) 表示要 nested。

<pre>, <code>

` 表示一行代表

`const firstName = 'Derrick';`

<!-- parse to-->

<p><code>const firstName = &#39;Derrick&#39;;</code></p>

``` 表示多行代码 (注:这个是 extended syntax)

```
  const firstName = 'Derrick';
  const lastName = 'Yam';
```

<!-- parse to-->

<pre>
  <code>
    const firstName = 'Derrick';
    const lastName = 'Yam';
  </code>
</pre>

此外,它还可以声明语言

```js
  const firstName = 'Derrick';
  const lastName = 'Yam';
```

<!-- parse to-->

<pre>
  <code class="language-js">
    const firstName = 'Derrick';
    const lastName = 'Yam';
  </code>
</pre>

会多一个 language class,CSS 可以针对不同语言做不同样式处理。

<img>

![image alt](/yangmi.jpg)

<!-- parse to-->

<p><img src="/yangmi.jpg" alt="image alt" /></p>

可以声明 alt 和 src。

src 可以是相对路径或绝对路径 URL 都可以。

要加 title 也可以

![image alt](/yangmi.jpg "Yang Mi")

<!-- parse to-->

<p><img src="/yangmi.jpg" alt="image alt" title="Yang Mi" /></p>

<hr>

使用 triple underscrore ___ 表示 <hr>

line1
___
line2

<!-- parse to-->

<p>line1</p>
<hr />
<p>line2</p>

除了 underscore,triple asterisk *** 和 triple dash --- 也是可以。

line1
***
line2

或者

line1

---
line2

不过 triple dash --- 要多一个 new line,不然它会变成 <h2>,因为 triple dash --- 和 triple equal === 也可以用作 <h2> 和 <h1>。

Title
===

<!-- parse to-->

<h1>Title</h1>


Title
---

<!-- parse to-->

<h2>Title</h2>

我个人的习惯是 heading 统一用 #,<hr> 统一用 underscore ___

<a>

[click to about page](/about)

<!-- parse to-->

<p><a href="/about">click to about page</a></p>

语法和 <img> 雷同,只是前面少了 ! 惊叹号。

和 img 一样也可以额外添加 title

[click to about page](/about "About page")

<!-- parse to-->

<p><a href="/about" title="About page">click to about page</a></p>

但很遗憾,它不支持 target="_blank"。

有一种写法是这样

[link](url){:target="_blank"}
or
[link](url){target="_blank"}

但不是正规的,大部分 parser 都不支持。

另外,我们也可以用 email text 或者 URL 直接作为 <a>

<hengkeat87@gmail.com>

<https://www.stooges.com.my>

<!-- parse to-->

<p><a href="mailto:hengkeat87@gmail.com">hengkeat87@gmail.com</a></p>
<p><a href="https://www.stooges.com.my">https://www.stooges.com.my</a></p>

wrap 一个 <> 符号到 email 和 URL 上就可以了。

HTML tags

不是所有的 HTML 都有对应的 Markdown。

比如 underline <u> 和 <ins> 就没有。

但是,Markdown Library 通常允许我们直接写 HTML tags 来弥补它的不足,比如

<u>underline</u>

<ins>underline</ins>

<!-- parse to-->

<p><u>underline</u></p>
<p><ins>underline</ins></p>

直接写,它是可以 parse 出来的 (注:也不是所有的 HTML 都可以 parse 啦)。

常见的有 <sub>、<sup>、<mark>、<del>,这几个有些 extended syntax 支持,有些不一定支持。

 

Extended Syntax

上一 part 是 basic,这一 part 是 extended syntax。

注:不是所有的 Library 都支持 parse extended syntax 哦,使用时记得先检查清楚。

<table>

| Header1 | Header2 |
| ------- | ------- |
| Cell 2A | Cell 2B |
| Cell 3A | Cell 3B |

<!-- parse to-->

<table>
  <thead>
    <tr>
      <th>Header1</th>
      <th>Header2</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Cell 2A</td>
      <td>Cell 2B</td>
    </tr>
    <tr>
      <td>Cell 3A</td>
      <td>Cell 3B</td>
    </tr>
  </tbody>
</table>

利用 pipe | 和 dash --- 做分割。

pipe 和 triple dash 不需要对齐,像下面这样也是可以的。

| Header1 | Header2 |
| --- | --- |
| Value1 | Value2 |

要 align 也可以,加上分号:

| Header1 | Header2 | Header3 |
| ------: | :-----: | :------ |
| Value1 | Value2 | Value3 |
| Value4 | Value5 | Value6 |

<!-- parse to-->

<table>
  <thead>
    <tr>
      <th align="right">Header1</th>
      <th align="center">Header2</th>
      <th align="left">Header3</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td align="right">Value1</td>
      <td align="center">Value2</td>
      <td align="left">Value3</td>
    </tr>
    <tr>
      <td align="right">Value4</td>
      <td align="center">Value5</td>
      <td align="left">Value6</td>
    </tr>
  </tbody>
</table>

不过它只能批量,不能针对特定 cell align。

另外,有些 Markdown Library 会 parse to style,像这样

<th style="text-align: right;">Header1</th>
<th style="text-align: center;">Header2</th>
<th style="text-align: left;">Header3</th>

比如说 JS --marked 是 parse to align="center",而 .NET -- markdig 则是 style="text-align: center;"

<h1 - h6> with [id]

# My Great Heading {#custom-id}

<!-- parse to-->

<h1 id="custom-id">My Great Heading</h1>

link to Heading with [id]

[Heading IDs](#custom-id)

<!-- parse to-->

<p><a href="#custom-id">Heading IDs</a></p>

<dl>, <dt>, <dd>

First Term
  : This is the definition of the first term.

Second Term
  : This is one definition of the second term.
  : This is another definition of the second term.

<!-- parse to-->

<dl>
  <dt>First Term</dt>
  <dd>This is the definition of the first term.</dd>
  <dt>Second Term</dt>
  <dd>This is one definition of the second term.</dd>
  <dd>This is another definition of the second term.</dd>
</dl>

注:dd 分号前面一定要有两个空格,后面一定要有一个空格哦。

dt 要用 empty line 做间隔,否则

First Term
  : This is the definition of the first term.
Second Term
  : This is one definition of the second term.

<!-- parse to-->

<dl>
  <dt>First Term</dt>
  <dd>This is the definition of the first term.
  Second Term</dd>
 
  <dd>This is one definition of the second term.</dd>
</dl>

Second Term 不被认定为 <dt> 反而是上一个 <dd> 的内容。

有些 parser 支持 multiple <dt> 但有些不支持哦

First Term
  : This is the definition of the first term.

Second Term
Third Term
  : This is one definition of the second term.

<!-- parse to-->

<dl>
  <dt>First Term</dt>
  <dd>This is the definition of the first term.</dd>
  
  <dt>Second Term</dt>
  <dt>Third Term</dt>
  <dd>This is one definition of the second term.</dd>
</dl>

像 C# 的 markdig 支持,但 JS 的 markdown-it 不支持。

<del>

~~delete~~

<!-- parse to-->

<p><del>delete</del></p>

<mark>

==mark==

<!-- parse to-->

<p><mark>mark</mark></p>

总结

basic 和 extended syntax 大概就是这些,我没有 100% 的列出,只是写了我有接触到的,想看完整的 list 可以看这三篇:Basic SyntaxExtended SyntaxHacks

 

JavaScript Library for Markdown – marked

JS 有好几个 Markdown Library 可选,比较火的是 marked

get started

安装

yarn add marked

它源码是用 TypeScript 写的,直接支持类型了。

调用

import { marked } from 'marked';

const html = marked.parse(`# Hello World`); // <h1>Hello World</h1>\n

import + pares 就可以了,非常简单。

注:block element 的结尾会附带 \n 哦。

configuration

marked 可以做一些简单的配置。

我们先看看如何 set config。

const html = await marked.parse(`# Hello World`, { async: true }); // set config on each call

在调用 parse 时,传入 config,这是其中一个 set config 的方式。

每一次 parse 都 set 很麻烦,所以可以使用 use 来配置全局

marked.use({ async: true }); // set global config
const html = await marked.parse(`# Hello World`);

其实它也不是全局啦,marked 是对象,它的范围就是这个对象内,如果有需要,我们也可以实例化多个对象来做管理。

import { marked, Marked } from 'marked';

const marked1 = new Marked();
marked1.use({ async: true });

const marked2 = new Marked();
marked2.use({ async: false });

const html1 = await marked.parse(`# Hello World`); // 有 async 
const html2 = marked.parse(`# Hello World`);       // 没 async

常见 config

所有的 config list 看这篇 -- Options

这里只点出我常用的。

import { marked } from 'marked';

marked.use({ async: true, breaks: true });

const html = await marked.parse(`
paragraph1
paragraph2
`);

console.log(html); // <p>paragraph1<br>paragraph2</p>

两个点:

  1. parse 默认是同步 sync 的,如果想要 async 可以设置 async: true

  2. 本来 <br> 需要在结尾加两个 space,开启 breaks: true 之后就不需要了。

另外,markded 默认 config gfm: true,gfm stand for GitHub Flavored Markdown,就是 Github 采用的 Markdown 标准 (里头除了 basic 和 一些 extended syntax)。

其中一个特色是 auto convert link

const html1 = marked.parse('hengkeat87@gmail.com');                 // <p><a href="mailto:hengkeat87@gmail.com">hengkeat87@gmail.com</a></p>
const html2 = marked.parse('hengkeat87@gmail.com', { gfm: false }); // <p>hengkeat87@gmail.com</p>

gfm: true 的情况下,email text 会直接 parse 成 <a>,原版的 Markdown 需要 wrap 一层 <> 符号 <hengkeat87@gmail.com> 才会 parse to <a>。

其它操作

这里列出一些我日常用过的操作

parseInline

const html1 = marked.parse('hengkeat87@gmail.com');       // <p><a href="mailto:hengkeat87@gmail.com">hengkeat87@gmail.com</a></p>
const html2 = marked.parseInline('hengkeat87@gmail.com'); // <a href="mailto:hengkeat87@gmail.com">hengkeat87@gmail.com</a>

主要区别是有没有 wrap 一层 <p>,inline 就没有。

Extensions

marked 支持插件扩展 (比如 Markdown extended syntax 甚至是超出这个范围),这里介绍一些常用的 extensions。(注:想看完整的 list -- 这篇 Known Extensions

marked-custom-heading-id

要支持 Markdown extended syntax -- heading with [id],需要额外的插件

安装

yarn add marked-custom-heading-id

使用 marked.use() 来 register plugin。 

import { marked } from 'marked';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import customHeadingId from 'marked-custom-heading-id';

// eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-call
marked.use(customHeadingId());

const html = marked.parse('# heading {#custom-id}'); // <h1 id="custom-id">heading</h1>

由于这个插件不是 TypeScript 写的,也没有 .d.ts 文档,所以我使用了 @ts-ignore 来 by pass。(注:大多数的 plugin 是支持类型的,只有少数是不支持的)

注:如果不想使用插件,自己写一个 override render 也是可以实现,下一 part 会教。

marked-emoji

安装

yarn add marked-emoji
yarn add @octokit/rest

@octokit/rest 用来获取 Github 的 emojis (你想用其它库也可以),marked-emoji 是插件。

import { Octokit } from '@octokit/rest';
import { marked } from 'marked';
import { markedEmoji, MarkedEmojiOptions } from 'marked-emoji';

const octokit = new Octokit();
const response = await octokit.rest.emojis.get(); // get all emojis available to use on GitHub.
const emojis = response.data;

const options: MarkedEmojiOptions = {
  emojis,
  renderer: token => `<img alt="${token.name}" src="${token.emoji}" class="marked-emoji-img">`,
};

marked.use(markedEmoji(options));

const html = marked.parse(':smile:');
// <p><img alt="smile" src="https://github.githubassets.com/images/icons/emoji/unicode/1f604.png?v8" class="marked-emoji-img"></p>

marked-extended-tables

Markdown extended syntax 支持 <table>,但不支持 colspan,rowspan,multiple header row。

而这个插件弥补了这些缺失。

安装

yarn add marked-extended-tables

调用

import { marked } from 'marked';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import extendedTables from 'marked-extended-tables';

// eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-call
marked.use(extendedTables());
const html = marked.parse(`
| H1      | H2      | H3      |
|---------|---------|---------|
| This cell spans 3 columns |||  
`);

/*
  <table>
    <thead>
      <tr>
        <th>H1</th>
        <th>H2</th>
        <th>H3</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <td colspan=3>This cell spans 3 columns</td>
      </tr>
    </tbody>
  </table>
*/

上面是 colspan 的 markdown 写法,再看看 rowspan 和 multiple header row 的写法

multiple header rows

效果

还有 column width 的写法

override default renderer & tokenizer

如果我们想拦截 marked parse 的过程,然后动点手脚的话,可以 override default renderer。

下面这个是让 marked 支持 heading + id property (默认是不支持的,上一 part 有提到如何使用插件实现,这里给一个不依赖插件的做法)

import { marked, Tokens } from 'marked';

// 一个带 id 的 heading
const text = `
  # Hello World {#my-id}
`;

// 创建一个 default renderer
const customRenderer = new marked.Renderer();

// override default renderer 的 heading 方法
customRenderer.heading = function ({ tokens, depth }: Tokens.Heading) {
  // 使用 original 手法 parse heading
  // 这里会得到 string 'Hello World {#my-id}'
  const headingText = this.parser.parseInline(tokens);

  // id 的正则表达
  const idRegex = / {#([a-z]+[a-z\-0-9]+)}$/;
  const matchResults = headingText.match(idRegex);

  if (matchResults) {
    // 尝试 match id
    const id = matchResults[1];
    const headlineTextWithoutId = headingText.replace(idRegex, '');

    // 如果有 match 到 id 就加进去,同时把 id 从 heading 里删除,最终返回
    return `<h${depth} id="${id}">${headlineTextWithoutId}</h${depth}>\n`;
  }

  // 如果没有 match 到 id 直接返回就可以了。
  return `<h${depth}>${headingText}</h${depth}>\n`;

  // 源码 renderer.heading 长这样:
  // heading({ tokens, depth }: Tokens.Heading): string {
  //   return `<h${depth}>${this.parser.parseInline(tokens)}</h${depth}>\n`;
  // }
};

// 把 custom renderer 配置进去 marked
marked.use({ renderer: customRenderer });

// parse markdown syntax
console.log(marked.parse(text)); // result: <h1 id="my-id">Hello World</h1>

如果我们想支持 anchor target _blank 难度会比较高。

先看看它的源码如何实现 renderer

用到了一些 internal(没用公开)的方法:cleanUrl、escape。

只有 href, title 资料,它们来自 tokens。

最后靠拼接 string 形成 <a href title>。

从这几个面向来看,完全没用 target _blank 的影子,因此我们不只是要 override renderer,还要 override tokenizer 才有办法做到。

最终的调用

const html = marked.parse(
  `
    # Hello World

    I am the [king](/king "king"){target="_blank" aria-label="test"} of the world
  `
    .split('\n')
    .map(v => v.trim())
    .join('\n'),
);

在此之前,我们需要给 marked 添加自定义的 tokenizer 和 renderer

interface IAttributes {
  attributes?: string;
}

// 自定义一个 tokenizer
const tokenizer: TokenizerObject = {
  // 我们要覆盖原本的 link 方法
  link(src) {
    // 调用原生的 link 方法
    const result = marked.Tokenizer.prototype.link.call(this, src);

    // 如果是 undefined,代表不是 anchor,不需要处理
    if (result) {
      // 此时的 src 是 [king](/king "king"){target="_blank" aria-label="test"} of the world
      // restOfSrc 就是 {target="_blank" aria-label="test"} of the world
      const restOfSrc = src.substring(result.raw.length);

      // 我们要的就是括弧内的 attributes
      if (restOfSrc.length > 0 && restOfSrc.charAt(0) === '{') {
        const indexOfEndBracket = restOfSrc.indexOf('}', 1);
        if (indexOfEndBracket !== -1) {
          // attributes 就是 target="_blank" aria-label="test"
          const attributes = restOfSrc.substring(1, indexOfEndBracket);
          // 存起来
          (result as IAttributes).attributes = attributes;
          // result.raw 就是这一次处理的字符串:[king](/king "king"){target="_blank" aria-label="test"}
          // 剩余的 ' of the world' 会继续被其它 tokenizer 解析
          result.raw += '{' + attributes + '}';
        }
      }
    }

    return result;
  },
};

const renderer = new marked.Renderer();
renderer.link = function (linkTokenInfo: Tokens.Link & IAttributes): string {
  // 调用原生的 link 方法,会拿到 anchor raw html '<a href="/king" title="king">king</a>'
  const linkRawHtml = marked.Renderer.prototype.link.call(this, linkTokenInfo) as string;
  if (linkTokenInfo.attributes === undefined) return linkRawHtml;
  // 把我们的 attributes replace 进去
  // 形成 <a target="_blank" aria-label="test" href="/king" title="king">king</a>
  return linkRawHtml.replace(/<a(\s|>)/, `<a ${linkTokenInfo.attributes}$1`);
};

marked.use({ tokenizer, renderer });

实现手法比较粗糙,但勉强能用。

figure & figcaption

Markdown 语法本身没有支持 <figure> 和 <figcaption>,官方文档 Image Captions

marked 也不支持,但 C# 的 Markdown 插件 markdig 却支持(下面会讲解)。

如果我们想要扩展 marked 可以这样写(粗糙的写法,勉强用)

renderer.paragraph = function ({ tokens }: Tokens.Paragraph) {
  if (
    tokens.length >= 3 &&
    tokens[0].raw.trim().startsWith('^^^') &&
    tokens[1].type === 'image' &&
    tokens[2].raw.trim().startsWith('^^^')
  ) {
    return `<figure>\n  ${this.parser.parseInline([tokens[1]])}${tokens.at(3) !== undefined ? `\n  <figcaption>${this.parser.parseInline([tokens[3]])}</figcaption>` : ''}\n</figure>`;
  }
  return this.parser.parseInline(tokens);
};

使用

marked(
  `
  ^^^
  ![yangmi](/yangmi.jpg)
  ^^^ *This is a caption*
`
    .split('\n')
    .map(v => v.trim())
    .join('\n'),
),

效果

<figure>
  <img src="/yangmi.jpg" alt="yangmi">
  <figcaption><em>This is a caption</em></figcaption>
</figure>

dl dt dd

marked 不支持 dl dt dd

markdown-it (JS) 和 markdig (C#) 支持,但是大家的语法解析规范不完全一致。

marked 官方有 issue 但没人要做,它只是给了一个 extension 模板让大家自己去实现,但规范和 markdown-it、markdig 又不一样...

我自己大概写了一个,勉强够我用

markdown.ts

import { Tokens, TokenizerExtension, RendererExtension, marked, Token } from 'marked';

interface DescriptionListToken extends Tokens.Generic {
  dtAndDds: { type: 'dt' | 'dd'; raw: string; tokens: Token[]; tokenType: 'block' | 'inline' }[];
}

const descriptionListTokenizer: TokenizerExtension = {
  name: 'dl',
  level: 'block',
  tokenizer(src) {
    const DD_PREFIX = '  : ';
    if (!src.includes(DD_PREFIX)) return;

    // note 解释:
    // 把自己 (dl tokenizer) 移除掉,不然会死循环,等处理完后才放回去
    const currTokenizer = descriptionListTokenizer.tokenizer;
    const currTokenizerIndex = this.lexer.options.extensions!.block!.indexOf(currTokenizer);
    this.lexer.options.extensions!.block!.splice(currTokenizerIndex, 1);

    // note 解释:
    // 这里的步骤是先 loop 一轮,把 "可能" 的都放进 actions 先
    // 比如:发现一个 dt,但假如后面没有跟着 dd,那它其实不是真的 dt,可这要 loop 到后面才知道,所以无论如何得先存着先。
    // 后续会再 loop 一轮做清理
    const lines = src.split('\n');
    const actions: { type: '' | 'dt' | 'dd' | 'dd continuation'; line: string }[] = [];
    for (let index = 0; index < lines.length; index++) {
      const line = lines[index];
      const tokens = this.lexer.blockTokens(line);
      const isEmptyLine = tokens.length === 0;
      const isSpace = tokens.length === 1 && tokens[0].type === 'space';
      const isParagraph = tokens.length === 1 && tokens[0].type === 'paragraph';
      const isPreCode = tokens.length === 1 && tokens[0].type === 'code';

      const lastAction = actions.at(-1);
      const withoutEmptyLineLastAction = actions.filter(({ type }) => type !== '').at(-1);

      if (isEmptyLine || isSpace) {
        actions.push({ type: '', line });
        continue;
      }

      const preCodeButMaybeDdContinuation = isPreCode && withoutEmptyLineLastAction?.type.startsWith('dd');

      if (isParagraph || preCodeButMaybeDdContinuation) {
        const isDd = line.startsWith(DD_PREFIX);

        // note 解释:add dd
        if (isDd) {
          const hasDt = actions.find(({ type }) => type === 'dt') !== undefined;
          // note 解释:如果之前没有 dt,那这个 dd 会被认作为 dt,而不是 dd。
          actions.push({ type: hasDt ? 'dd' : 'dt', line });
          continue;
        }

        // note 解释:add dt
        if (!lastAction || lastAction.type === 'dt') {
          actions.push({ type: 'dt', line });
          continue;
        }

        // note 解释:add dd isDdContinuation
        if (lastAction.type.startsWith('dd')) {
          actions.push({ type: 'dd continuation', line });
          continue;
        }

        // note 解释:add dt or dd continuation
        if (lastAction.type === '') {
          if (actions.every(({ type }) => type === '')) {
            actions.push({ type: 'dt', line });
            continue;
          }

          if (withoutEmptyLineLastAction?.type.startsWith('dd')) {
            if (line.startsWith('    ')) {
              // note 解释:
              // 这里需要把之前的 empty line 变成 dd continuation
              // 因为 empty line 对 dd 是有意义的
              // 比如
              /*
                 Term B
                   : First definition paragraph.

                     Second paragraph.
              */
              // dd 内会有两个 p,就是因为有 empty line
              for (const action of actions.toReversed()) {
                if (action.line !== '') break;
                action.type = 'dd continuation';
              }
              actions.push({ type: 'dd continuation', line });
            } else {
              actions.push({ type: 'dt', line });
            }
            continue;
          }

          // 解释:
          // dt
          // empty line
          // dt
          // 这种情况等同于遇到了 block,该结束了,直接 break 掉它。
          if (withoutEmptyLineLastAction?.type === 'dt') {
            break;
          }

          throw new Error('never');
        }
      }

      // 能到了这里就是 block 了
      if (!withoutEmptyLineLastAction?.type.startsWith('dd')) {
        actions.length === 0;
      }
      break;
    }

    // note 解释:
    // 从后面 loop 起身,把 empty line 和没有 dd 的 dt 移除
    const actionsToRemove: typeof actions = [];
    for (let index = actions.length - 1; index >= 0; index--) {
      const action = actions[index];
      if (action.type === '' || action.type === 'dt') {
        actionsToRemove.unshift(...actions.splice(index, 1));
        continue;
      }
      break;
    }

    // note 解释:如果移除的全都是 empty line 那就加回去
    if (actionsToRemove.every(action => action.type === '')) {
      actions.push(...actionsToRemove);
    }

    if (actions.length === 0) {
      this.lexer.options.extensions!.block!.splice(currTokenizerIndex, 0, currTokenizer);
      return;
    }

    const dtAndDds: DescriptionListToken['dtAndDds'] = [];
    const raws: string[] = [];

    for (let index = 0; index < actions.length; index++) {
      const { type, line } = actions[index];

      const generateDdTokens = (ddRaw: string): { tokens: Token[]; tokenType: 'block' | 'inline' } => {
        const blockTokens = this.lexer.blockTokens(ddRaw.trim());

        return blockTokens.length === 1 && blockTokens[0].type === 'paragraph'
          ? {
              tokens: this.lexer.inlineTokens(ddRaw.trim()),
              tokenType: 'inline',
            }
          : {
              tokens: blockTokens,
              tokenType: 'block',
            };
      };

      raws.push(line);
      if (type === '') continue;

      if (type === 'dt') {
        const raw = line.trim();
        dtAndDds.push({ type: 'dt', raw, tokens: this.lexer.inlineTokens(raw), tokenType: 'inline' });
        continue;
      }

      if (type === 'dd') {
        const raw = line.substring(DD_PREFIX.length).trim();
        dtAndDds.push({ type: 'dd', raw: raw, ...generateDdTokens(raw) });
        continue;
      }

      if (type === 'dd continuation') {
        const dd = dtAndDds.at(-1)!;
        dd.raw += `\n${line.trim()}`;
        ({ tokens: dd.tokens, tokenType: dd.tokenType } = generateDdTokens(dd.raw));
        continue;
      }

      throw new Error('never');
    }

    const tokenRaw = raws.join('\n');
    const token: DescriptionListToken = {
      type: 'dl',
      raw: tokenRaw,
      dtAndDds,
    };

    this.lexer.options.extensions!.block!.splice(currTokenizerIndex, 0, currTokenizer);
    return token;
  },
};

const descriptionListRenderer: RendererExtension = {
  name: 'dl',
  renderer(token) {
    this.parser.parseInline;
    const dlToken = token as DescriptionListToken;
    const dtAndDdRaw = dlToken.dtAndDds
      .map(({ type, tokens, tokenType }) =>
        type === 'dt'
          ? `<dt>${this.parser[tokenType === 'block' ? 'parse' : 'parseInline'](tokens)}</dt>`
          : `<dd>${this.parser[tokenType === 'block' ? 'parse' : 'parseInline'](tokens)}</dd>`
      )
      .join('\n');

    return `<dl>\n${dtAndDdRaw}\n</dl>\n`;
  },
};

marked.use({ extensions: [descriptionListTokenizer, descriptionListRenderer] });

export function markdown(src: string): string {
  return marked.parse(src) as string;
}
View Code

markdown.test.ts (Vitest)

import { describe, it, expect } from 'vitest';
import { markdown } from'./markdown';

describe(`test function 'markdown'`, () => {
  it('Test 1: simple dt + dd', () => {
    const markdownText = `
Term
  : Definition
`;

    const rawHtml = markdown(markdownText);

    expect(rawHtml).toBe(`
<dl>
<dt>Term</dt>
<dd>Definition</dd>
</dl>
`.trimStart());
  });

  it('Test 2: dd with multiple lines', () => {
    const markdownText = `
Term
  : First line
    Second line
`;

    const rawHtml = markdown(markdownText);

    expect(rawHtml).toBe(`
<dl>
<dt>Term</dt>
<dd>First line
Second line</dd>
</dl>
`.trimStart());
  });

  it('Test 3: multiple dt sharing one dd', () => {
  const markdownText = `
Term A
Term B
  : Shared definition
`;

  const rawHtml = markdown(markdownText);

  expect(rawHtml).toBe(`
<dl>
<dt>Term A</dt>
<dt>Term B</dt>
<dd>Shared definition</dd>
</dl>
`.trimStart());
  });

  it('Test 4: one dt with multiple dd', () => {
  const markdownText = `
Term
  : Definition A
  : Definition B
`;

  const rawHtml = markdown(markdownText);

  expect(rawHtml).toBe(`
<dl>
<dt>Term</dt>
<dd>Definition A</dd>
<dd>Definition B</dd>
</dl>
`.trimStart());
  });

  it('Test 5: multiple dt with multi-line dd', () => {
  const markdownText = `
Term A
Term B
  : First definition line
    Second definition line
  : Another definition
`;

  const rawHtml = markdown(markdownText);

  expect(rawHtml).toBe(`
<dl>
<dt>Term A</dt>
<dt>Term B</dt>
<dd>First definition line
Second definition line</dd>
<dd>Another definition</dd>
</dl>
`.trimStart());
  });

  it('Test 6: dd with multiple paragraphs', () => {
  const markdownText = `
Term
  : First paragraph

    Second paragraph
`;

  const rawHtml = markdown(markdownText);

  expect(rawHtml).toBe(`
<dl>
<dt>Term</dt>
<dd><p>First paragraph</p>
<p>Second paragraph</p>
</dd>
</dl>
`.trimStart());
  });

  it('Test 7: dd continuation line', () => {
    const markdownText = `
Term
  : Definition line 1
Definition line 2
  : Another definition
`;

    const rawHtml = markdown(markdownText);

    expect(rawHtml).toBe(`
<dl>
<dt>Term</dt>
<dd>Definition line 1
Definition line 2</dd>
<dd>Another definition</dd>
</dl>
`.trimStart());
  });

  it('Test 8: mixed indentation, multi-line dd', () => {
    const markdownText = `
Term A
Term B
  : Definition 1 line 1
    Definition 1 line 2
  : Definition 2
Term C
  : Single definition
`;

    const rawHtml = markdown(markdownText);

    expect(rawHtml).toBe(`
<dl>
<dt>Term A</dt>
<dt>Term B</dt>
<dd>Definition 1 line 1
Definition 1 line 2</dd>
<dd>Definition 2
Term C</dd>
<dd>Single definition</dd>
</dl>
`.trimStart());
  });

  it('Test 9: dt followed by empty line', () => {
    const markdownText = `
Term A

  : Definition 1
`;

    const rawHtml = markdown(markdownText);

    expect(rawHtml).toBe(`
<dl>
<dt>Term A</dt>
<dd>Definition 1</dd>
</dl>
`.trimStart());
  });

  it('Test 10: multiple dd without dt', () => {
    const markdownText = `
  : Definition 1
  : Definition 2
`;

    const rawHtml = markdown(markdownText);

    expect(rawHtml).toBe(`
<dl>
<dt>: Definition 1</dt>
<dd>Definition 2</dd>
</dl>
`.trimStart());
  });

  it('Test 11: dd containing a list', () => {
    const markdownText = `
Term
  : List:
    - Item 1
    - Item 2
`;

    const rawHtml = markdown(markdownText);

    expect(rawHtml).toBe(`
<dl>
<dt>Term</dt>
<dd><p>List:</p>
<ul>
<li>Item 1</li>
<li>Item 2</li>
</ul>
</dd>
</dl>
`.trimStart());
  });

  it('Test 12: complex multi dt/dd with paragraphs and list', () => {
    const markdownText = `
Term A
Term B
  : First definition paragraph.

    Second paragraph.
    - Item 1
    - Item 2
  : Another definition
Term C
  : Single definition
`;

    const rawHtml = markdown(markdownText);

    expect(rawHtml).toBe(`
<dl>
<dt>Term A</dt>
<dt>Term B</dt>
<dd><p>First definition paragraph.</p>
<p>Second paragraph.</p>
<ul>
<li>Item 1</li>
<li>Item 2</li>
</ul>
</dd>
<dd>Another definition
Term C</dd>
<dd>Single definition</dd>
</dl>
`.trimStart());
  });

  it('Test 13: dd containing a code block', () => {
  const markdownText = `
Term
  : \`\`\`js
    console.log('hello')
    \`\`\`
`;

  const rawHtml = markdown(markdownText);

  expect(rawHtml).toBe(`
<dl>
<dt>Term</dt>
<dd><pre><code class="language-js">console.log(&#39;hello&#39;)
</code></pre>
</dd>
</dl>
`.trimStart());
  });

  it('Test 14: dd containing a blockquote', () => {
    const markdownText = `
Term
  : > This is a quote
    > spanning two lines
`;

    const rawHtml = markdown(markdownText);

    expect(rawHtml).toBe(`
<dl>
<dt>Term</dt>
<dd><blockquote>
<p>This is a quote
spanning two lines</p>
</blockquote>
</dd>
</dl>
`.trimStart());
  });

  it('Test 15: dd containing inline formatting', () => {
    const markdownText = `
Term
  : Definition with *italic*, **bold**, and \`inline code\`
`;

    const rawHtml = markdown(markdownText);

    expect(rawHtml).toBe(`
<dl>
<dt>Term</dt>
<dd>Definition with <em>italic</em>, <strong>bold</strong>, and <code>inline code</code></dd>
</dl>
`.trimStart());
  });

  it('Test 16: empty dd', () => {
    const markdownText = `
Term
  :
`;

    const rawHtml = markdown(markdownText);

    expect(rawHtml).toBe(`
<p>Term
  :</p>
`.trimStart());
  });
});
View Code

总结

我目前有用到的大致上就是这些了,更多功能以后有用到才来补上。

 

.NET Library for Markdown – markdig

.NET 有好几个 Markdown Library 可选,比较火的是 markdig

它的文档比较简陋,我懒得深入研究了。

基本上 basic / extended syntax 都支持。

get started

安装

dotnet add package Markdig

Program.cs

using Markdig;

var result = Markdown.ToHtml("# Hello World");

Console.WriteLine(result); // <h1>Hello World</h1>

extended syntax

要支持 extended syntax 就添加 pipeline。

using Markdig;

var pipeline = new MarkdownPipelineBuilder().UseAdvancedExtensions().Build();

var result = Markdown.ToHtml(@"
  | Header1 | Header2 |
  | ------- | ------- |
  | Cell 2A | Cell 2B |
", pipeline);

Console.WriteLine(result);

/*
<table>
  <thead>
    <tr>
      <th>Header1</th>
      <th>Header2</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Cell 2A</td>
      <td>Cell 2B</td>
    </tr>
  </tbody>
</table>
*/

上面这个是全部都开启,如果只想针对 table extended syntax,那可以这样写。

var pipeline = new MarkdownPipelineBuilder().UsePipeTables().Build();

注:这个 table syntax 不支持 colspan 哦,想支持 colspan 要用 Grid Table 插件。

var pipeline = new MarkdownPipelineBuilder().UseGridTables().Build();

Markdown 语法也不同

+---------+---------+---------+
| Col1    | Col2    | Col3    |
| Col1a   | Col2a   | Col3a   |
| Col1b             | Col3b   |
| Col1c                       |

<!-- parse to-->

<table>
  <col style="width:33.33%" />
  <col style="width:33.33%" />
  <col style="width:33.33%" />
  <tbody>
    <tr>
      <td>Col1
        Col1a</td>
      <td>Col2
        Col2a</td>
      <td>Col3
        Col3a</td>
    </tr>
    <tr>
      <td colspan="2">Col1b</td>
      <td>Col3b</td>
    </tr>
    <tr>
      <td colspan="3">Col1c</td>
    </tr>
  </tbody>
</table>

注意事项

开启所有的 extended syntax (UseAdvancedExtensions) 是挺危险的,一不小心可能会有意想不到的 parse 结果哦,比如

自动加 id

var pipeline = new MarkdownPipelineBuilder().UseAdvancedExtensions().Build();
var result = Markdown.ToHtml(@"# Hello World", pipeline); // <h1 id="hello-world">Hello World</h1>

auto convert link, but not email

var pipeline = new MarkdownPipelineBuilder().UseAdvancedExtensions().Build();
var result = Markdown.ToHtml(@"
  website: https://www.stooges.com.my
  
  email: hengkeat87@gmail.com
", pipeline);

// <p>website: <a href="https://www.stooges.com.my">https://www.stooges.com.my</a></p>
// <p>email: hengkeat87@gmail.com</p>

所以最好还是挨个挨个添加 extensions

var pipeline = new MarkdownPipelineBuilder()
  .UseAutoIdentifiers() // auto add id 
  .UseAutoLinks() // auto convert link 
  .Build();

如果真的太多了,那就只能反过来,添加 all 然后再 remove 特定的 extension。

var pipelineBuilder = new MarkdownPipelineBuilder()
  .UseAdvancedExtensions();

pipelineBuilder.Extensions.Remove(pipelineBuilder.Extensions.Find<AutoIdentifierExtension>()!); // remove auto add id
pipelineBuilder.Extensions.Remove(pipelineBuilder.Extensions.Find<AutoLinkExtension>()!); // remove auto convert link

var pipeline = pipelineBuilder.Build();

anchor target _blank and heading id

有 built-in 支持

var pipeline = new MarkdownPipelineBuilder()
  .UseGenericAttributes()
  .Build();

var markdown = """
  [click to about page](/about "About page"){target="_blank"}

  # My Great Heading {#custom-id}
""";

Console.WriteLine(Markdown.ToHtml(markdown, pipeline));

/*
  <p><a href="/about" target="_blank" title="About page">click to about page</a></p>
  <h1 id="custom-id">My Great Heading</h1>
*/

它不只是 target="_blank" 和 id,任何 attribute 都可以添加。

只要在后续写上 { attributeName="attributeValue" } 就可以了(注:支持 multiple attribute,用空格 space 做分割)。

figure & figcaption

Markdown 语法本身没有支持 <figure> 和 <figcaption>,官方文档 Image Captions

不过 markdig 有扩展支持,语法长这样:

^^^
![yangmi](/yangmi.jpg)
^^^ *This is a caption*

<figure>
  <img src="/yangmi.jpg" alt="yangmi">
  <figcaption><em>This is a caption</em></figcaption>
</figure>

加上 UseFigures 就可以了

var pipeline = new MarkdownPipelineBuilder().UseFigures().Build();
var html = Markdown.ToHtml(markdown, pipeline);

dl, dt, dd

UseDefinitionLists()

string markdown = @"
First Term
  : This is the definition of the first term.

Second Term
  : This is one definition of the second term.
";

var pipeline = new MarkdownPipelineBuilder()
    .UseDefinitionLists()
    .Build();

string html = Markdown.ToHtml(markdown, pipeline);

Console.WriteLine(html);
/*
<dl>
  <dt>First Term</dt>
  <dd>This is the definition of the first term.</dd>
  <dt>Second Term</dt>
  <dd>This is one definition of the second term.</dd>
</dl>
*/

parseInline

marked 有 parseInline 的功能,但 markdig 却没有。

相关 Issue – Setting ImplicitParagraph has no effect

目前的 workaround 是这样

using Markdig;
using Markdig.Renderers;

var pipelineBuilder = new MarkdownPipelineBuilder();
var pipeline = pipelineBuilder.Build();

var markdown = "**Hello World**";

Console.WriteLine(Markdown.ToHtml(markdown, pipeline)); // <p><strong>Hello World</strong></p> 会有 paragraph


using var writer = new StringWriter();
var renderer = new HtmlRenderer(writer)
{
  ImplicitParagraph = true
};
pipeline.Setup(renderer);

var document = Markdown.Parse(markdown, pipeline, null);
renderer.Render(document);
Console.WriteLine(writer.ToString()); // <strong>Hello World</strong> 没有 paragraph

使用底层的 HtmlRenderer 做 parse。

markdown to plain text

有时候我们想把 markdown 转成 plain text 而不是 raw html(比如:想生成 JSON-LD)。

markdig 没用 built-in 这个功能,我目前的方案是把 markdown 转成 raw html,接着在用 AngleSharp(类似 browser 的 DOM API)解析 HTML 取出 plain text。

string markdown = @"
**How often should I service my aircon?**

We recommend servicing your aircon every 3-6 months to maintain optimal performance.

[Learn more](https://example.com)
";

string html = Markdown.ToHtml(markdown);
Console.WriteLine("HTML:\n" + html);
/*
  <p><strong>How often should I service my aircon?</strong></p>
  <p>We recommend servicing your aircon every 3-6 months to maintain optimal performance.</p>
  <p><a href="https://example.com">Learn more</a></p>
*/

var parser = new HtmlParser();
var document = parser.ParseDocument(html);

string plainText = document.Body!.TextContent.Trim();
Console.WriteLine("\nPlain Text:\n" + plainText);
/*
  How often should I service my aircon?
  We recommend servicing your aircon every 3-6 months to maintain optimal performance.
  Learn more
*/

 

总结

本篇简单介绍了 Markdown 语法和两个 Markdown Library。

一个是 JS 的 marked

另一个是 .NET 的 markdig

 

posted @ 2025-01-08 15:15  兴杰  阅读(210)  评论(0)    收藏  举报