AngularでMarkdownをレンダーするパイプ

はいさい！ちゅらデータぬオースティンやいびーん！

# 概要

今日は、AngularでMarkdownをHTMLにレンダーする方法を紹介していきたいと思います！

## 背景

以前、LitのWeb ComponentsでMarkdownをレンダーする方法を紹介しました。

https://qiita.com/tronicboy/items/6f27bc73ebb0c9a93291

Angularの中でこのWeb Componentを使ってMarkdownをレンダーすることも可能です。しかも、それもWeb Componentをフレームワークの中で活用する例としてとてもいいとも思っています。

ただし、Angularにはデータを変換するために強力なツールを用意してくれています。

そのツールは、 **[Pipes](https://angular.io/guide/pipes)** （パイプ）です。

今回の記事は、カスタムパイプを作って、それでMarkdownをHTMLに変換する方法を紹介します。

## Angularのパイプとは

Angularのパイプは、AngularのHTMLテンプレートで文字列データの表示を変換する便利な機能です。

[既存のパイプ](https://angular.io/guide/pipes#transforming-data-using-pipes)もたくさんあります。

| パイプ名 | 機能 |
| ------ | --- |
| DatePipe | 日付オブジェクト・数字・文字列を指定のフォーマットに変換する |
| UpperCasePipe | 文字列の全てを大文字に変換する |
| LowerCasePipe | 文字列の全てを小文字に変換する |
| CurrencyPipe | 通貨をローカルに合わせてフォーマットする |
| DecimalPipe | 指定に合わせて小数点を文字列に変換する |
| PercentPipe | 指定に合わせて数字をパーセントの文字列に変換する |

例えば、とある部品のHTMLテンプレートの中で`UpperCasePipe`を使ってみましょう。

```typescript:my-component.component.ts
content_copy
import { Component } from '@angular/core';

@Component({
  selector: 'app-hero-birthday',
  templateUrl: './my-component.component.html',
})
export class HeroBirthdayComponent {
  public name = "austin"
}
```

```html:my-component.component.html
<h1>{{ this.name | uppercase }}</h1>
```

すると以下のような表示になります：

<pre><h1>AUSTIN</h1></pre>

こういうパイプがたくさんあり、とても強力なツールです。パイプで表示を変換することは非常にAngularらしいことです。

そこで、Markdownの文字列をHTMLの文字列に変換したいので、**パイプを使おう**という結論に至るのです。

# marked.jsをインストールする

Markdownをレンダーするソフトウェアを白紙から作るとなると大変なので、オープンソースの優秀なソフトを拝借します。

https://github.com/markedjs/marked

以下のCLIコマンドでAngularプロジェクトに追加します。

```
npm install marked
```

# カスタムパイプを追加する

既存のAngularプロジェクトで以下のCLIコマンドを実行して`markdown.pipe.ts`をプロジェクトに追加します。

```
ng generate pipe pipes/markdown
```
※ _`pipes/`は不要ですが、筆者はパイプを特定のフォルダーに入れるのが好きなので、そのように指定しています。_

すると、`src/app/pipes`に`markdown.pipe.spec.ts`と`markdown.pipe.ts`という二つのファイルが生成されますが、今回は`markdown.pipe.ts`だけに集中します。

```typescript:markdown.pipe.ts
import { Pipe, PipeTransform } from '@angular/core';

@Pipe({
  name: 'markdown'
})
export class MarkdownPipe implements PipeTransform {

  transform(value: unknown, ...args: unknown[]): unknown {
    return null;
  }

}
```

この`transform`というクラス関数が魔法をしてくれる部分です。ここにHTMLでレンダーしたいものを返します。

以下のように、LitのMarkdownと同じようなロジックを入力します。

```typescript:markdown.pipe.ts
import { Pipe, PipeTransform } from "@angular/core";
import { marked } from "marked";

@Pipe({
  name: "markdown",
})
export class MarkdownPipe implements PipeTransform {

  transform(rawMarkdown: string) {
    return new Promise<string>((resolve, reject) => {
      marked.parse(rawMarkdown, (error, result) => {
        if (error) return reject(error);
        resolve(result);
      });
    });
  }
}
```

ここで気になる問題が二つ出てきました。

1. `transform`にPromiseを返していいのか
1. HTMLの文字列を返したら、Angularがエスケープしないのか

どちらも解決していきましょう。

# パイプでPromiseを変換する

まず、上記のパイプを試してみましょう。

```typescript:src/app/app.component.ts
import { Component } from "@angular/core";
import { startAppCheck } from "@firebase/app-check-module";

@Component({
  selector: "app-root",
  templateUrl: "./app.component.html",
  styleUrls: ["./app.component.css"],
})
export class AppComponent {
  public raw = `# Hello World
  
  I am a paragraph`;
}
```

```html:src/app/app.component.html
<div>{{ this.raw | markdown }}</div>
```

#### 結果

![スクリーンショット 2022-10-24 13.03.20.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2344956/2dcd94ca-e67a-40ee-5737-feb702bcefd4.png)

まあ、こんなところですよね。

Promiseの結果ではなく、**`Promise.prototype.toString()`の結果を表示している**だけなのです。

ここで助けてくれるのは、既存パイプの[`AsyncPipe`](https://angular.io/api/common/AsyncPipe)なのです。

上記のHTMLテンプレートのパイプのところに一個パイプを追加します。

```html:src/app/app.component.html
<div>{{ this.raw | markdown　| async }}</div>
```

保存すると、画面の表示は変わります！

![スクリーンショット 2022-10-24 13.06.34.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2344956/bc704e51-d390-c999-0b70-62830cac36c5.png)

が、まだ完全に解決できていないのです。

# 生のHTMLをAngularに信用させる方法

Angularはデフォルトで生のHTMLをセキュリティ上の理由から、正しくもエスケープするようになっています。

本来これはありがたい機能なのですが、今回のようにMarkdownをレンダーした結果のHTMLを表示させたい時に邪魔になります。

こういう時は、[`innerHTML`にパイプの結果をバインド](https://angular.io/guide/security#sanitization-example)すればできます。

```html:src/app/app.component.html
<div [innerHTML]="this.raw | markdown | async"></div>
```

#### 結果

![スクリーンショット 2022-10-24 13.13.46.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2344956/b440c278-f075-03bc-9fe1-646b19685b69.png)

望ましい結果になってくれましたね！

## DomSanitizerをパイプで使う

`DomSanitizer`というライブラリを`markdown.pipe.ts`にインジェクトすることもでき、明示的にエスケープを回避するべき値であることを指定することができます。

```typescript
import { Pipe, PipeTransform } from "@angular/core";
import { DomSanitizer } from "@angular/platform-browser";
import { marked } from "marked";

@Pipe({
  name: "markdown1",
})
export class Markdown1Pipe implements PipeTransform {
  constructor(private sanitizer: DomSanitizer) {}

  transform(rawMarkdown: string) {
    return new Promise<string>((resolve, reject) => {
      marked.parse(rawMarkdown, (error, result) => {
        if (error) return reject(error);
        resolve(result);
      });
    }).then((rawHTML) => {
      const bypassedHTML = this.sanitizer.bypassSecurityTrustHtml(rawHTML);
      return bypassedHTML;
    });
  }
}
```

すると、上記と同じ結果が出ますが、___これはかなり危険な行為です。___

上記の`app.component.ts`の`raw`の値を以下のようにします。

```typescript
import { Component } from "@angular/core";
import { startAppCheck } from "@firebase/app-check-module";

@Component({
  selector: "app-root",
  templateUrl: "./app.component.html",
  styleUrls: ["./app.component.css"],
})
export class AppComponent {
  public raw = `# Hello World
  <script>alert("Hacked!");</script>
  I am a paragraph`; // XSS攻撃！アキサミヨー！

  ngAfterViewInit(): void {
    startAppCheck();
  }
}
```

すると、ページでは以下のようなHTMLがレンダーされます。

![スクリーンショット 2022-10-24 13.20.26.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2344956/1045bbe5-ed23-cd10-8c20-fd0a74c99db8.png)

上記のDOMツリーを見てわかるように、`<script>`要素をMarkdownから入れることができ、かなり危険です。

特別な理由がなければ上記のやり方を採用せず、AngularのSanitizerを使いましょう。

もし、AngularのSanitizerが邪魔しているのであれば、ご自身でHTMLをsanitizeするパッケージ等をお使いください。

ちなみに、`this.sanitizer.bypassSecurityTrustHtml(rawHTML);`を使わずに実行すると以下のような結果になります。

```typescript:markdown.pipe.ts
import { Pipe, PipeTransform } from "@angular/core";
import { DomSanitizer } from "@angular/platform-browser";
import { marked } from "marked";

@Pipe({
  name: "markdown1",
})
export class Markdown1Pipe implements PipeTransform {
  constructor(private sanitizer: DomSanitizer) {}

  transform(rawMarkdown: string) {
    return new Promise<string>((resolve, reject) => {
      marked.parse(rawMarkdown, (error, result) => {
        if (error) return reject(error);
        resolve(result);
      });
    })
    // .then((rawHTML) => {
    //   const bypassedHTML = this.sanitizer.bypassSecurityTrustHtml(rawHTML);
    //   return bypassedHTML;
    // })
    ;
  }
}
```

### 結果

![スクリーンショット 2022-10-24 13.25.18.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2344956/2d500677-d4cf-d2f3-ec0d-7e21a3fdff7d.png)

# まとめ

以上、AngularのカスタムパイプでMarkdownをレンダーする方法を紹介してまいりましたが、いかがでしょうか？

最後で紹介したように、AngularのHTMLサニタイズ機能を回避すると危険なので、くれぐれも意識しながら実装をするようにお願いします。