はいさい!ちゅらデータぬオースティンやいびーん!
概要
今日は、AngularでMarkdownをHTMLにレンダーする方法を紹介していきたいと思います!
背景
以前、LitのWeb ComponentsでMarkdownをレンダーする方法を紹介しました。
Angularの中でこのWeb Componentを使ってMarkdownをレンダーすることも可能です。しかも、それもWeb Componentをフレームワークの中で活用する例としてとてもいいとも思っています。
ただし、Angularにはデータを変換するために強力なツールを用意してくれています。
そのツールは、 Pipes (パイプ)です。
今回の記事は、カスタムパイプを作って、それでMarkdownをHTMLに変換する方法を紹介します。
Angularのパイプとは
Angularのパイプは、AngularのHTMLテンプレートで文字列データの表示を変換する便利な機能です。
既存のパイプもたくさんあります。
パイプ名 | 機能 |
---|---|
DatePipe | 日付オブジェクト・数字・文字列を指定のフォーマットに変換する |
UpperCasePipe | 文字列の全てを大文字に変換する |
LowerCasePipe | 文字列の全てを小文字に変換する |
CurrencyPipe | 通貨をローカルに合わせてフォーマットする |
DecimalPipe | 指定に合わせて小数点を文字列に変換する |
PercentPipe | 指定に合わせて数字をパーセントの文字列に変換する |
例えば、とある部品のHTMLテンプレートの中でUpperCasePipe
を使ってみましょう。
content_copy
import { Component } from '@angular/core';
@Component({
selector: 'app-hero-birthday',
templateUrl: './my-component.component.html',
})
export class HeroBirthdayComponent {
public name = "austin"
}
<h1>{{ this.name | uppercase }}</h1>
すると以下のような表示になります:
AUSTIN
こういうパイプがたくさんあり、とても強力なツールです。パイプで表示を変換することは非常にAngularらしいことです。
そこで、Markdownの文字列をHTMLの文字列に変換したいので、パイプを使おうという結論に至るのです。
marked.jsをインストールする
Markdownをレンダーするソフトウェアを白紙から作るとなると大変なので、オープンソースの優秀なソフトを拝借します。
以下の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
だけに集中します。
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と同じようなロジックを入力します。
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);
});
});
}
}
ここで気になる問題が二つ出てきました。
-
transform
にPromiseを返していいのか - HTMLの文字列を返したら、Angularがエスケープしないのか
どちらも解決していきましょう。
パイプでPromiseを変換する
まず、上記のパイプを試してみましょう。
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`;
}
<div>{{ this.raw | markdown }}</div>
結果
まあ、こんなところですよね。
Promiseの結果ではなく、Promise.prototype.toString()
の結果を表示しているだけなのです。
ここで助けてくれるのは、既存パイプのAsyncPipe
なのです。
上記のHTMLテンプレートのパイプのところに一個パイプを追加します。
<div>{{ this.raw | markdown | async }}</div>
保存すると、画面の表示は変わります!
が、まだ完全に解決できていないのです。
生のHTMLをAngularに信用させる方法
Angularはデフォルトで生のHTMLをセキュリティ上の理由から、正しくもエスケープするようになっています。
本来これはありがたい機能なのですが、今回のようにMarkdownをレンダーした結果のHTMLを表示させたい時に邪魔になります。
こういう時は、innerHTML
にパイプの結果をバインドすればできます。
<div [innerHTML]="this.raw | markdown | async"></div>
結果
望ましい結果になってくれましたね!
DomSanitizerをパイプで使う
DomSanitizer
というライブラリを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;
});
}
}
すると、上記と同じ結果が出ますが、これはかなり危険な行為です。
上記のapp.component.ts
のraw
の値を以下のようにします。
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がレンダーされます。
上記のDOMツリーを見てわかるように、<script>
要素をMarkdownから入れることができ、かなり危険です。
特別な理由がなければ上記のやり方を採用せず、AngularのSanitizerを使いましょう。
もし、AngularのSanitizerが邪魔しているのであれば、ご自身でHTMLをsanitizeするパッケージ等をお使いください。
ちなみに、this.sanitizer.bypassSecurityTrustHtml(rawHTML);
を使わずに実行すると以下のような結果になります。
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;
// })
;
}
}
結果
まとめ
以上、AngularのカスタムパイプでMarkdownをレンダーする方法を紹介してまいりましたが、いかがでしょうか?
最後で紹介したように、AngularのHTMLサニタイズ機能を回避すると危険なので、くれぐれも意識しながら実装をするようにお願いします。