うるおいらんど
アイキャッチ画像

FreshでGitサブモジュールからコンテンツを取得してみた

FreshDenoGit

どうも。Reoです。

このブログをWordPressからFreshに移行したときに、Fresh本体のリポジトリとブログ記事のリポジトリを分けることにしました。Fresh とブログ記事をつなげる方法として、Git のサブモジュール機能を利用しました。

ブログをWordPressからFreshに移行した過程の紹介②【技術選定と記事の移行】ではこの方法を大成功としていますが、しばらく運用して、動いてはいるけど最適でない部分も多いなと感じています。

サブモジュール化をすることで嬉しいことは、ブログを Fresh から移行したくなった時にもブログ記事のリポジトリはそのまま使えることと、開発と執筆を完全に分けることができることです。コミットも混ざらないしブランチ管理などもそれぞれで行えます。

だいぶ時間は経っていますが、改めて思い出しながらどういう仕組みになっているか書いていきます。がんばるぞい。

概要

Freshの公式ブログ記事「How to Build a Blog with Fresh」にて、Freshでのブログの作り方が書かれています。

記事ではブログのコンテンツは、Freshプロジェクト内の posts フォルダに配置されます。 posts の中にはマークダウンのファイルが配置されます。

my-fresh-blog/
├── .vscode
├── posts         👈 ココ
├── routes
├── static
├── deno.json
├── dev.ts
├── fresh.gen.ts
├── import_map.json
├── main.ts
├── README.md
└── twins.config.ts

今回はこれをもとにサンプルを作成しながら、posts の部分だけ別のリポジトリにしたい!を実現していこうと思います。

環境

$ deno --version
deno 2.1.9 (stable, release, aarch64-apple-darwin)
v8 13.0.245.12-rusty
typescript 5.6.2

GitHub を利用します。

プロジェクトの準備

まずはサンプルのプロジェクトを作成します。tailwindcss と VSCode は y を選択しておきましょう。

# プロジェクトを生成
$ deno run -A -r https://fresh.deno.dev
Project Name: fresh-submodule-sample
# プロジェクトに移動
$ cd fresh-submodule-sample

まずは、このプロジェクト自体を git 管理します。

$ git init
$ git add .
$ git commit -m "Initial Commit"
# GitHub と紐づける
$ git remote add origin git@github.com:uruly/fresh-submodule-sample.git
$ git push -u origin main

コンテンツ用のリポジトリも用意します。

# fresh-submoudle-sample の中には作らない
$ mkdir fresh-submodule-sample-content

コンテンツ用のリポジトリには以下のようにマークダウンファイルを配置しました。これも git 管理しておきます。

# コンテンツをgit管理しておく
$ git init
$ git add .
$ git commit -m "Initial Commit"
# GitHub と紐づける
$ git remote add origin git@github.com:uruly/fresh-submodule-sample-content.git
$ git push -u origin main

サブモジュール化

サブモジュール を参考にやっていきます。

fresh-submodule-sampleに対して、先ほどコンテンツリポジトリとして作った fresh-submodule-sample-content をサブモジュールで追加します。 posts ディレクトリに入るように指定しました。

$ cd fresh-submodule-sample
# git submodule add コンテンツのリポジトリ ディレクトリ名
$ git submodule add git@github.com:uruly/fresh-submodule-sample-content.git posts

この時 .gitmoduleファイルが作成されます。

[submodule "posts"]
    path = posts
    url = git@github.com:uruly/fresh-submodule-sample-content.git

また、posts ディレクトリが追加されており、その中に fresh-submodule-sample-contentの中身が入っています。

コミットします。

$ git commit -m "Add posts as submodules"

 create mode 100644 .gitmodules
 create mode 160000 posts

 submodule エントリのモードが 160000 となったことに注目しましょう。これは Git における特別なモードで、サブディレクトリやファイルではなくディレクトリエントリとしてこのコミットを記録したことを意味します。

これでサブモジュールとしてコンテンツのみを保存することができました。

リポジトリをクローンする時

このリポジトリをクローンするときには毎回 git submodule updateが必要になります。

$ git clone Freshのリポジトリ
# サブモジュールのローカルの設定ファイルを初期化する
$ git submodule init
# プロジェクトからのデータを取得し、親プロジェクトで指定されている適切なコミットをチェックアウト
$ git submodule update

更新する時

コンテンツが修正された後にはその修正はFreshプロジェクトには反映されません。 なので変更を取り込んであげる必要があります。

$ git submodule update --remote
Submodule path 'posts': checked out 'a03bbf528a91a91206a7f1d5856a353d'

表示してみる

Freshの公式ブログ記事「How to Build a Blog with Fresh」の通りにコンテンツを表示してみましょう。

今回の記事の本題ではないのでサクッと実装していきます。

Post を定義する

utils ディレクトリを作成し、posts.ts ファイルを作成します。

fresh-submodule-sample/
…
├── utils
│   └── posts.ts
…

Postを定義します。

utils/posts.ts
export interface Post {
  slug: string;
  title: string;
  publishedAt: Date;
  content: string;
}

記事一覧を取得する

posts フォルダから投稿データを取得します。

utils/posts.ts
// 記事の一覧を取得する
export async function getPosts(): Promise<Post[]> {
  const files = Deno.readDir("./posts/public");
  const promises = [];
  for await (const file of files) {
    const slug = file.name.replace(".md", "");
    promises.push(getPost(slug));
  }
  const posts = await Promise.all(promises) as Post[];
  posts.sort((a, b) => b.publishedAt.getTime() - a.publishedAt.getTime());
  return posts;
}

今回はサブモジュールである posts フォルダ内の public フォルダに入っている記事を取得します。

routes/index.ts を次のように修正します。

utils/index.ts
import { Handlers, PageProps } from "$fresh/server.ts";
import { getPosts, Post } from "../utils/posts.ts";

export const handler: Handlers<Post[]> = {
  async GET(_req, ctx) {
    const posts = await getPosts();
    return ctx.render(posts);
  },
};

export default function BlogIndexPage(props: PageProps<Post[]>) {
  const posts = props.data;
  return (
    <main class="max-w-screen-md px-4 pt-16 mx-auto">
      <h1 class="text-5xl font-bold">Blog</h1>
      <div class="mt-8">
        {posts.map((post) => <PostCard post={post} />)}
      </div>
    </main>
  );
}

function PostCard(props: { post: Post }) {
  const { post } = props;
  return (
    <div class="py-8 border(t gray-200)">
      <a class="sm:col-span-2" href={`/${post.slug}`}>
        <h3 class="text(3xl gray-900) font-bold">
          {post.title}
        </h3>
        <time class="text-gray-500">
          {new Date(post.publishedAt).toLocaleDateString("en-us", {
            year: "numeric",
            month: "long",
            day: "numeric",
          })}
        </time>
      </a>
    </div>
  );
}

実行して記事一覧が表示されるかみてみましょう。

$ deno task start

 🍋 Fresh ready 
    Local: http://localhost:8000/

http://localhost:8000/ で確認してみます。

Tailwind をあとから追加する

Fresh プロジェクトを作成するときに スタイリングライブラリ を追加しますか?で No を選択してしまっていたので、追加しておきます。

Migrating to Tailwindの通りに追加します。大変なのでプロジェクトを作る時にYesを選んでおくことをお勧めします...

次のように表示されました。

単一の記事も表示できるようにしておく

utils/posts.ts に追記します。「Update the routes」のセクションより、そのままではエラーが出る部分は修正してあります。

utils/posts.ts
import { extract } from "$std/front_matter/any.ts";
import { join } from "$std/path/mod.ts";

// 単一の記事を取得する
export async function getPost(slug: string): Promise<Post | null> {
  const text = await Deno.readTextFile(join("./posts/public", `${slug}.md`));
  const { attrs, body } = extract(text);

  return {
    slug: slug,
    title: attrs.title as string,
    publishedAt: new Date(attrs.published_at as string),
    content: body,
  };
}

routes/[slug].tsxを追加します。

routes/[slug].tsx
import { Handlers, PageProps } from "$fresh/server.ts";
import { getPost, Post } from "../utils/posts.ts";

export const handler: Handlers<Post> = {
  async GET(_req, ctx) {
    const post = await getPost(ctx.params.slug);
    if (post === null) return ctx.renderNotFound();
    return ctx.render(post);
  },
};

export default function PostPage(props: PageProps<Post>) {
    const post = props.data;
    return (
      <main class="max-w-screen-md px-4 pt-16 mx-auto">
        <h1 class="text-5xl font-bold">{post.title}</h1>
        <time class="text-gray-500">
          {new Date(post.publishedAt).toLocaleDateString("en-us", {
            year: "numeric",
            month: "long",
            day: "numeric"
          })}
        </time>
        <div class="mt-8"
          dangerouslySetInnerHTML={{ __html: post.content }}
          />
      </main>
    )
  }

これで単一記事も表示できるようになりました。

マークダウンをパース

マークダウンをパースする方法については 「Parsing markdown」セクションを参考にしています。

まずは Github Flavored Markdowのモジュールを追加します。

$ deno add jsr:@deno/gfm

/routes/[slug].tsx に追記します。追加後の全体は次のとおりです。

[slug].tsx
import { Handlers, PageProps } from "$fresh/server.ts";
import { getPost, Post } from "../utils/posts.ts";
import { CSS, render } from "@deno/gfm";
import { Head } from "$fresh/runtime.ts";

export const handler: Handlers<Post> = {
  async GET(_req, ctx) {
    const post = await getPost(ctx.params.slug);
    if (post === null) return ctx.renderNotFound();
    return ctx.render(post);
  },
};

export default function PostPage(props: PageProps<Post>) {
  const post = props.data;
  return (
    <>
      <Head>
        <style dangerouslySetInnerHTML={{ __html: CSS }} />
      </Head>
      <main class="max-w-screen-md px-4 pt-16 mx-auto">
        <h1 class="text-5xl font-bold">{post.title}</h1>
        <time class="text-gray-500">
          {new Date(post.publishedAt).toLocaleDateString("en-us", {
            year: "numeric",
            month: "long",
            day: "numeric",
          })}
        </time>
        <div
          class="mt-8 markdown-body"
          dangerouslySetInnerHTML={{ __html: render(post.content) }}
        />
      </main>
    </>
  );
}

コンテンツ側にマークダウンのサンプルを追加してremote にプッシュしておきます。

コンテンツの変更を取得します。

$ git submodule update --remote

$ git add posts
$ git commit -m "🤖 Update submodules"
$ git push origin main

これで表示ができました。

問題点

サブモジュールで別のリポジトリとして紐づけることはできましたが、実際にはデプロイ時などに問題があります。

問題①最新の記事を取得するの面倒すぎ

1番の問題点は記事が更新されるたびに git submodule update --remote で変更を取得してコミットしなきゃいけないことです。なんて面倒なんだ。

これは GitHub Actions と 運用でカバー してます。

問題② 記事の更新をどうやって伝える?

記事が更新(コンテンツリポジトリへのpush)されても、Fresh側には変更は通知されません。

変更したことを通知する必要があります。

これも GitHub Actions で解決しています。

GitHub Actions でデプロイ時にsubmoduleを更新する

まずは問題①から。 「 GIthub Actions で Git Submodule を 最新に更新して処理する」を参考に、ワークフローを書きました。

.github/workflows/deploy.yml を作成して、以下のように設定しました。 デプロイは Deno Deployを利用します。

.github/workflows/deploy.yml
name: Deploy
on:
  push:
    branches: main
  pull_request:
    branches: main
  workflow_dispatch:  # 手動でトリガーできるイベントを設定

jobs:
  deploy:
    name: Deploy
    runs-on: ubuntu-latest

    permissions:
      id-token: write # Needed for auth with Deno Deploy
      contents: read # Needed to clone the repository

    steps:
      - name: Clone repository
        uses: actions/checkout@v4
        with:
          submodules: true # サブモジュールも含めてクローン
          ssh-key: ${{ secrets.SSH_KEY }}
          ref: main

      # サブモジュールの更新
      - name: Update submodules
        id: update
        run: | 
          git submodule init
          git submodule update --remote 
      - name: Run git status
        id: status
        run: |
          status=$(git status -s)
          echo "status=$status" >> $GITHUB_ENV
      - name: Add and commit files
        run: |
          git add .
          git config --local user.email "action@github.com"
          git config --local user.name "GitHub Action"
          git commit -m "Update submodules at $(date "+DATE: %Y-%m-%d TIME: %H:%M:%S")"
        if: ${{ env.status != '' }}

      - name: Push changes to main
        run: git push origin HEAD:refs/heads/main
        if: ${{ env.status != '' }}
    
      - name: Install Deno
        uses: denoland/setup-deno@v2
        with:
          deno-version: v2.x

      - name: Install Node.js
        uses: actions/setup-node@v4
        with:
          node-version: lts/*

      - name: Install step
        run: ""

      - name: Build step
        run: "deno task build"

      - name: Upload to Deno Deploy
        uses: denoland/deployctl@v1
        with:
          project: "fresh-submodule-sample"
          entrypoint: "main.ts" # 本番用
          root: "."

このサブモジュールを扱うために追加しているのは Update submodules の部分です。

まず、 クローンする時に submodules: true に設定して、サブモジュールも含めてクローンするようにします。

    steps:
      - name: Clone repository
        uses: actions/checkout@v4
        with:
          submodules: true # サブモジュールも含めてクローン
          ssh-key: ${{ secrets.SSH_KEY }}
          ref: main

次に、サブモジュールを更新します。

  1. サブモジュールのローカルの設定ファイルを初期化して更新する(これは必ず実行しなければならない)
  2. git status で新しい変更があるかを確認
  3. git add . をしてコミットを行う (変更があった場合のみ)
  4. 変更があった場合にはmainブランチにpushする(変更があった場合のみ)
      # 1. 初期化とリモートから取得
      - name: Update submodules
        id: update
        run: | 
          git submodule init
          git submodule update --remote
      # 2. 前回と差分があるか確認する
      - name: Run git status
        id: status
        run: |
          status=$(git status -s)
          echo "status=$status" >> $GITHUB_ENV
      # 3. 変更があった場合にはコミットをする
      - name: Add and commit files
        run: |
          git add .
          git config --local user.email "action@github.com"
          git config --local user.name "GitHub Action"
          git commit -m "Update submodules at $(date "+DATE: %Y-%m-%d TIME: %H:%M:%S")"
        if: ${{ env.status != '' }}
     # 4. 変更があった場合にはmainブランチにpushする
      - name: Push changes to main
        run: git push origin HEAD:refs/heads/main
        if: ${{ env.status != '' }}

if: ${{ env.status != '' }}git status で変更がない場合には実行しないようにしています。

SSH Key を登録する

ローカルで SSH Key を生成します。新しい SSH キーを生成するを参考にしてください。

$ ssh-keygen -t ed25519 -C "your-email@example.com"

生成された公開鍵と秘密鍵をそれぞれ使います。

秘密鍵

秘密鍵の方は リポジトリの SSH_Key に追加します。

fresh-submodule-sample リポジトリの Settings > Secrets and variables > Actions に登録します。

公開鍵

公開鍵は アカウントの Settings より SSH Keys に追加します。

Deno Deploy に登録する

Deno Deploy でプロジェクトを作成しておきます。

Deno Deploy ではリポジトリを紐づけるだけでokです。(Access Token とか作らなくてok)

ワークフローを実行する

Actions タブ、または main ブランチに変更をpush してワークフローを実行します。

Deno Deploy から https://fresh-submodule-sample.deno.dev/ を確認してみると、無事にデプロイが完了してサブモジュールのコンテンツも見ることができました。

workflowは成功したのに表示されない場合などは、Deno Deploy の Logs を見るとエラーが吐かれているかもしれません。

記事更新後にFresh側のワークフローを動かす

次に、問題②を解決します。

fresh-submodule-sample-content リポジトリ側に変更がpushされた時に、先ほど作成した fresh-submodule-sampledeploy を発火するようにしていきます。

  1. GitHub Appsを作る
  2. 動かしたいworkflow側に workflow_dispatch:をつける
  3. 動かしたいworkflow側のプロジェクトを GitHub Apps と紐づける(インストール)
  4. サブモジュール側に上記のworkflowを用意する
  5. サブモジュール側のsercretsに APP_IDPRIVATE_KEYを登録する。(1のAPP_ID と 1で作成される .pem)

GitHub Actions 内で他の repository の workflow を発火する」を参考にさせていただきました。

GitHub App を作る

GitHub のアカウントページより Developer Settings を選択して GitHub Appsを作成します。

  • Webhook Active のチェックを外す
  • Permissions > Repositry permissions で ActionsRead and write に設定
  • Where can this GitHub App be installed? は Only on this account を選択

作成できたら、Private key を作成します。Generate a private key ボタンを押すと、fresh-submodule-sample.2025-02-07.private-key.pem というファイルが生成されます。

最後に、fresh-submodule-sample に対してこのアプリのインストールも行いましょう。

workflow_deipatch:をつける

先ほどの fresh-submodule-sampledeploy.yml には workflow_dispatch: がすでに書いてありますが、これは外部からこのworkflowを実行できるようにするためのものです。

name: Deploy
on:
  push:
    branches: main
  pull_request:
    branches: main
  workflow_dispatch:  # 手動でトリガーできるイベントを設定

update.yml を作成する

次は fresh-submodule-sample-content のリポジトリ側に workflow を追加していきます。

.github/workflows/deploy_content.yml を追加します。

.github/workflows/deploy_content.yml
name: Deploy Contents
on:
  push:
    branches:
      - main

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - uses: actions/create-github-app-token@v1
        id: app-token
        with:
          app-id: ${{ secrets.APP_ID }}
          private-key: ${{ secrets.PRIVATE_KEY }}
          repositories: fresh-submodule-sample
      - uses: actions/github-script@v7
        with:
          github-token: ${{ steps.app-token.outputs.token }}
          script: |
            await github.rest.actions.createWorkflowDispatch({
              owner: 'uruly',
              repo: 'fresh-submodule-sample',
              workflow_id: 'deploy.yml',
              ref: 'main'
            })

main ブランチにpushされたときに実行するようにしています。

on:
  push:
    branches:
      - main

まず、App Token を生成します。これには GitHub App のIDと秘密鍵が必要です。

    steps:
      - uses: actions/checkout@v2
      - uses: actions/create-github-app-token@v1
        id: app-token
        with:
          app-id: ${{ secrets.APP_ID }}
          private-key: ${{ secrets.PRIVATE_KEY }}
          repositories: fresh-submodule-sample # インストールしたリポジトリ

そして生成したApp Token を用いて、fresh-submodule-sampleリポジトリにアクセスして、Workflowを実行します。

      - uses: actions/github-script@v7
        with:
          github-token: ${{ steps.app-token.outputs.token }}
          script: |
            await github.rest.actions.createWorkflowDispatch({
              owner: 'uruly',
              repo: 'fresh-submodule-sample',
              workflow_id: 'deploy.yml',
              ref: 'main'
            })

Secrets を設定

fresh-submodule-sample-content リポジトリの Settings > Secrets and variables > Actions に登録します。

APP_ID は先ほど作成した GitHub App の App ID です。

PRIVATE_KEY は 生成したfresh-submodule-sample.2025-02-07.private-key.pem の中身を貼り付けます。

Workflowを実行してみる

fresh-submodule-sample-content の記事の内容を編集して、main ブランチにpushしてみます。

  1. fresh-submodule-sample-contentdeploy_content.yml が実行される
  2. fresh-submodule-contentdeploy.ymlが実行される

無事に1, 2の実行に成功したのを確認したら、ページを開いてみます。

無事に更新されていました!

運用でカバー

さて、問題①に以下のように書きました。

これは GitHub Actions と 運用でカバー してます。

運用でカバーしている部分について最後に紹介します。

fresh-submodule-contentdeploy.yml では、アクション内でコミットとプッシュを行っています。つまり、コンテンツを修正すると main ブランチにコミットがされるわけです...。

なので開発中は次のルールを設定しています。

  1. feature ブランチの中では git submodule update --remote を行わないようにしよう
  2. feature ブランチを切る前に main の変更を取り込もう
$ git checkout staging
$ git pull --rebase origin main
$ git submodule update

現状はこんな感じで頑張っています。記事を書くことの方が圧倒的に多く、Fresh側の作業をあんまりすることがない個人だからこそできることかもしれません...。

おわりに

SSH keyの設定方法をすっかり忘れていて、かなりハマっちゃいました。記事は早いうちに書け、ですね...。

記事の更新は Obsidian のホットキーを使っているので、普段は単にファイルを保存するだけのような感じでブログを更新しています。ここもスクリプト書いてあるので気が向いたら記事書きます。

一度設定してしまえば便利っちゃ便利だけれど、これでいいのかなぁという気もしています。この方法自体はそんな悪くはない?どう?ただし、画像をサブモジュールにぶち込んだまま何の処理もしてないので、早く何とかしたいところです。

実際に戦っていた時から数ヶ月経ってしまったので、記事を書くのがとても大変でした。疲れちゃったのでアイキャッチ画像ももう頑張れないぜ...

ではでは。何かの役に立てば嬉しいです。

Comments

コメントはありません。

現在コメントフォームは工事中です。