Marked.js を使いこなす

Markdown を HTML に変換するツールとして知られる **[Marked.js](https://marked.js.org/)** は、その軽量性、高速性、拡張性により、多くの JavaScript プロジェクトで使用されています。

本記事では、`Marked.js` の基本から高度な使い方までを解説します。

# 基本的な使い方
## インストール
```
npm install marked
```
もしくは CDN を利用します。
```
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
```
## 初歩的なコード
以下は、シンプルなMarkdownをHTMLに変換する例です。
```html
<div id="content"></div>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script>
  document.getElementById("content").innerHTML = marked.parse(
    "# 見出し\n\n**太字のテキスト**"
  );
</script>
```
# セキュリティの注意点

Marked.js は、出力された HTML をサニタイズしません。そのため、未検証のデータを処理する場合は、XSS 攻撃を防ぐために必ずフィルタリングを行いましょう。

**DOMPurifyを使用した例**
```javascript
DOMPurify.sanitize(
  marked.parse(`<img src="x" onerror="alert('危険!')">`)
);
```
# Marked.js をカスタマイズ
## 前提知識
Marked.js をカスタマイズする上で必要な前提知識を説明します。

- **Marked.js の役割**
  `Markdown` で記述された文章は **Markdown パーサ**（HTML 形式に変換するプログラム）を通じて、ブラウザ上で表示可能な `HTML` 形式に変換します。`Marked.js` もパーサのひとつです。
  
  ```mermaid
  graph LR
  Markdown形式の文章-->|Markdownパーサ| HTML形式の文章
  ```

- **Markdown パーサの仕様**
  Markdown パーサの仕様はさまざま存在します。**[CommonMark](https://spec.commonmark.org/)** と CommonMark を拡張した **[GitHub Flavored Markdown (GFM)](https://github.github.com/gfm/)** が有名です。

- **ブロックレベルとインラインレベル**
  Markdown は、2 つの主要な要素タイプで構成されています、
  
  1. **ブロックレベル**: 見出しや段落、リストなど文章全体の構造を表します。
  2. **インラインレベル**: テキスト内で使う装飾（例: リンク、太字、画像）を表します。
  
  これらの要素は階層構造をとります。ブロック要素の中にブロックレベル・インラインレベルを、インラインレベルの中に他のインラインレベルをもつことができます。（インラインレベルの中にブロックレベルをもつことはありません。）
  ```javascript
  const inlineHtml = marked.parseInline("**太字** _イタリック_");
  console.log(inlineHtml); // '<strong>太字</strong> <em>イタリック</em>'
  ```
  インラインレベルのみを HTML に変換したい場合は `marked.parseInline` を使用します。

- **Marked が Markdown から HTML に変換する流れ**
    ```mermaid
    flowchart LR
        subgraph Lexer
            A((入力文字列)) --> B
            B{Tokenizer}
        end

        subgraph walkTokens
            C((トークンツリー)) --> D{walkTokens}
        end
    
        subgraph Parser
            E{Renderer}
        end
    
        subgraph Output
            F((HTML出力))
        end
    
        B --> C
        D --> E
        E --> F
  ```
  1. `marked.parse` は文字列を受け取ると、
  2. `Lexer` クラスが `tokenizer` クラスを利用してトークンという単位に分割し、その後、トークンをネストされたツリー構造に変換し、
  3. `walkTokens` 関数は、ツリー内のすべてのトークンを探索し、トークン内容の最終調整を行い、
  4. `Parser` クラスが `Renderer` クラスを利用してトークンツリーを HTML に変換します。


## オプションの設定
以下は主要なオプションの一部です。

|オプション|デフォルト|説明|
|:--|:--|:--|
|gfm|true|GitHub Flavored Markdown を使用|
|breaks|false|単一の改行（`\n`）を `<br>` に変換|
| tokenizer  |  new Tokenizer() | Markdown で記述された文章を token に変換|
| renderer   | new Renderer()  | token を HTMLに変換|
| walkTokens | null | 各トークンの最終調整|

```javascript
marked.use({ gfm: true, breaks: true });
console.log(marked.parse("Hello\nWorld"));
```

## Renderer
`Parser` がトークンツリー全体を管理し、`Renderer` が（`Parser` から渡された）各 token を実際に HTML へ変換する役割を持ちます。

既存の Renderer をカスタマイズする際は token がどのようなプロパティを持つか確認する必要があります。例えば、Heading の場合は `type`、`raw`、`depth`、`text`、`tokens` を持ちます。詳しくは [markedjs- Tokens](https://github.com/markedjs/marked/blob/master/src/Tokens.ts) を参照してください。


```javascript
// Override function
const renderer = {
  heading(token) {
    const text = this.parser.parseInline(token.tokens);
    const escapedText = text.toLowerCase().replace(/[^\w]+/g, "-");

    return `
            <h${token.depth}>
              <a name="${escapedText}" class="anchor" href="#${escapedText}">
                <span class="header-link"></span>
              </a>
              ${text}
            </h${token.depth}>`;
  },
  // hr(token) {
  //   ...
  // },
  // ...
};

marked.use({ renderer });
```
- token には **ブロックレベル**と**インラインレベル** があります。heading の場合は、子要素（tokens）はインラインレベルなため、子要素は `parseInline` メソッドを使用しています。
- 複数回オーバーライドすると、最後に割り当てられたバージョンが優先されます。

`Renderer` のカスタマイズには [markedjs- Renderer](https://github.com/markedjs/marked/blob/master/src/Renderer.ts) のコードが参考になります。
 
## Tokenizer

`Lexer` が入力された文字列全体を管理し、`Tokenizer` が実際に文字列を Token へ変換する役割を持ちます。



```javascript
// Override function
const tokenizer = {
  codespan(src) {
    const match = src.match(/^\$+([^\$\n]+?)\$+/);
    if (match) {
      return {
        type: 'codespan',
        raw: match[0],
        text: match[1].trim()
      };
    }

    // return false to use original codespan tokenizer
    return false;
  },
  // blockquote(src) {
  //   ...
  // },
  // ...
};

marked.use({ tokenizer });
```
- 基本的に、引数は `src` だけでいいですが、
    - `reflink` メソッドは `src` と `links`を引数にとります。
    - `emStrong` メソッドは `src` と `maskedSrc`、 `prevChar` を引数にとります。
- 返り値の Token の型は整合性を保つために、[markedjs- Tokens](https://github.com/markedjs/marked/blob/master/src/Tokens.ts) のコードと同じにする必要があります。
- 複数回オーバーライドすると、最後に割り当てられたバージョンが優先されます。

詳しくは [markedjs- Tokenizer](https://github.com/markedjs/marked/blob/master/src/Tokenizer.ts) を参照してください。


## walkTokens
Tokenize と Render の間で、トークン内容の最終調整したい場合は、`walkTokens` を利用します。

```javascript
// Override function
const walkTokens = (token) => {
  if (token.type === 'heading') {
    token.depth += 1;
  }
};

marked.use({ walkTokens });
```
- token の `type` から最終調整する token を判断しています。`type` については以下を参照してください。
  - [markedjs- Tokens](https://github.com/markedjs/marked/blob/master/src/Tokenizer.ts)
## Extensions
`marked.use({extensions: []})` を使用すると、独自の Markdown 記法を追加することができます。

Extension を行うには、以下のプロパティを使用する必要があります。
- `name`:
  - トークンを識別するために使用される文字列。
- `level`
    - この拡張のトークナイザーを実行するタイミングを指定する文字列。
    - `'block'` または `'inline'` のどちらかを指定する必要があります。
- `start(string src)`
    - トークンの開始位置を示すインデックスを返す関数。
- `tokenizer(string src, array tokens)`
    - テキストを読み込み、トークンを返す関数
    - `tokens` には、これまでに `lexer` によって生成されたトークンの配列が含まれています。
- `renderer(object token)`
  - トークンを読み込み、HTML 文字列を返す関数。

オプションで `childTokens` プロパティも使用できます。 

```javascript
const youTubeExtension = {
    name: 'youtube',
    level: 'block',

    start(text) {
        const match = /^::youtube\[([^\]]+)\]$/.exec(text);
        return match ? match.index : undefined;
    },
    tokenizer(text) {
        const match = /^::youtube\[([^\]]+)\]$/.exec(text);
        if (match) {
            const lineCount = match[0].split('\n').length;

            return {
                type: 'youtube',
                text: match[1],
                length: lineCount - 2,
                raw: match[0],
                tokens: []
            };
        }
    },
    renderer(token) {
        return renderYouTube(token.text);
    }
}

function renderYouTube(url) {
    let videoId = null;
    if (url.startsWith('https://www.youtube.com/watch?v=')) {
        videoId = url.split('&')[0].split('/watch?v=')[1];
    } else if (url.startsWith('https://youtu.be/')) {
        videoId = url.slice(17).split('?')[0];
    } else if (url.startsWith('https://www.youtube.com/shorts/') && url.length > 32) {
        videoId = url.slice(31).split('?')[0];
    } else if (url.startsWith('https://m.youtube.com/shorts/') && url.length > 31) {
        videoId = url.slice(29).split('?')[0];
    }
    // videoId は escape 処理をした方がよい
    if (videoId && videoId.length < 15) return `<iframe width="100%" height="100%" src="https://www.youtube.com/embed/${videoId}" frameborder="0" allowfullscreen></iframe>`;
    return '';
}

const extensions = [youTubeExtension]
marked.use({ extensions });

document.getElementById("content").innerHTML = marked.parse(
    "# Heading\n\n::youtube[https://www.youtube.com/watch?v=w6zgUcQnhIs]"
);
```

詳しくは [Using Pro - Marked Documentation](https://marked.js.org/using_pro#extensions) を参照してください。

# おわりに
本記事で紹介したように、Marked.js はカスタマイズ性に優れており、自由に Markdown 記法を拡張できます。

今回紹介しなかった機能に `Hooks` と `Workers` があります。Marked.js のカスタマイズ性をさらに高めるための強力な機能です。ぜひ[公式ドキュメント](https://marked.js.org/)を参照してください。