Angular で、markdown-it を使ってみた
概要
Angular で、Markdown 記法を表示できるようにした際に、いくつか工夫したことがあるので、そのメモを残していきます。
目次
- 概要
- やりたいこと
- 実装したこと
- 使い方
- 環境情報
- 作業内容
- 工夫点の解説
- 余談
- 参考文献
やりたいこと
- Angular 製のWeb アプリで、Markdown 記法で記述されたものを、表示できるようにしたい
- Markdown 記法のパーサーとして、今回はmarkdown-it を採用する
- 表示しているMarkdown 文書で、絵文字を表示できるようにしたい
- 絵文字を表示するために、今回はmarkdown-it-emoji を採用する
- 表示している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 15 以降で使えるスタンドアロンベースのため、今回は
- (変更) 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 などの取得するための、余計な通信が実行される
- 解析ツールを使っている際、ページ離脱したと見做される (※同じページに留まっているという判定にならない)
そこで下記の実装を使って、ハイパーリンクのクリックイベントをハンドリングするようにしました。 大まかなロジックは下記です。
- イベント発生した
<a>
を特定し、遷移先を抜き出す - 手順1 の遷移先が、内部リンクかを判定する
- (手順2 で内部リンクだった場合) URL のプロトコルやホスト名を取り除き、パス形式に変換する
- (手順3 でパス形式のものが取得できた場合) Angular のRouter に流し、発生したイベントをキャンセルする
余談
- Angular x Markdown 記法のライブラリは、いくつかnpm にありますが、メンテ状況や構成を読みきれなかったので、自作することにしました
- Angular のカスタムパイプでも、同等のことが出来そうなので、そちらでも良かったかもしれません
- AngularでMarkdownをレンダーするパイプ - Qiita (2023/05/01)
- Markdown 記法のパーサーは、いくつかありますが、今回はVSCode の構成に合わせました
- markdown-it, highlight.js を利用しているみたいです (※該当箇所)