TL;DR
- ObsidianのMarkdownファイルをそのままブログ記事として使う
- Flask-Frozenで静的HTML化 → Cloudflare Pagesで無料公開
- Claude Codeのカスタムスキルで記事管理をAI補助
- OGP画像の自動生成・サイト内検索・sitemap.xml生成まで一通り揃えた
- MITライセンスでテンプレートを公開中
モチベーション
WordPressは管理コストが高い。Hugoは速いがカスタマイズがつらい。Notionはいいが独自ドメインが有料。
結局「Markdownファイルが手元にあって、それがそのままブログになる」構成が一番シンプルだと思い、Obsidian + Flask-Frozenで自作した。
システム構成
content/posts/*.md ← Obsidianで編集
↓ frontmatter + wikilink解決
app.py (Flask)
↓ flask-frozen
build/ ← 静的HTML
↓ git push
Cloudflare Pages ← 自動デプロイ
技術選定
| 役割 | 採用 | 理由 |
|---|---|---|
| エディタ/CMS | Obsidian | ローカルファイル、プロパティUI、グラフビュー |
| Webフレームワーク | Flask | Pythonで書ける、シンプル |
| 静的化 | Frozen-Flask | Flaskのルートをそのまま静的化できる |
| ホスティング | Cloudflare Pages | 無料、カスタムドメイン対応 |
| AI補助 | Claude Code | カスタムスキルで記事管理 |
実装のポイント
Obsidianとの統合
Obsidianのvaultを content/ に設定し、以下を .obsidian/ で構成済みにした。
// app.json
{
"newFileFolderPath": "posts",
"attachmentFolderPath": "assets"
}
これでObsidianから新規ノート作成 → content/posts/ に保存、画像貼り付け → content/assets/ に保存、が自動で行われる。
Wikilinkの解決
ObsidianのWikilink記法([[ファイル名|表示テキスト]])をPython側で処理する。
ファイル名→slugのマップを作り、/posts/{slug}/ に変換する。
def _get_slug_map():
result = {}
for fp in glob.glob(os.path.join(POSTS_DIR, '*.md')):
p = frontmatter.load(fp)
fn = os.path.splitext(os.path.basename(fp))[0]
result[fn] = p.get('slug', fn)
return result
def convert_wikilinks(text, slug_map):
def repl_piped(m):
slug = slug_map.get(m.group(1).strip(), m.group(1).strip())
return f'[{m.group(2).strip()}](/posts/{slug}/)'
def repl_plain(m):
ref = m.group(1).strip()
slug = slug_map.get(ref, ref)
return f'[{ref}](/posts/{slug}/)'
text = re.sub(r'\[\[([^\]|]+)\|([^\]]+)\]\]', repl_piped, text)
text = re.sub(r'\[\[([^\]|]+)\]\]', repl_plain, text)
return text
ファイル名を日本語にしておくとObsidianのグラフビューに日本語タイトルが表示されて見やすい。URLはフロントマターの slug フィールドで制御する。
OGP画像の自動生成
Pillowを使ってビルド時に全記事分のOGP画像(1200×630px)を生成する。日本語フォントはmacOSのHiragino、Linux環境ではNoto CJKを自動検出する。
FONT_CANDIDATES = [
'/System/Library/Fonts/ヒラギノ角ゴシック W6.ttc',
'/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc',
'/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf',
]
def _find_font(size):
for path in FONT_CANDIDATES:
if os.path.exists(path):
return ImageFont.truetype(path, size)
return ImageFont.load_default()
Cloudflare PagesのビルドコンテナはLinuxなのでNoto CJKをインストールしておく必要がある。
# build command
apt-get install -y fonts-noto-cjk && pip install -r requirements.txt && python freeze.py
検索
クライアントサイドJSによる全文検索。ページロード時のパフォーマンスを落とさないため、JSONインデックスはボタンクリック時にlazy fetchする。
@app.route('/search-index.json')
def search_index():
return jsonify([{
'slug': p['slug'],
'title': p['title'],
'date': p['date'].isoformat() if p['date'] else '',
'tags': p['tags'],
'summary': p['summary'],
} for p in get_posts()])
button.addEventListener('click', () => {
if (index) { search(); return; }
fetch(indexUrl)
.then(r => r.json())
.then(data => { index = data; search(); });
});
Frozen-Flaskは /search-index.json もパラメータなしルートとして静的ファイル化してくれる。
記事の表示順
日付降順だけでなく、order フロントマターで明示的な順序を指定できるようにした。チュートリアル系記事に便利。
def get_posts(include_drafts=False):
...
return sorted(posts, key=lambda p: (
p['order'] if p['order'] is not None else 9999,
-(p['date'].toordinal() if p['date'] else 0)
))
Claude Codeカスタムスキル
.claude/skills/blog.md にプロジェクトローカルのスキルを定義する。/blog と入力するだけでClaude Codeが記事作成・一覧確認・ビルドなどを案内してくれる。
<!-- .claude/skills/blog.md の冒頭 -->
# ブログ管理スキル
このスキルが呼ばれたら、以下を確認してユーザーに提案せよ:
1. 新規投稿の作成
2. 下書き一覧の確認
...
フロントマター仕様
---
order: 1 # 表示順(省略時は日付降順で末尾)
title: 記事タイトル
slug: url-slug # URLに使用。省略時はファイル名
date: 2026-05-01 # YYYY-MM-DD(クォートなし)
tags:
- タグ名
summary: 概要文 # OGP・一覧・meta descriptionに使用
draft: false # trueで非公開
---
デプロイ設定(Cloudflare Pages)
| 項目 | 値 |
|---|---|
| Build command | apt-get install -y fonts-noto-cjk && pip install -r requirements.txt && python freeze.py |
| Build output directory | build |
環境変数 SITE_URL
|
https://<project>.pages.dev |
環境変数 SITE_NAME
|
ブログ名 |
mainブランチへのpushで自動デプロイ。
まとめ
- 書く環境: Obsidianのローカルファイル編集 + プロパティUI + グラフビュー
- ビルド: Flask-Frozenで静的HTML化、OGP画像も同時生成
- 運用コスト: 月額0円(GitHub + Cloudflare Pages無料枠)
- AI補助: Claude Codeのカスタムスキルで記事管理を自動化
テンプレートはMITライセンスで公開中です → https://github.com/pons-llc/obsidian_blog