Corredor

ウェブ、プログラミングの勉強メモ。

Markdown ファイルを動的にパースして表示・別ファイルへの遷移もできる Angular アプリ「ngx-markdown-wiki」を作った

Markdown ファイルを動的にパースして表示し、別の Markdown ファイルへの遷移もできる Angular アプリ「ngx-markdown-wiki」を作った。Markdown ファイル同士の遷移ができるので、Markdown 製の Wiki が作れるかなー、という思いで、「Markdown Wiki」とした。

デモページは以下。↓

また、このアプリを利用して実際に個人的 Tips をまとめた Wiki サイトを作ってみた。↓

以下、このアプリを作るまでに考慮したことなどを書き残しておく。

HttpClient + marked.js

Markdown ファイルは ./src/assets/docs/ 配下に格納しておく。angular.json (Angular v6 から・v5 以前は .angular-cli.json) にて、assets ディレクトリを出力するようにしておくと、ng build した時に ./dist/assets/docs/ が出力されるようになる。

で、ココに格納したファイルを HttpClient で GET して、marked.js に食わせてパースしたものを innerHTML で表示する、というのが基本的な構造。

別の Markdown ファイルへのリンクをどのように扱うか

パースした Markdown ファイルを innerHTML で表示させるところは、DomSanitizer#bypassSecurityTrustHtml() を使えば簡単にできる。問題は、こうして出力した Markdown ファイルから、別の Markdown ファイルに遷移するリンクを、どう扱ってやるか、というところだった。

<!-- 以下のようなリンクを持つ Markdown ファイルがあったとして… -->
- [次のドキュメントに移る](./my-text-2.md)

このままパースさせると、当然以下のような HTML になる。

<a href="./my-text-2.md">次のドキュメントに移る</a>

コレでは、Angular アプリのルートから見て存在しないパスへのリンクになってしまうし、.md ファイルへ直接遷移しても意味がない。

そこで最初に考えたのは、Markdown ファイルへのリンクを (click)="onClick()" 的なイベントバインディングに書き換える方法だった。Angular のバインディングを機能させるには、innerHTML ではなく、動的に Angular コンポーネントを生成してコンパイルし、ブチ込んでやる必要が出てくる。コレに関しては以前、Compiler クラスというモノを紹介した。

neos21.hatenablog.com

コレで一応、クリック時に TempComponent (動的に生成したコンポーネント) 内の onClick() 関数は呼べたので、サービスクラスを経由して処理したら行けるかな?と思ったのだが、なかなか煩雑になってきて、色々なパターンに対応できなくなってきた。

(click) イベントではなく [routerLink] を使用して、ルーティングモジュール側でリダイレクトなり何なりする、ということも考えたが、コレも煩雑になって大変だった。

色々考えた挙げ句、Markdown ファイルへのリンクには CSS クラスを振るよう、marked.js でのパース時に処理を追加した。そして、innerHTML を書き換える要素の親要素で (click) イベントを定義し、Markdown ファイルの描画領域全体のクリックイベントを常に監視することにした。そして、指定の CSS クラスの要素がクリックされた時に、画面遷移処理を実行する、という流れにした。

<!-- クリックイベントを監視する -->
<div (click)="onClick($event)">
  <!-- ↓ Markdown パース後の HTML を挿入する要素 -->
  <div [innerHTML]="contents"></div>
</div>
public onClick(event: Event): void {
  // クリックされた要素を取得する
  const target = event.target as any;
  
  // 他の「.md」ファイルへのリンクの場合 (CSS クラスで判断する)
  if(target.classList.contains('anchor-md')) {
    // リンク遷移の動作をキャンセルする
    event.preventDefault();
    
    // リンク先の Markdown ファイルをパースして表示する処理を呼ぶ…
  }
}

こんな感じ。

Markdown ファイルへの相対リンクパスをどのように解決するか

他の Markdown ファイルへリンクしている a 要素を加工して区別し、クリックイベントを検知することはできた。しかし、まだ解決しないといけない問題がある。

Markdown ファイルには相対パスでリンクを記述しているが、そのパスをフルパスに変換しないと、Angular アプリとしては「読み込むべきファイル」の所在が分からないのだ。

つまり、src/assets/docs/my-texts/my-item/my-document.md というファイルが、[トップに戻る](../../index.md) というリンクを持っていた場合、src/assets/docs/index.md を読み込むようにフルパスを導いてやらないといけない、というワケだ。

色々と試行錯誤したが、以下のように作ることにした。

  • 初期表示時に、「現在開いているファイルまでの階層」情報を控えておく。src/assets/docs/index.md を開いた場合は、src/assets/docs/ 以下をルートと見なして、その階層情報は空文字 '' (イメージ的には / と同義) となる。
  • 開いたファイルをパースし、Markdown ファイルへのリンクに CSS クラスを付与するタイミングで、「現在開いているファイルまでの階層」情報と「そのリンクの相対パス」を合わせて、フルパスを生成し埋め込む。
    • src/assets/docs/my-texts/my-item ディレクトリを開いていて、../../other-texts/other-document.md という相対パスなのであれば、まず2階層上に上がり (/ までの区切りを削る)、other-texts/other-document.md を付与して src/assets/docs/other-texts/other-document.md というフルパスを作る、という感じ。
    • この「ベースパスと相対パスから絶対パスを求める」というライブラリがありそうだったのだが、イマイチ思った感じのモノが見つからず、ゴリゴリ自作した。バグがありそうで怖い。
    • hoge.md#my-hash のように、ハッシュ付きのリンクも上手く解釈するようにした。
    • そして、リンク文字列自体は、右クリックから「リンク先の URL をコピー」された時に正常に遷移できる URL になるよう、ハッシュから始まる文字列を当てた。
  • パースして画面表示しているリンクは <a href="../other-items/extra-document.md"> といった相対パスではなく <a href="#/other-texts/other-items/extra-document.md"> といったフルパスになっている。
  • コレを押下された時、先頭のハッシュ #/ 部分を除去し、(src/assets/docs/ 配下の) other-texts/other-items/extra-document.md というファイルを読み込む、という風に情報を渡してやることで、うまく遷移できるようにした。

なんとなく、クエリパラメータとかで表示対象のファイルを表現したくなくて、人力で HashLocationStrategy っぽいモノを実現しようとしてつらいことになった、という感じ。w

同ページ内のハッシュリンクも制御

同ページ内のハッシュリンクも、それ用の CSS クラスを振って区別し、element#scrollIntoView() という JavaScript の API を利用してスムーズスクロールするようにした。ついでに window.history.pushState() を利用して履歴に残す URL 文字列も、コピペして再アクセス可能な URL にした。

アプリ起動時に指定のファイルを開かせるには AppComponent での制御が必要だった

当初、この ngx-markdown-wiki は、一つの NgModule としてライブラリ的に提供できたら良いんじゃないかなーと思っていたのだが、

  • https://neos21.github.io/ngx-markdown-wiki/#/my-texts/my-item/my-document.md

といった URL で直接遷移された時に、アプリ起動時にこのファイルを開くための制御が上手くしきれず、AppComponent に全部実装する必要があった。どうもルートコンポーネントよりも後に発火する、子コンポーネントの ngOnInit() では Router Events の判定が遅れるらしく、初回アクセスの URL が上手く拾えなかった。

仕方なく AppComponent に直接書き、ライブラリとして配布可能な構造にするのは諦めた。もう少し汎用性が欲しいんだよなぁ…。

一応ハッシュリンクを生成しているので、GitHub Pages で公開する際も 404.html から location.href を控えてのリダイレクトはしなくても大丈夫そう。

色々バギーなのでご利用は止めた方がいいです

相対パスの解釈がギリギリダメっぽくて、なかなか世間的にお使いいただけなさそう。個人的な範囲で、挙動を分かっていて使う分にはギリギリかな、という感じ。

「Markdown ファイルを動的にパースできたら、アプリ本体の更新とかビルド環境とか要らないんじゃね?」なんて思ってこんなアプリを作ってみましたが、ちゃんと Markdown ファイルをパースしておいて、静的ファイルとして公開できるようなビルドツールを大人しく使った方が良さそう。

Angular のお勉強ということで、このプロジェクトはココまで、かなぁ。もっと違うアプローチで実現できそうなら、別物として作ろうと思う。個人的な Wiki : Neo's Wiki は、当面は拙作の ngx-markdown-wiki を使いつつ、細々と続けていこうかなーと思う。

Ionicで作る モバイルアプリ制作入門 Web/iPhone/Android対応

Ionicで作る モバイルアプリ制作入門 Web/iPhone/Android対応

React入門 React・Reduxの導入からサーバサイドレンダリングによるUXの向上まで

React入門 React・Reduxの導入からサーバサイドレンダリングによるUXの向上まで