LoginSignup
2
2

More than 1 year has passed since last update.

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

Last updated at Posted at 2022-10-24

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

概要

今日は、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を使ってみましょう。

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"
}
my-component.component.html
<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/pipesmarkdown.pipe.spec.tsmarkdown.pipe.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と同じようなロジックを入力します。

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を返していいのか
  2. HTMLの文字列を返したら、Angularがエスケープしないのか

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

パイプでPromiseを変換する

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

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`;
}
src/app/app.component.html
<div>{{ this.raw | markdown }}</div>

結果

スクリーンショット 2022-10-24 13.03.20.png

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

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

ここで助けてくれるのは、既存パイプのAsyncPipeなのです。

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

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

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

スクリーンショット 2022-10-24 13.06.34.png

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

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

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

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

こういう時は、innerHTMLにパイプの結果をバインドすればできます。

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

結果

スクリーンショット 2022-10-24 13.13.46.png

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

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.tsrawの値を以下のようにします。

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

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

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

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

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

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

まとめ

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

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

2
2
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2