AstroとNotionでエンジニアブログを作る

2024-05-31

エンジニアブログを作りたい

Eukarya CTOの井上です。この度Eukaryaでは、エンジニアブログを始めることになりました!

エンジニアブログを立ち上げるにあたって最初に必ず考えることが、どんなツールで記事を執筆し、どんな手段でブログを公開するかということだと思います。

Eukaryaでも、クラウドサービス含め様々なツールを検討した結果、Notionを使って記事を書き、その内容をHTML化して静的ページとして配信することにしました。

Notionを使うことにしたのは、社内で既に広く使用されていること、エンジニアにとって執筆体験がとても良いこと、外部サービスに情報が散らばらないこと、といった理由が挙げられます。

そうすると、Notionを静的なブログとして公開するにはどうしたらよいかと調べたところ、 astro-notion-blog というツールを見つけました。名前の通り、Astroという静的ページジェネレーターを使用して、Notionからブログを作れるツールです。

実際に私もastro-notion-blogも使ってみたのですが、残念ながら以下の理由により導入するには至りませんでした。

  • デザイン: Mediumのように、もう少しシンプルでオシャレなデザインでブログを作りたい。
  • ESLintやTypeScriptのエラー: カスタマイズしようとしたが、ESLintやTypeScriptのエラーが多く、開発体験が良くない。
  • レンダリングの実装が複雑: NotionからHTMLへのレンダリングの多くが独自実装であり、機能を追加するために実装しなければいけないことが多い。

そこで、astro-notion-blogを参考にしつつ、自分で新たなツールを実装することにしました。そうしてできあがったのが、Astrotion(https://github.com/rot1024/astrotion)です。astro-notion-blogと同様、このリポジトリをフォークして使用します。

Astrotionの特徴

astro-notion-blogとの違いに絞って取り上げると、Astrotionには以下の特徴があります。

  • CreekというAstroのテーマをベースにしている(Tailwindを使用)
  • 100%TypeScriptで実装
  • カスタマイズ可能な設定ファイルやastroファイルを分離しており、カスタマイズしやすい
  • Notionをマークダウンに変換してからレンダリングしているため、レンダリングをカスタマイズしやすい
  • NotionのキャッシュがCloudflare Pagesのビルドでも効く
  • OGPの自動生成機能

Astrotionは出来立てのツールで本番運用実績はまだありません。このブログで本番運用してみて、気付いたことがあれば随時修正していこうと思いますが、現時点では最初の要望を十分を満たしてくれています。

もしなにか要望があれば、issuesやPRを立てていただければ、時間がある時に確認します!

以下、目玉となる機能をどう実装したか解説していきます。

Astroでビルド時に任意の処理を走らせる

Astroで、Notionから情報を取得してページの内容に反映するなど、ビルド時に任意の処理を走らせるには、 .astro ファイル内に直接処理を実装するのが一番簡単です。

ブログは主に、複数の記事を並べて一覧で表示するページと、個別の記事のページの2種類があるかと思います。Astroでは index.astro[slug].astro のようなファイルを作成し、それぞれのページをビルドできます。

以下は、記事一覧ページの index.astro ファイルの抜粋です。

  1. Notionのデータベースのメタデータと、データベース内のすべてのページのメタデータを取得します。ブログのタイトルや各記事のタイトル、各記事のカバー画像、作成日時等のデータが含まれています。
  2. paginate ヘルパー関数で ページネーションを生成します。
  3. 最後に downloadImages で、このページが必要とする画像をダウンロードして、 static に配置します(Notion APIで得られる画像のURLは、期限付きのURLのため永続的に使用できない)。
---
import Pagination from "../components/Pagination.astro";
import PostList from "../components/PostList.astro";
import config from "../config";
import { downloadImages } from "../download";
import Layout from "../layouts/Layout.astro";
import client from "../notion";
import { paginate } from "../utils";

const [database, posts] = await Promise.all([
  client.getDatabase(),
  client.getAllPosts(),
]);

const { pageCount, pageInt, pagePosts } = paginate(posts, "1");
await downloadImages(database?.images, ...posts.map((p) => p.images));
---

<Layout>
  <main class="py-12 lg:py-20">
    <article class="max-w-6xl mx-auto px-3">
      <header class="text-center mb-12">
        <h1 class:list={["mb-12 text-6xl title", config.index?.titleClasses]}>
          {database.title}
        </h1>
        <p class="mx-auto max-w-xl">
          {database.description}
        </p>
      </header>
      <PostList posts={pagePosts} />
      <Pagination page={pageInt} pageCount={pageCount} />
    </article>
  </main>
</Layout>

次に、個別の記事ページである [slug].astro ファイルの抜粋です。

Astroでは getStaticPaths という関数をエクスポートすると、ダイナミックにスラッグのリストを計算したうえでAstroに教えることができ、その個数分 [slug].astro の処理が実行されて個別のページがビルドされます。

  1. client.getDatabase でNotionからデータベースのメタデータを取得(ブログタイトルなどで使用)。
  2. client.getPostBySlugAstro.params.slug をもとにNotionの記事ページのメタデータを取得。ページID、タイトル、カバー画像、作成日時などの情報を含みます。
  3. ページのIDが得られるので client.getPostContent でNotionからページの内容を取得。この時マークダウン化の処理も同時に行われ、マークダウンとして取得できます。
  4. マークダウンをmarkdownToHTML でHTMLに変換します。これがAstroのHTML中に埋め込まれます。
  5. 最後に、 downloadImages で、今まで登場した必要となる画像をダウンロードして、 static ディレクトリに配置します(Notion APIで得られる画像のURLは、期限付きのURLのため永続的に使用できない)。
---
import config from "../../config";
import PostFooter from "../../customization/PostFooter.astro";
import { downloadImages } from "../../download";
import Layout from "../../layouts/Layout.astro";
import { markdownToHTML } from "../../markdown";
import client from "../../notion";
import { postUrl, formatPostDate } from "../../utils";

const { slug } = Astro.params;

export async function getStaticPaths() {
  const posts = await client.getAllPosts();
  return posts.map((p) => ({ params: { slug: p.slug } })) ?? [];
}

const database = await client.getDatabase();
const post = slug ? await client.getPostBySlug(slug) : undefined;
if (!post) throw new Error(`Post not found: ${slug}`);

const content = post ? await client.getPostContent(post.id) : undefined;
const html = content ? await markdownToHTML(content.markdown) : undefined;
await downloadImages(content?.images, post?.images, database?.images);
---

<Layout
  title={post.title}
  description={post.excerpt}
  path={postUrl(post.slug)}
  ogImage={postUrl(post.slug + ".webp", Astro.site)}
>
  <main class="py-12 lg:py-20">
    <article class="max-w-5xl mx-auto px-3">
      <header class="mx-auto pb-12 lg:pb-20 max-w-3xl text-center">
        <h1 class:list={["text-5xl mb-6 title", config.post?.titleClasses]}>
          {post.title}
        </h1>
        <p class="text-center">
          {formatPostDate(post.date)}
        </p>
      </header>
      {
        post.featuredImage && (
          <img
            class="rounded-xl mx-auto aspect-video object-cover"
            style="min-width: 80%;"
            loading="lazy"
            src={post.featuredImage}
            alt={post.title}
          />
        )
      }
      <section
        class:list={[
          "max-w-3xl mx-auto py-6 lg:py-12 markdown post",
          config.post?.classes,
        ]}
        set:html={html}
      />
      <PostFooter post={post} database={database} />
    </article>
  </main>
</Layout>

ところで、お気づきかもしれませんが、 client.getDatabase client.getPostContent といった、Notion APIを呼び出す処理は、Astroのページビルド時にページの数の分だけ実行されます。つまり、100記事あれば1記事3回としても300回以上はNotion APIを呼び出す必要が出てきてしまいます。これではNotion APIのRate Limitに抵触する可能性があり、時間もかかってしまうので、まずいです。

Notionから取得した記事をキャッシュする

そこで、Astrotionでは、ビルドを高速化するためにNotion APIからのレスポンスをキャッシュしているのが大きなポイントです。

上記で登場した client は、通常のNotionのライブラリと同じような感覚で呼び出しができるが、裏では最適化がされており、既にキャッシュが存在すれば、Notion APIを呼ばずにキャッシュの内容を返すようになっています。キャッシュミスの場合はNotion APIを呼び出し、レスポンスの内容をファイルに保存してから結果を返却します。

これにより、大量に client が呼ばれても、Notion APIの呼び出し回数を抑制することができます!

実際にAstroのビルドを走らせると、以下のようなキャッシュが生成されます。

tree node_modules/.astro/.astrotion
node_modules/.astro/.astrotion
├── notion-cache
│   ├── blocks-003fbd8d-14b3-4e8d-b189-d0425b63ce20.json
│   ├── blocks-00a7c94f-2115-48e6-9a28-f8d021eb4313.json
│   ├── blocks-04d758a4-0cf8-4485-b0de-a32c1238fae0.json
│   ├── blocks-09b21335-9d1a-4389-82cc-e776c7e70269.json
│   ├── blocks-0ceafd41-7530-4b67-ba95-91b304da459d.json
│   ├── blocks-1606a454-2ab8-4ae7-8f61-814e7ab66aee.json
│   ├── blocks-1c2a51a8-afa2-4f7f-a794-216b7d377ac2.json
│   ├── blocks-2cc76487-bca9-4aac-9596-2462ffb5058b.json
│   ├── blocks-2da23309-61bd-4c80-8de3-94149b638b45.json
│   ├── blocks-366522c9-e5d4-4e3c-af9e-4e0b4b16bb65.json
│   └── meta.json
└── static
    ├── CE8F71C3-DBE1-40AB-805C-8F17B3F5D37B.webp
    ├── D9E44F39-ADD7-4315-9A2B-9717C4B116F4.webp
    └── EC946DBE-9A4C-47BA-827D-C0181EB54B0E.webp

上記のファイルを見ると、blocksというファイルがキャッシュされています。これはNotion APIで取得できる「ブロック」のレスポンス内容です。

Notionのページの内容は、いってしまえばブロックの集合体です。ページ自体が1つの大きなブロックであり、あるブロックの中身が他のブロックの内容に依存していることもよくあります。その場合、新たなブロックIDが現れるたびに、Notion APIを新たに呼び出し、各ブロックIDに対応するJSONファイルを保存しています。

そして、次回Notionのページ内容を取得する際には、該当ブロックIDのキャッシュが存在しているかチェックし、そのキャッシュの更新日付をもとに、新たな変更が発生していなければそのファイルの内容を、Notion APIのレスポンスの内容の代わりとして使用します。

なお、meta.jsonというファイルがありますが、各記事の更新日付と、ブロックの親子関係を保持するためのキャッシュファイルです。webpのファイルは後述するOGP画像のレンダリング結果です。

これらが node_modules 内に格納されることで、NetlifyやCloudflare Pagesなどでもキャッシュに対応し、2回目以降のビルドが高速化されます。

NotionをHTMLとしてレンダリングする

Astrotionでは、前述の通り

  1. Notionのブロックをまずマークダウンに変換
  2. その後マークダウンをHTMLに変換する

という二段構えでレンダリングを行っています。

こうすることでうまく関心の分離が行え、それぞれの処理について、既にnpmに存在する豊富なツールやライブラリが使用できるようになります。

例えば、Notionからマークダウンの変換には、 notion-to-mdを使用しています。全てのブロックに対応しているわけではないので、いくつか追加でCustom Transformersを実装しています。カスタマイズが容易なのもこのライブラリの良い点です。

ただこのライブラリは、公式のNotionクライアントの実装に依存しており、勝手にNotion APIを呼び出してくれるため、先程述べたキャッシュが実現できません。そこで、notion-to-mdが依存するNotionのClientと同じような型を持つが、透過的にキャッシュ処理をしてくれるNotionクライアントのラッパーを実装し、notion-to-mdの初期化時に無理やり差し込んでいます。

一方、マークダウンからHTMLへの変換では、unifedを使用し、remark(マークダウンASTの処理)やrehype(HTML ASTの処理)をunifiedの上で動作させつつ、様々なremarkやrehypeのプラグインがすでに存在するので、それらを以下のように組み合わせるだけで、十分高機能な変換が実現してしまいます。

例えば、シンタックスハイライト、数式のレンダリング、Mermaidのレンダリングなども、全てremarkやunifiedのエコシステム内で完結してしまいます。ブックマークを埋め込む機能やコールアウトを作りたくなった場合も機能拡張が行いやすいです。超便利です。

export const md2html = unified()
  .use(remarkParse)
  .use(remarkGfm)
  .use(remarkMath)
  .use(remarkRehype, { allowDangerousHtml: true })
  .use(rehypeRaw)
  .use(rehypeKatex)
  .use(rehypeMermaid, { strategy: "pre-mermaid" })
  .use(rehypePrism) // put after mermaid
  .use(rehypeStringify);

export async function markdownToHTML(md: string): Promise<string> {
  return String(await md2html.process(md));
}

OGP画像をレンダリングする

ブログ記事をSNSでシェアするときに、QiitaやZennのように記事タイトルが入ったOGP画像が自動的に出てきてほしいですよね。Astroなら割と簡単にできちゃいます!

[slug].astro を先程紹介しましたが、 [slug].webp.ts というファイルを作れば、個別記事と同じ要領で画像をビルドすることが可能です!

ファイル内では、OGPのベースとなる画像を読み込んだうえで、 ezogというライブラリを使用してOGPをレンダリングしています。出力がPNG形式のバイナリなので、sharpを使って、より高圧縮なWebPに変換しています。

import fs from "node:fs";

import type { APIRoute, GetStaticPaths } from "astro";
import { defaultFonts, generate } from "ezog";
import sharp from "sharp";

import config from "../../config";
import client from "../../notion";

const fonts = defaultFonts(700);
const ogBaseBuffer = await fs.promises
  .readFile(config.og?.baseImagePath || "public/og-base.png")
  .catch(() => null);

export interface Props {
  slug: string;
}

export const getStaticPaths: GetStaticPaths = async () => {
  const posts = await client.getAllPosts();
  return posts.map((p) => ({ params: { slug: p.slug } })) ?? [];
};

export const GET: APIRoute<Props> = async ({ params }) => {
  const post = await client.getPostBySlug(params.slug || "");
  if (!post) throw new Error("Post not found");

  const image = await generate(
    [
      ...(ogBaseBuffer
        ? [
            {
              type: "image" as const,
              buffer: ogBaseBuffer,
              x: 0,
              y: 0,
              width: 1200,
              height: 630,
            },
          ]
        : []),
      {
        type: "textBox",
        text: post.title,
        x: 60,
        y: 60,
        width: 1080,
        fontFamily: [...fonts.map((font) => font.name)],
        fontSize: 60,
        lineHeight: 80,
        align: "center",
        color: "#000",
        ...config.og?.titleStyle,
      },
    ],
    {
      width: 1200,
      height: 630,
      fonts: [...fonts],
      background: config.og?.backgroundColor || "#fff",
    },
  );

  const webp = await sharp(image).webp().toBuffer();
  return new Response(webp.buffer);
};

あとはこのURLを各記事のヘッダに埋め込むだけで、例えば以下のようなOGP画像が自動的に生成できてしまいます!

まとめ

Astrotionでは、これまでに述べた様々な工夫により、エンジニアブログで求められがちな機能を効率的に実現しました。もし興味を持っていただいたらご自身のブログでも使ってみてください。イシューでの改善提案やプルリクエストなども歓迎です!(すぐに反応できないかもしれませんが…)

将来的には、Astrotionを単独のライブラリ化してヘッドレス化することで、テーマに依存しないで簡単に好きなAstroプロジェクトに組み込めるように整理してみたいです。

Astroは、.astroという独自フォーマットに若干怖気つきますが、ReactとTypeScriptでJSXをマークアップする開発体験がそのまま味わえて、ビルド時にいろんな処理を走らせられるのでかなり便利です!エコシステムも充実しており、Viteを使っているためビルドも高速なので、おすすめです。

Japanese

Eukaryaでは様々な職種で積極的にエンジニア採用を行っています!OSSにコントリビュートしていただける皆様からの応募をお待ちしております!

Eukarya 採用ページ

Eukarya is hiring for various positions! We are looking forward to your application from everyone who can contribute to OSS!

Eukarya Careers

Eukaryaは、Re:Earthと呼ばれるWebGISのSaaSの開発運営・研究開発を行っています。Web上で3Dを含むGIS(地図アプリの公開、データ管理、データ変換等)に関するあらゆる業務を完結できることを目指しています。ソースコードはほとんどOSSとしてGitHubで公開されています。

Eukarya Webサイト / ➔ note / ➔ GitHub

Eukarya is developing and operating a WebGIS SaaS called Re:Earth. We aim to complete all GIS-related tasks including 3D (such as publishing map applications, data management, and data conversion) on the web. Most of the source code is published on GitHub as OSS.

Eukarya Official Page / ➔ Medium / ➔ GitHub