Shion のもくログ(旧: Shion の技術メモ)

使った技術のメモや、うまくいかなかった事とかを綴ります

PR

Angular で、markdown-it を使ってみた

概要

Angular で、Markdown 記法を表示できるようにした際に、いくつか工夫したことがあるので、そのメモを残していきます。

目次

  • 概要
  • やりたいこと
  • 実装したこと
    • 使い方
    • 環境情報
    • 作業内容
    • 工夫点の解説
  • 余談
  • 参考文献

やりたいこと

  • Angular 製のWeb アプリで、Markdown 記法で記述されたものを、表示できるようにしたい
    • Markdown 記法のパーサーとして、今回はmarkdown-it を採用する
  • 表示しているMarkdown 文書で、絵文字を表示できるようにしたい
  • 表示しているMarkdown 文書内にある内部リンクのクリックで、ページ全体が再読み込みされないようにしたい
    • クリックした内部リンクをハンドリングし、Angular のルーティングに流すようにする
  • 表示しているMarkdown 文章内に、プログラミング言語のコードがあれば、キーワードなどをハイライト表示したい
    • シンタックスハイライトライブラリとして、今回はhighlight.js を採用する

実装したこと

使い方

今回は、Angular の属性ディレクティブを使って、実装しました。 Markdown 文書を、自作した属性ディレクティブに渡すことで、表示されるようになります。

<article [markdown]="markdown text"></article>

環境情報

※関係ありそうな箇所を抜き出しました。

"dependencies": {
    "@angular/common": "^15.2.0",
    "@angular/core": "^15.2.0",
    "@angular/router": "^15.2.0",
    "highlight.js": "^11.7.0",
    "markdown-it": "^13.0.1",
    "markdown-it-emoji": "^2.0.2",
},
"devDependencies": {
    "@types/markdown-it-emoji": "^2.0.2",
    "typescript": "~4.9.4"
}

作業内容

下記のあたりで、作業を行いました。

  • (新規追加) 属性ディレクティブの実装ファイル
    • Angular 15 以降で使えるスタンドアロンベースのため、今回はNgModule を変更していないです
  • (変更) angular.json
    • highlight.js にあるCSS テーマを利用しようとしたら、なぜかパス解決出来なかったので、変更しました

(新規追加) 属性ディレクティブの実装ファイルの実装例

注意点: CommonJS をごっちゃに記述していると思われるので、適宜読み替えてください🙇

import { Directive, ElementRef, Input } from '@angular/core';
import { Router } from '@angular/router';
import hljs from 'highlight.js/lib/core';
import typescript from 'highlight.js/lib/languages/typescript';
import * as MarkdownIt from 'markdown-it';
import * as MarkdownItEmoji from 'markdown-it-emoji';

@Directive({
  standalone: true,
  selector: '[markdown]',
})
export class MarkdownDirective {

  private static markdownIt: MarkdownIt | undefined = undefined;


  @Input()
  public set markdown(text: string | undefined) {
    const dom = this.elementRef.nativeElement;
    if (dom instanceof HTMLElement) {
      let html = undefined;
      if (text) {
        html = MarkdownDirective.markdownIt?.render(text);
      }
      dom.innerHTML = html ?? '';

      // クリックイベントを拾って、条件に合致した場合に、Angular Router に流すロジックの実装箇所
      dom.onclick = (e) => {
        const distPath = this.parsePath(this.parseUrl(e));
        if (distPath) {
          this.router.navigateByUrl(distPath);
          e.preventDefault();
        }
      };
    }
  }


  constructor(
    private readonly elementRef: ElementRef,
    private readonly router: Router,
  ) {
    if (!MarkdownDirective.markdownIt) {
      hljs.registerLanguage('typescript', typescript); // typescript, ts

      const mdit: MarkdownIt = MarkdownIt({
        highlight: (str, lang) => {
          let rendered: string | undefined = undefined;
          if (lang && hljs.getLanguage(lang)) {
            try {
              rendered = hljs.highlight(str, {
                language: lang,
                ignoreIllegals: true,
              }).value;
            } catch (_) { /* empty */ }
          }
          if (!rendered) {
            rendered = mdit.utils.escapeHtml(str);
          }
          return `<pre><code class="hljs">${rendered}</code></pre>`;
        },
        html: true,
        linkify: true,
      });
      mdit.use(MarkdownItEmoji);

      MarkdownDirective.markdownIt = mdit;
    }
  }


  /** このアプリ用のパスへ変換 */
  private parsePath(url?: string) {
    const baseUrl = `${location.protocol}//${location.host}`;
    return url?.startsWith(baseUrl)
      ? url.replace(baseUrl, '')
      : undefined;
  }

  /** 遷移先URL の解析 */
  private parseUrl(e: Event) {
    const elements = e.composedPath() as Array<HTMLElement>;
    const anchor = elements.find(p => p?.tagName?.toLowerCase() === 'a');
    return anchor instanceof HTMLAnchorElement ? anchor.href : undefined;
  }
}

(変更) angular.json の実装例

例として、"solarized-dark.css" を指定した際に、変更した箇所を記載します。

{
    (省略)
    "projects": {
        "(省略)": {
            (省略)
            "architect": {
                "build": {
                    (省略)
                    "options": {
                        (省略)
                        "styles": [
                            "(node_modules のパス)/highlight.js/styles/base16/solarized-dark.css",
                        ]
                        (省略)
                    }
                    (省略)
                }
            }
            (省略)
        }
    },
}

工夫点の解説

markdown-it の初期化タイミングに制限を追加

何度もmarkdown 文書を表示した時に、その都度、初期化処理を実行するのが良いのかという疑問がありました。 ちゃんとやるのであれば、markdown-it の初期化コスト(CPU 使用率や時間など) を計測してから判断すると良いと考えられます。 しかし、今回は時間が無かったため、暫定対応として、初期化したものがあれば、それを使い回すようにしました。

Markdown 文書中のページ遷移イベントのハンドリング

Markdown 文書中のハイパーリンクをクリックすると、ページ再読み込みになってしまいます。 もしクリックしたのが、内部リンクだった場合、下記のあたりの問題が発生します。

  • 再読み込みなので、HTML などの取得するための、余計な通信が実行される
  • 解析ツールを使っている際、ページ離脱したと見做される (※同じページに留まっているという判定にならない)

そこで下記の実装を使って、ハイパーリンクのクリックイベントをハンドリングするようにしました。 大まかなロジックは下記です。

  1. イベント発生した<a> を特定し、遷移先を抜き出す
  2. 手順1 の遷移先が、内部リンクかを判定する
  3. (手順2 で内部リンクだった場合) URL のプロトコルやホスト名を取り除き、パス形式に変換する
  4. (手順3 でパス形式のものが取得できた場合) Angular のRouter に流し、発生したイベントをキャンセルする
Angular Web アプリ他Web ページRouterView 層他Web ページRouterView 層alt[外部リンクである][内部リンクである]ユーザーのブラウザMarkdown 文書内のリンクをタップしたリンクの判定ページ離脱外部ページの表示ルーターに流すWeb アプリ内の画面遷移内部ページの表示ユーザーのブラウザ

余談

  • Angular x Markdown 記法のライブラリは、いくつかnpm にありますが、メンテ状況や構成を読みきれなかったので、自作することにしました
  • Angular のカスタムパイプでも、同等のことが出来そうなので、そちらでも良かったかもしれません
  • Markdown 記法のパーサーは、いくつかありますが、今回はVSCode の構成に合わせました

参考文献

PR