Astro で Markdoc を使う

Tech
January 8, 2023

astro-markdoc-renderer という、Astro で構築したブログに Markdoc を組み込めるライブラリを見つけたので、Astro + Markdoc の構成でブログを作ってみる。
Astro を使ったブログサイトの構築には、Astro Blog テンプレートを使う。

Astro で Markdoc を使いたい

このブログは、Markdoc を使って Markdoc + Next.js の構成で構築している。
Markdoc には、次の使いやすさを感じている。

  • HTML タグに独自フックを差し込める
  • 独自コンポーネントを利用する際、記事に HTML タグを書く必要がなく、可読性が高くなる

Astro2022 JavaScript Rising Stars の SSG 部門で 2 位にランクインしていて、2023 年現在注目されている SSG ツールである。
Astro では、できる限りコンポーネントを静的生成し、レンダリング時には最低限の JavaScript だけが実行される。
そのためページの読み込みが高速になる。

Astro のメリットを享受しつつ、Markdoc の機能が使いたいと思ったので、astro-markdoc-renderer を試してみる。

STEP1:Astro プロジェクトの作成

Astro プロジェクトを作成する。

$ npm create astro@latest -- --template blog

╭─────╮  Houston:
│ ◠ ◡ ◠  Keeping the internet weird since 2021.
╰─────╯

 astro   v1.9.1 Launch sequence initiated.

✔ Where would you like to create your new project? … my-blog
✔ Would you like to install npm dependencies? (recommended) … yes
✔ Would you like to initialize a new git repository? (optional) … yes
✔ How would you like to setup TypeScript? › Strict

現時点でのディレクトリ構成は次の通り。

.
├── README.md
├── astro.config.mjs
├── node_modules
├── package-lock.json
├── package.json
├── public
├── src
│   ├── components
│   │   ├── BaseHead.astro
│   │   ├── Footer.astro
│   │   ├── Header.astro
│   │   └── HeaderLink.astro
│   ├── config.ts
│   ├── env.d.ts
│   ├── layouts
│   │   └── BlogPost.astro
│   ├── pages
│   │   ├── about.md
│   │   ├── blog
│   │   │   ├── first-post.md
│   │   │   ├── markdown-style-guide.md
│   │   │   ├── second-post.md
│   │   │   ├── third-post.md
│   │   │   └── using-mdx.mdx
│   │   ├── blog.astro
│   │   ├── index.astro
│   │   └── rss.xml.js
│   └── styles
└── tsconfig.json

STEP2:必要なパッケージのインストール

必要なパッケージをインストールする。

$ npm install @markdoc/markdoc astro-markdoc-renderer gray-matter

STEP3:Markdoc の設定ファイルの作成

次の内容で、src/lib/markdoc/markdoc.config.ts を作成する。
Markdoc の独自タグや HTML タグのフックに関する設定は、後の STEP で追記する。

src/lib/markdoc/markdoc.config.ts
import Markdoc from "@markdoc/markdoc";
import type { Config } from "@markdoc/markdoc";

const { nodes, Tag } = Markdoc;

export const config: Config = {
  tags: {},
  nodes: {},
};

STEP4:astro-markdoc-renderer の利用

コンテンツを表示する際に、astro-markdoc-renderer が適用されるようにする。 Blog テンプレートにおいては、コンテンツ表示に BlobPost.astro が使われている。
そのため、BlobPost.astroMarkdocRenderer を利用する。

  1. 次の内容で、src/components/Renderer.astro を作成する。

    src/components/Renderer.astro
    ---
    import { MarkdocRenderer } from "astro-markdoc-renderer";
    import type { Content } from "astro-markdoc-renderer";
    
    type Props = {
      content: Content;
    };
    
    const { content } = Astro.props;
    
    const components = {};
    ---
    
    <MarkdocRenderer content={content} components={components} />
    
  2. 次の内容で src/lib/post.ts を作成する。
    Astro では、Markdown のコンテンツは、<slot /> に展開される。
    MarkdocRenderercontent には、Markdoc を通したコンテンツを渡す必要があるため、readPost() でその処理を実施する。

    src/lib/post.ts
    import fs from "fs/promises";
    import matter from "gray-matter";
    import Markdoc from "@markdoc/markdoc";
    import { config } from "./markdoc/markdoc.config";
    
    export async function readPost({ filepath }: { filepath: string }) {
      const rawString = await fs.readFile(filepath, "utf8");
      const { content } = matter(rawString);
      const ast = Markdoc.parse(content);
      const errors = Markdoc.validate(ast, config);
      if (errors.length) {
        console.error(errors);
      }
      return {
        content: Markdoc.transform(ast, config),
      };
    }
    
  3. src/layouts/BlobPost.astro を次のように修正する。
    先ほど作成した Renderer に、Markdoc を通したコンテンツを渡す。

    src/layouts/BlobPost.astro
    ---
    import BaseHead from '../components/BaseHead.astro';
    import Header from '../components/Header.astro';
    import Footer from '../components/Footer.astro';
    + import Renderer from "../components/Renderer.astro";
    + import { readPost } from '../lib/post';
    
    export interface Props {
      content: {
        title: string;
        description: string;
        pubDate?: string;
        updatedDate?: string;
        heroImage?: string;
    +    file: string;
      };
    }
    
    const {
    +  content: { title, description, pubDate, updatedDate, heroImage, file },
    -  content: { title, description, pubDate, updatedDate, heroImage },
    } = Astro.props;
    + const { content } = await readPost({
    +   filepath: file
    + });
    ---
    // 省略
    
        <main>
          <article>
            {heroImage && <img width={720} height={360} src={heroImage} alt="" />}
            <h1 class="title">{title}</h1>
            {pubDate && <time>{pubDate}</time>}
            {
              updatedDate && (
                <div>
                  Last updated on <time>{updatedDate}</time>
                </div>
              )
            }
            <hr />
    -        <slot />
    +        <Renderer content={content} slot="content" />
          </article>
        </main>
    
    // 省略
    

STEP5:HTML タグへのフックの追加

Markdoc では、Markdown シンタックスから HTML タグを生成するときに、独自の処理を差し込むことができる。
参考:Nodes
今回は、href の値が http 始まりなら別タブで開くような処理を差し込む。

ts:src/lib/markdoc/markdoc.config.tsnodes に、HTML タグ生成時に差し込む処理を追加する。

src/lib/markdoc/markdoc.config.ts
// 省略

export const config: Config = {
  tags: {},
-  nodes: {},
+  nodes: {
+    link: {
+      children: ["inline"],
+      attributes: {
+        href: { type: String },
+        target: { type: String },
+        rel: { type: String },
+      },
+      ...nodes.link,
+      transform(node, config) {
+        const attributes = node.transformAttributes(config);
+        const target = attributes.href.startsWith("http") ? "_blank" : null;
+        const rel = target ? "noopener noreferrer" : null;
+        return new Tag(
+          this.render,
+          { ...attributes, target, rel },
+          node.transformChildren(config)
+        );
+      },
+    },
+  },
};

STEP6:独自タグの追加

Markdoc では、独自タグを実装し、{% TAG %} のような記法を Markdown に記述することで、シンタックスでは記述できない複雑な DOM を表現できる。
参考:Tags
今回は、背景色のついた注意書きブロックタグを作成する。

  1. 次の内容で src/components/Callout.astro を追加する。

    src/components/Callout.astro
    ---
    type Props = {
      title: string;
    };
    
    const { title } = Astro.props;
    ---
    
    <style>
      .callout {
        border-radius: 0.375rem;
        background-color: #ccc;
        padding: 0.5rem 1rem;
      }
      .callout--title {
        font-weight: 800;
      }
      .callout--content {
      }
    </style>
    <div class="callout">
      <div class="callout--title">
        {title}
      </div>
      <div class="callout--content"><slot /></div>
    </div>
    
  2. ts:src/lib/markdoc/markdoc.config.ts を次のように書き換える。

    src/lib/markdoc/markdoc.config.ts
    // 省略
    
    export const config: Config = {
    -  tags: {},
    +  tags: {
    +    callout: {
    +      render: "Callout",
    +      attributes: {
    +        title: { type: String },
    +      },
    +      children: nodes.document.children,
    +    },
    +  },
      nodes: {
      // 省略...
    };
    

STEP7:テストページの追加

次の内容で pages/blog/markdoc-test.md を追加する。

---
layout: "../../layouts/BlogPost.astro"
title: "Markdoc のテスト"
description: "Markdoc のテストです"
pubDate: "July 8 2023"
---

## リンク

[Google](https://google.co.jp) は外部リンクなので、別タブで開きます。  
[Home](/) は内部リンクなので、同じタブで開きます。

## Callout

{% callout title="タイトル" %}

注意書きです。**太字** もできます。

{% /callout %}

最終的なディレクトリ構成は次の通り。

.
├── README.md
├── astro.config.mjs
├── node_modules
├── package-lock.json
├── package.json
├── public
├── src
│   ├── components
│   │   ├── BaseHead.astro
│   │   ├── Callout.astro <== 追加
│   │   ├── Footer.astro
│   │   ├── Header.astro
│   │   ├── HeaderLink.astro
│   │   └── Renderer.astro <== 追加
│   ├── config.ts
│   ├── env.d.ts
│   ├── layouts
│   │   └── BlogPost.astro <== 修正
│   ├── lib
│   │   ├── markdoc
│   │   │   └── markdoc.config.ts <== 追加
│   │   └── post.ts <== 追加
│   ├── pages
│   │   ├── about.md
│   │   ├── blog
│   │   │   ├── first-post.md
│   │   │   ├── markdoc-test.md <== 追加
│   │   │   ├── markdown-style-guide.md
│   │   │   ├── second-post.md
│   │   │   ├── third-post.md
│   │   │   └── using-mdx.mdx
│   │   ├── blog.astro
│   │   ├── index.astro
│   │   └── rss.xml.js
│   └── styles
└── tsconfig.json

STEP8:動作確認

ローカルサーバーを起動して、テストページを確認する。

$ npm run dev

> @example/blog@0.0.1 dev
> astro dev

  🚀  astro  v1.9.1 started in 600ms

  ┃ Local    http://localhost:3000/
  ┃ Network  use --host to expose

{% callout title="タイトル" %} を記述した部分が、Callout.astro で実装した内容に置き換わっている。
スクリーンショット:テストページが表示されている

リンクについては、外部リンクは別タブで開くように target が設定されていることも確認できた。

<h2>リンク</h2>
<p>
  <a href="https://google.co.jp" target="_blank" rel="noopener noreferrer">Google</a> は外部リンクなので、別タブで開きます。<br>
  <a href="/">Home</a> は内部リンクなので、同じタブで開きます。
</p>

所感

astro-markdoc-renderer を使うと、Astro + Markdoc の構成が実現できた。
現在の Next.js を使った構成から移行する場合も、コンテンツに関してはそのまま使えそう。