Blazor WebAssembly でアドベントカレンダーを作ってみる（できるだけ手軽に）

Blazor の勉強を兼ねて簡単なアプリを作成してみようと思い、この度、開発者アドベントカレンダーアプリを作ってみましたのでどのようなことをやったか記録したいと思います。

## 実現したいこと（要件）

「開発者アドベントカレンダー」それ自体の説明は省きますが、ざっくり以下の実装が必要かなと考えました。シンプルな要件だと思います。

- カレンダーのUI
- カレンダーの各日付部分に、記事のタイトルを、「誰が記事を書くか決まっていない状態」「記事を書く人が決まっている状態」「記事が公開されている状態」の３つのステータスに基づいて表示する（１日ずつ記事が公開される）
- 公開された記事のタイトルをクリックすると記事の詳細が読める

## 作ったアプリのご紹介

早速ですが今回作成したアプリは以下のような感じです。
https://igjp-sample.github.io/AdventCalendar/

![7d6ff7e61bdd79ffd50d6112d2b49950.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/785128/d8239d92-4ce2-5b75-d2f7-f12753f574d9.gif)

カレンダーの各日付部分が、「リンク付きのタイトル」「リンクなしのタイトル」「ブランク」の３パターンになっていて、リンク付きのタイトルをクリックすると記事詳細が表示され、ブランクの日付をクリックすると寄稿方法の説明に遷移します。

アドベントカレンダーでは有志で記事の投稿を募るわけですが、今回この仕組みは Github を利用することにしました。寄稿者にはマークダウンファイルで記事を作成してもらい、プルリクで記事を投稿してもらう流れです。

また、アプリのデプロイは Github Pages にて行いました。@jsakamoto さんの以下の記事を参考にさせていただきました。

https://qiita.com/jsakamoto/items/0be0005d9b30acbfe1e4

## カレンダーUIの作成

特別なことはしていません。カレンダーの見た目になるように四角（div）を並べて、記事（articles）コレクションにデータがあれば記事タイトルなどを表示、なければブランクにして寄稿ページへのリンクを入れます。

```cs
<div class="calendar">
    <!-- Weekdays -->
    <div class="weekday">Sun</div>
    <div class="weekday">Mon</div>
    <div class="weekday">Tue</div>
    <div class="weekday">Wed</div>
    <div class="weekday">Thu</div>
    <div class="weekday">Fri</div>
    <div class="weekday">Sat</div>

    <!-- Days 1 to 24 -->
    @for (int day = 1; day <= 24; day++)
    {
        string dayClass = "day";

        // 土曜日、日曜日の場合のクラス追加
        if (day % 7 == 1) // 1, 8, 15, 22 → 日曜日 (sunday)
        {
            dayClass += " sunday";
        }
        else if (day % 7 == 0) // 7, 14, 21 → 土曜日 (saturday)
        {
            dayClass += " saturday";
        }

        <div class="@dayClass">
            <span>@day</span>
            <div class="calendar-content @(articles.ContainsKey(day) ? "has-post" : "")">
                @if (articles.ContainsKey(day))
                {
                    var article = articles[day];
                    <b>
                        @{
                            var titleContent = (article.Status == "Publish")
                            ? (MarkupString)$"<a href=\"./post/{day}\">{article.Title}</a>"
                            : (MarkupString)article.Title;
                        }
                        @titleContent
                    </b>
                    <i>by @@@article.Author</i>
                }
                else
                {
                    <a class="how-to-contribute" href="https://github.com/igjp-sample/AdventCalendar/blob/main/how_to_contribute_article.md" target="_blank">+</a>
                }
            </div>
        </div>

    }

    <!-- Days 25 to 28 (Greyed out) -->
    <div class="day greyed-out xmas"><span>25</span></div>
    <div class="day greyed-out"><span>26</span></div>
    <div class="day greyed-out"><span>27</span></div>
    <div class="day greyed-out"><span>28</span></div>
</div>

@code {
    private Dictionary<int, Article> articles = new Dictionary<int, Article>();
    public class Article
    {
        public string? Title { get; set; }
        public string? Author { get; set; }
        public string? Status { get; set; }
    }
}

```

## カレンダーに表示する記事情報の取得

記事情報（今回のケースですと Github レポジトリの content ディレクトリに記事ファイルを格納する形としたため、そこにある md ファイル一式）を取得する方法ですが、はじめは以下のように Github API を利用する方法を採用しました。これなら簡単に記事情報にアクセス出来ます。

```cs
protected override async Task OnInitializedAsync()
{
    var client = HttpClient;
    client.DefaultRequestHeaders.UserAgent.ParseAdd("request");

    var url = "https://api.github.com/repos/igjp-sample/AdventCalendar/contents/content";
    var contents = await client.GetFromJsonAsync<List<GitHubContent>>(url);
}
```

しかしながら、（当然と言えば当然ですが）APIのリクエスト数には制限があることに後から気付き（認証を通さないと60リクエスト/1時間）、方式を変更することにしました。具体的には、Github Actions で、記事のコミット時に content ディレクトリのファイル名と最後のコミッターのユーザー名を取得し、一覧データとして JSON 形式で保存することにしました。以下のような感じです。

```yml
      # Read files in content directory and create JSON
      - name: Generate JSON with file names and last committers
        run: |
          TARGET_DIR="content"
          OUTPUT_FILE="public/wwwroot/committers.json"
          echo "[]" > "$OUTPUT_FILE"
          
          # Read all .md files in content directory except those named 'template'
          for file in $(find "$TARGET_DIR" -type f -name '*.md' -not -name 'template*'); do
            filename=$(basename "$file" .${file##*.})
            last_committer=$(git log -1 --pretty=format:'%an' -- "$file")
            echo "Last committer for $file: $last_committer"
            
            # Append JSON object to array
            temp_json=$(jq -n --arg name "$filename" --arg committer "$last_committer" '{name: $name, committer: $committer}')
            jq ". += [\$temp]" --argjson temp "$temp_json" "$OUTPUT_FILE" > temp_output.json && mv temp_output.json "$OUTPUT_FILE"
          done
```

コミッター情報は `git log -1 --pretty=format:'%an' -- "$file"` というコマンドで取得出来ました。

前述の Github API の代わりに、上記で生成した JSON ファイルを読み取ってカレンダーに反映します。

```cs
protected override async Task OnInitializedAsync()
{

    var client = HttpClient;
    client.DefaultRequestHeaders.UserAgent.ParseAdd("request");

    // 生成した JSON ファイル
    var url = "https://raw.githubusercontent.com/igjp-sample/AdventCalendar/refs/heads/gh-pages/committers.json";

    try
    {
        var contents = await client.GetFromJsonAsync<List<GitHubContent>>(url);
        if (contents != null)
        {
            foreach (var content in contents)
            {
                var rowFileUrl = $"https://raw.githubusercontent.com/igjp-sample/AdventCalendar/main/content/{content.Name}.md";
                var fileContent = await client.GetStringAsync(rowFileUrl);
                // Markdown の2行目がタイトル
                var lines = fileContent.Split('\n');
                string title = "タイトル未設定";
                if (lines.Length > 1)
                {
                    var getTitle = lines[1].Trim();
                    if (getTitle.StartsWith("Title: "))
                    {
                        title = getTitle.Substring("Title: ".Length).Trim();
                    }
                }

                // Markdown の3行目が公開ステータス
                string status = "Draft";
                if (lines.Length > 2)
                {
                    var statusLine = lines[2].Trim();
                    if (statusLine.StartsWith("Status (Draft or Publish): "))
                    {
                        status = statusLine.Substring("Status (Draft or Publish): ".Length).Trim();
                    }
                }

                // 記事IDの生成 (x.md の x 部分を取得)
                var articleId = int.Parse(content.Name);
                articles[articleId] = new Article { Title = title, Author = content.Committer, Status = status };

            }
        }
    }
}
```

上記で記事一覧情報を取得出来たことで、個々の記事マークダウンファイルの格納場所も割り出すことが出来ます。
ファイルの一覧やコミット情報などのメタ情報を取得するには Github API が必要となりますが、個々のファイルの生データであれば、制限なくアクセス出来るため、個々の記事マークダウンファイルにアクセスしてタイトルなどの必要な情報を取ってきています。

記事マークダウンファイルは以下のように、ファイルの冒頭にメタ情報（タイトルとステータス）を入力し、６行目から記事本文を書いていくフォーマットにしました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/785128/6c0149e4-c090-a071-b5b1-a86900156155.png)

（※メタ情報の取得方法は、愚直に２行目、３行目の文字列を取得し判別するという形を取りました。）

以上で寄稿されたマークダウンファイルの「タイトル」「ステータス」「寄稿者ユーザ名」が取得出来ましたので、カレンダー一覧に表示することが出来ました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/785128/d4e989e8-27e9-e253-b0d0-8f9f7fb38759.png)

## 記事詳細ページの作成

最後のステップとして、記事詳細ページを作成していきます。今回 `Post.razor` というファイルを新規作成し、冒頭行を以下のようにしました。

```cs:Post.razor
@page "/post/{day:int}"
...
```

これによって各日付に対応した記事情報を表示します。記事マークダウンファイルのファイル名は日付と連動した 1.md というような形式となっているため、URL内の日付パラメーターから表示すべき記事のマークダウンファイルを判別することが出来ます。

また、マークダウンから HTML への変換は [Markdig](https://github.com/xoofx/markdig) という マークダウンパーサーライブラリを使用しました。

以下のような感じでファイルを取得するところまでやれば後は Markdig に任せてしまえば良いのでかなり簡単だったという印象です。

```cs:Post.razor
protected override async Task OnInitializedAsync()
{
    try
    {
        // GitHub raw リンクの組み立て
        var url = $"https://raw.githubusercontent.com/igjp-sample/AdventCalendar/main/content/{Day}.md";

        // Markdown ファイルを取得
        var markdown = await Http.GetStringAsync(url);

        // Markdig を使って HTML に変換
        var pipeline = new Markdig.MarkdownPipelineBuilder().UseAdvancedExtensions().Build();

        content = Markdig.Markdown.ToHtml(markdown, pipeline);
    }
    ...
}
```

### 画像パスを解決する

記事内に画像が使われている場合、その画像ファイルや、そもそもの記事データは作成した Blazor アプリに内包されているわけではなく、あくまで Github の content ディレクトリから取得しているだけですので、ただマークダウンからHTMLに変換しただけでは記事内の画像は表示されません。Markdig 自体が提供しているカスタマイズ機構で、画像ノードの場合はパスを書き換えるということが可能でしたのでそのようにしました。

以下のようなカスタムパーサーを用意して、

```cs:ImagePathRewriterExtension.cs
using Markdig;
using Markdig.Renderers;
using Markdig.Syntax;
using Markdig.Syntax.Inlines;

public class ImagePathRewriterExtension : IMarkdownExtension
{
    public void Setup(MarkdownPipelineBuilder pipeline) { }

    public void Setup(MarkdownPipeline pipeline, IMarkdownRenderer renderer) { }

    public static void RewriteImagePaths(MarkdownDocument document)
    {
        // Markdownのノードを走査して画像のパスを変更する
        foreach (var node in document.Descendants<LinkInline>())
        {
            if (node.IsImage && node.Url != null)
            {
                // 画像のURLを書き換える処理
                node.Url = "https://github.com/igjp-sample/AdventCalendar/blob/main/content/" + node.Url + "?raw=true";
            }
        }
    }
}
```

マークダウンからHTMLへの変換プロセスの中にカスタムパーサーを組み込みます。

```cs:Post.razor
// GitHub raw リンクの組み立て
var url = $"https://raw.githubusercontent.com/igjp-sample/AdventCalendar/main/content/{Day}.md";

// Markdown ファイルを取得
var markdown = await Http.GetStringAsync(url);

// Markdig を使って HTML に変換
var pipeline = new Markdig.MarkdownPipelineBuilder().UseAdvancedExtensions().Build();
var document = Markdown.Parse(markdown, pipeline);

// カスタムパーサーを使って画像パスを書き換える
ImagePathRewriterExtension.RewriteImagePaths(document);

content = Markdig.Markdown.ToHtml(document, pipeline);
```
これで記事内の画像パスも、大元である Github レポジトリの content ディレクトリを見に行くようになったため、画像が表示されるようになりました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/785128/24447148-a2a8-9634-6415-df2a975dd664.png)

### 記事内のソースコードにシンタックスハイライトを適用する

開発者アドベントカレンダーという性質上、その記事にはソースコードの表記が頻繁に使われるだろうということで、ソースコード（HTMLでいうと `<pre>` と `<code>` を組み合わせて表現する部分）にシンタックスハイライトライブラリを適用していきます。

今回は [Prism.js](https://prismjs.com/) というライブラリを利用することにしました。Markdig がソースコード部分のHTMLには Prism.js を利用する上で必要なクラス（例：`<code class="language-python">`）を付与してくれるので、基本的には読み込むだけで利用可能だと思うのですが今回はうまく働きませんでした。

原因としては、おそらく、今回記事本文部分は、動的に記事マークダウンファイルを取得し、HTMLに変換して表示するという行程を経ているため、 Prism.js が読み込まれたタイミングではまだハイライトすべきソースコード部分がなく、適切にハイライトがなされていないのではと想像します。

したがって以下のようなJSを別途呼び出すことで解決しました。（もっと良い方法あるかもですが）

```js
window.applyPrism = function () {
    setTimeout(function () {
        Prism.highlightAll();
    }, 50);
};
```

▽ハイライト適用後

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/785128/a0299a03-086a-5698-876f-56a9ed185c5d.png)

## まとめ

今回比較的簡易にアドベントカレンダーの要件を Blazor アプリで実現することが出来たのではないかなと思います。同じ要領でブログアプリなんかも作成出来るんじゃないかと思いますのでご参考になれば幸いです。

アプリケーションの全体ソースコードは以下よりご確認いただけます。

https://github.com/igjp-sample/AdventCalendar/tree/main