🐈

【Astro on Cloudflare】個人ブログを支える技術

2023/04/17に公開

ブログは、時々投稿する以外に何もしなくても、24時間休みなしにあなたのことを宣伝してくれる媒体だと考えることができる。外からチャンスが舞い込むこと以外にも、ブログはあなた自身が成長するための素晴らしいチャンスを与えてくれる
引用元: CAREER SKILLS ソフトウェア開発者の完全キャリアガイド

というわけで、個人ブログをリリースしました。

「Zennやnote、企業でのテックブログなど複数の媒体でブログを執筆しているため、統合的な表示・宣伝の場が欲しかったこと」や「過去の制作物を宣伝する場が欲しかったこと」などが作成の主なモチベーションです。

ブログのデザインは、PinterestやZenn、Instagramといった自分が大好きなデザインのサービスを主に参考にしました。( Figma )

この記事では、本ブログを構成する技術や設計、機能実装に関して、解説を行います。以下、Githubリポジトリです。

https://github.com/YadaYuki/like-a-bear

2. 技術スタック・設計

2.1. 技術スタック

記事のタイトルにもあるとおり、今回作成した個人ブログはAstroで構築されています。Next.jsやGatsby, Remixなど世の中にフロントエンドフレームワークが群雄割拠する昨今、Astroを採用した動機としては

  1. Next.js等と比較して、クライアントサイドJavaScriptを最小限に抑えることができ、高パフォーマンス
  2. ページ内のインタラクティブなUIの実装(Astroアイランド)に、Reactをはじめとした複数のUIフレームワークの利用が可能
  3. Markdown(md,MDX)ベースのコンテンツ管理やRSSの生成、sitemapの生成といったブログ構築に必要な機能の実装が、公式提供のプラグインにより容易

といった点が挙げられます。

この中でも特に重視したのは「Next.js等と比較して、クライアントサイドJavaScriptを最小限に抑えることができ、高パフォーマンス」という点です。

今回構築した個人・企業のブログやLP、ポートフォリオのようなWebサイトは、ページの大部分が静的なHTML/CSSから構成されており、クライアントJavaScriptの実行によるインタラクティブなUIをほとんど必要としない場合が少なくありません

不要であるJavaScriptの読み込み・実行にかかるコストはWebサイトのパフォーマンス上のオーバヘッドとなります。

そのため、Astroが持つ「デフォルトでゼロJS」という特徴は、ブログやメディアサイトをはじめとするコンテンツのRead要件が主でリッチなインタラクションの重要性が低いWebサイトとの親和性が高いといえます。

そんな特徴を持つAstroですが、全くJavaScriptを使用することができないわけではありません。

ここで、Astroが採用しているアーキテクチャパターンである「Astro アイランド」について簡単に説明します。まずは「Astro アイランド」に関する公式ドキュメントの説明を見てみましょう。

「Astroアイランド」とは、HTMLの静的なページ上にあるインタラクティブなUIコンポーネントを指します。1つのページに複数のアイランドが存在でき、アイランドは常に孤立して表示されます。静的で非インタラクティブなHTMLの海に浮かぶ島(アイランド)とお考えください。

Astroは、「デフォルトでゼロJS」ではありますが、明示的にJSを使用すると指定したコンポーネントに関しては、JSによるインタラクティブなUIが構築可能です。

ここで、実際に今回構築した個人ブログの中で、アイランドを利用した実例を見ていきましょう。ブログ内では、ヘッダーのドロップダウンメニューの実装でアイランドを活用しています

---
import { SITE_TITLE } from "~/consts/meta";
import { Image } from "@astrojs/image/components";
import { Menu } from "./menu/Menu";
import { PageType, PAGES_TO_LABEL_MAP } from "~/consts/page";

interface Props {
  page: PageType;
}

const { page } = Astro.props;
---

<header>
  <div>
    <a href="/">
      <Image
        alt="ヒグマ"
        width={80}
        height={80}
        src="/bear.png"
        format={"png"}
      />
    </a>
    <h2>
      {SITE_TITLE}
      <span>/</span>
      {PAGES_TO_LABEL_MAP[page]}
    </h2>
    <Menu page={page} client:load /> // Astroアイランドを利用したDropdown メニュー
  </div>
</header>

実装を見るとわかるように、JavaScriptの実行が必要なMenuコンポーネントに、引数でclient:loadが指定されていることがわかります。

このように、Astroは、デフォルトでJSゼロ(静的なHTML/CSSのみ)なサイトを構築しつつ、明示的に指定した箇所のみ、JSの読み込み・実行を許容しています。

以上のような仕組みによってAstroはJavaScriptの使用を最小限に抑えています。今回構築した個人ブログでは、サイトのパフォーマンスを重視したかったこともあり、フレームワークとしてAstroを採用することを決めました。

そのほかの周辺技術スタック・ライブラリとしては以下を用いています。

  • Astroアイランドに対するUIフレームワーク・スタイリング: React, CSS Modules
  • RSS / sitemapの生成: @astrojs/rss, @astrojs/sitemap
  • OG画像の生成: satori + sharp
  • ホスティング: Cloudflare

2.2. フォルダ構成

次に、プロジェクトを概観するため、Astroで構築された今回のブログのフォルダ構成を見てみましょう。

.
├── README.md
├── astro.config.mjs
├── node_modules/
├── package.json
├── pnpm-lock.yaml
├── public/
│   ├── favicon.svg
│   └── robots.txt
├── src/
│   ├── assets/  # 画像やフォントなどの静的ファイル
│   ├── components/ 
│   ├── consts/ 
│   ├── content/ # Markdown(MDX)による記事
│   ├── data/    # ブログ内に掲載するコンテンツをTSの変数として管理
│   │   ├── externalNotes.ts
│   │   └── works.ts 
│   ├── domain/  # OG画像の生成やレコメンド等のロジックの実装
│   │   ├── ogp/
│   │   └── recommend/
│   ├── env.d.ts
│   ├── pages/
│   ├── schemas/ # Zodによるスキーマ定義
│   ├── styles/  # グローバルなスタイリング
│   └── utils/
├── tsconfig.json
└── vitest.config.mjs

フォルダ構成に関しては、公式ドキュメント内で紹介されている例を参考に、フォルダを切っています。

以上が今回実装したブログの技術スタック・設計になります。プロジェクトの全体像が掴めたところで、次の章から、ブログ内の機能の実装に関して説明していきます。

3. 実装

3.1. Content Collection APIを用いたMarkdown(MDX)管理

まず、個人ブログにおいて最も重要な要素とも言える、ブログ記事の管理について見ていきましょう。

外部リンクではないブログ記事はMarkdownにより執筆・管理しています。Markdown(MDX)で執筆された記事を管理するために、Astro v2から導入されたContent Collectionという機能を利用しました。

Content Collectionには、以下のようなメリットがあります。

  • Zodで定義した型により、Markdownのfront matterの値を検証できる
  • Astroが提供するAPIにより、記事一覧・記事アイテムの取得が容易にできる

プロジェクトのsrc/contentディレクトリはContent Collectionにおける予約ディレクトリです。その配下に、Markdown(MDX)により執筆されたコンテンツ(ブログ記事、ニュースレター,...etc)を格納します。

src/
    content/
        notes/
           hoge.mdx
           fuga.mdx
           ..
        config.ts

管理するコンテンツの種類をコレクション(Collection)として、区別することができ、content/config.tsでは、Zodを用いて、CollectionごとのMarkdownのfront matterの定義を行います。

上の例としては、notesというCollectionがただ一つ存在します。なお、Collectionは複数定義することも可能です。

ここでは例として、個人ブログ内のブログ記事に当たるCollectionであるnotesのfront matter定義を見てみましょう

import { defineCollection, z } from "astro:content";

export const NoteCategory = z.union([
  z.literal("tech"),
  z.literal("life"),
  z.literal("other"),
]);

export const Note = z.object({
  title: z.string(),
  description: z.string(),
  category: NoteCategory,
  emoji: z.string(),
  tags: z.array(z.string()).min(1).max(10),
  pubDate: z
    .string()
    .or(z.date())
    .transform((val) => new Date(val)),
  externalLink: z.string().optional(),
  noteId: z.string().optional(),
});

const notes = defineCollection({
  schema: Note,
});

export const collections = { notes };

Markdownのfront matterがconfig.tsで設定された定義に合致していない場合は、以下のようにエラーが発生します。

 error   notes → nand2tetris-poem.MDX frontmatter does not match collection schema.
  Invalid input
  Hint:
    See https://docs.astro.build/en/guides/content-collections/ for more information on content schemas.
  File:
    /Users/yadayuki/workspace/like-a-bear/src/content/notes/nand2tetris-poem.MDX
  Code:
    4 | description: ""
    > 5 | category: "hoge"
        | ^
      6 | emoji: "💫"
      7 | tags: ["nand2tetris", "go", "ast"]
      8 | pubDate: "2021-08-12"

このようにして、型安全にMarkdownの記事を管理することが可能です。

また、Contents Collectionにより、Astroが提供するAPIを呼び出すことで、コンテンツリストの取得も簡単に行えます。ブログのトップページで記事一覧を表示したい時には非常に便利です。

import { getCollection } from "astro:content";
const collections = await getCollection("notes"); // 全コンテンツリストの取得

以上がコンテンツ管理に関する紹介です。

3.2. Twimojiによる絵文字の付与

次に、ブログ記事に対する絵文字の付与について説明します。構築したブログでは、Zennのように記事の一つ一つに絵文字を付与しました。

絵文字画像には、Twitter がオープンソースで提供しているTwimojiを用いています。公式提供されているtwemoji-parserというライブラリを使うことで、絵文字の文字データから、TwimojiのSVG画像リンクへの変換が容易に実装可能です。

ここでは、絵文字の文字データをTwimoji画像データに変換するconvertEmojiStrToTwiImage関数の実装を見てみましょう。

import { EmojiEntity, parse } from "twemoji-parser";

export const convertEmojiStrToTwiImage = (s: string): EmojiEntity => {
  const entities = parse(s);

  if (entities.length !== 1) {
    throw Error("1 emoji must be set");
  }
  const emojiEntity = entities[0];
  return emojiEntity;
};

console.log(convertEmojiStrToTwiImage("🧐"))
// {
//   url: 'https://twemoji.maxcdn.com/v/latest/svg/1f9d0.svg',
//   indices: [ 0, 2 ],
//   text: '🧐',
//   type: 'emoji'
// }
console.log(convertEmojiStrToTwiImage("👾"))
// {
//   url: 'https://twemoji.maxcdn.com/v/latest/svg/1f47e.svg',
//   indices: [ 0, 2 ],
//   text: '👾',
//   type: 'emoji'
// }

3.3. コンテンツベースのレコメンデーション

今回実装したブログには、簡易的なレコメンデーションが実装されています。記事詳細ページの下部に表示されている関連記事の一覧です。

レコメンドのアルゴリズムは非常に単純です。

まず、ブログ内の各記事(Note)には、tags というフィールドが存在しています。ここには記事を特徴づけるタグ(キーワード)をリスト形式で設定します。例として、以下は以前執筆した「Python(PyTorch)で自作して理解するTransformer」という記事のデータです。

{
     title: "Python(PyTorch)で自作して理解するTransformer",
     description: "",
     category: "tech",
     emoji: "🙊",
     tags: ["machine learning", "python", "pytorch", "nlp", "docker"], // 記事に対するタグ(キーワード)
     pubDate: new Date("2022-03-31"),
     externalLink: "https://zenn.dev/yukiyada/articles/59f3b820c52571",
}

このブログにおけるレコメンドでは、まず、tagsで付与された単語列をTfIdfによりベクトル化します。

TF_i(t) = freq(t,d_i)
IDF(t) = log((1+n)/(1+df_t))
TF\verb|-|IDF_i(t) = TF_i(t) * (IDF(t) + 1)

そして、算出されたベクトル間の類似度をCosine Similarityにより算出し、今読んでいる記事に対する類似度が高いアイテム4件を関連記事として推薦しています。

cos(X,Y) = \frac{X \cdot Y}{|X| |Y|}

なお、これらの類似度計算はビルド(SSG)時に事前に実行しています。また、長くなるためここには記載しませんが、これらの計算は全て、TypeScriptによりスクラッチ実装しました。src/domain/recommend以下に実装があるので興味があれば、見てみてください

以上がレコメンドに関する説明です。

3.4. satori + sharp を用いたOG画像生成

最後にOG画像の生成について見ていきます。

本ブログの記事をSNS等でシェアすると、以下のようなpng形式のOG画像が表示されます。

今回作成したブログでは、

  • satori: HTML/CSSからsvg形式の画像を生成するvercel製のライブラリ
  • sharp: svgからpngへの変換

という二つのライブラリを使用し、HTML/CSSからOG画像を生成しています。

OG画像の生成方法としては、他にも、「HTML/CSSでスタイリングしたページをPuppeteer等のヘッドレスブラウザを操作可能なライブラリを用いてスクリーンショットする方法」などがあります。(CookpadGithubが採用)

しかし、「OG画像の生成にかかる時間を最小限にしたかったこと」や「Puppeteerのように容量の大きいライブラリが依存に含まれることを可能な限り避けたかったこと」から、satori + sharpというOG画像の生成方法を採用しました。

それでは、satori + sharpを用いて、png形式のOG画像を生成するコードを見てみましょう。

generateOgImage.ts
import { join } from "node:path";
import { readFileSync } from "node:fs";
import satori from "satori";
import sharp from "sharp";
import { OgImgTemplate } from "./OgImgTemplate";
import { FONT_DIR } from "~/consts/path";

export const generateSvgFromComponent = async (title: string) => {
  const fontData = readFileSync(
    join(FONT_DIR, "Noto_Sans_JP", "NotoSansJP-Bold.otf")
  );
  const svg = await satori(
    OgImgTemplate({
      title,
    }),
    {
      width: 1200,
      height: 630,
      fonts: [
        {
          name: "Noto Sans JP",
          data: fontData,
        },
      ],
    }
  );

  const sharpSvg = Buffer.from(svg);

  const buffer = await sharp(sharpSvg).toBuffer();

  return buffer;
};

以上がOG画像の生成に関する説明です。

4. まとめ

長くなりましたが、以上が実装の説明になります!

最近では、noteやzenn、hatenablogなど、ブログを運営するための優れたサービスが多く存在します。しかし、ゼロからブログを構築することで、表示したいコンテンツやデザインを自由に操作することができます。開発コストはかかりますが、アウトプットを発信する場として、非常に便利です。

本記事が独自ブログの開設を考えている個人・企業の参考になれば幸いです。

GitHubで編集を提案

Discussion