
どうも。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
を定義します。
export interface Post {
slug: string;
title: string;
publishedAt: Date;
content: string;
}
記事一覧を取得する
posts
フォルダから投稿データを取得します。
// 記事の一覧を取得する
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
を次のように修正します。
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」のセクションより、そのままではエラーが出る部分は修正してあります。
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
を追加します。
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
に追記します。追加後の全体は次のとおりです。
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を利用します。
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
次に、サブモジュールを更新します。
- サブモジュールのローカルの設定ファイルを初期化して更新する(これは必ず実行しなければならない)
git status
で新しい変更があるかを確認git add .
をしてコミットを行う (変更があった場合のみ)- 変更があった場合には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-sample
の deploy
を発火するようにしていきます。
- GitHub Appsを作る
- 動かしたいworkflow側に
workflow_dispatch:
をつける - 動かしたいworkflow側のプロジェクトを GitHub Apps と紐づける(インストール)
- サブモジュール側に上記のworkflowを用意する
- サブモジュール側のsercretsに
APP_ID
とPRIVATE_KEY
を登録する。(1のAPP_ID
と 1で作成される .pem)
「GitHub Actions 内で他の repository の workflow を発火する」を参考にさせていただきました。
GitHub App を作る
GitHub のアカウントページより Developer Settings
を選択して GitHub Appsを作成します。
- Webhook Active のチェックを外す
- Permissions > Repositry permissions で
Actions
をRead 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-sample
のdeploy.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
を追加します。
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してみます。
fresh-submodule-sample-content
のdeploy_content.yml
が実行されるfresh-submodule-content
のdeploy.yml
が実行される
無事に1, 2の実行に成功したのを確認したら、ページを開いてみます。
無事に更新されていました!
運用でカバー
さて、問題①に以下のように書きました。
これは GitHub Actions と 運用でカバー してます。
運用でカバーしている部分について最後に紹介します。
fresh-submodule-content
の deploy.yml
では、アクション内でコミットとプッシュを行っています。つまり、コンテンツを修正すると main
ブランチにコミットがされるわけです...。
なので開発中は次のルールを設定しています。
feature
ブランチの中ではgit submodule update --remote
を行わないようにしようfeature
ブランチを切る前にmain
の変更を取り込もう
$ git checkout staging
$ git pull --rebase origin main
$ git submodule update
現状はこんな感じで頑張っています。記事を書くことの方が圧倒的に多く、Fresh側の作業をあんまりすることがない個人だからこそできることかもしれません...。
おわりに
SSH keyの設定方法をすっかり忘れていて、かなりハマっちゃいました。記事は早いうちに書け、ですね...。
記事の更新は Obsidian のホットキーを使っているので、普段は単にファイルを保存するだけのような感じでブログを更新しています。ここもスクリプト書いてあるので気が向いたら記事書きます。
一度設定してしまえば便利っちゃ便利だけれど、これでいいのかなぁという気もしています。この方法自体はそんな悪くはない?どう?ただし、画像をサブモジュールにぶち込んだまま何の処理もしてないので、早く何とかしたいところです。
実際に戦っていた時から数ヶ月経ってしまったので、記事を書くのがとても大変でした。疲れちゃったのでアイキャッチ画像ももう頑張れないぜ...
ではでは。何かの役に立てば嬉しいです。
コメントはありません。
現在コメントフォームは工事中です。