Claude CodeでZenn記事を書く — textlintと公開前チェックをhookで回す
Zenn CLIのarticlesで記事をClaude Codeに書かせ、保存のたびにtextlintとfrontmatter・画像パスの点検をhookで回す設定です。published: trueは人が切り替える運用まで扱います。
Zenn記事は「ファイルを保存して、pushすると公開される」
Zenn CLIで管理する記事は、articlesディレクトリに置いたMarkdownファイル1本が1記事です。GitHubリポジトリをZennと連携しておき、ファイルをコミットしてpushすると、同期が始まって記事が公開されます。
この構成はClaude Codeと相性が良い反面、落とし穴もあります。Claudeはファイルを書いて直すだけなので、公開に関わる決まりごとは別に守らせる必要があります。公開設定、slug、画像のパスがその例です。
そこで役割を分けます。
- 本文の下書きと推敲はClaude Codeに任せる
- 日本語の表記ゆれと読みにくい文は、保存のたびにtextlintが検査する
- frontmatterと画像パスの決まりは、hookのスクリプトが検査する
published: trueへの切り替えとpushは人が行う
汎用のtextlint設定はClaude Codeでtextlintを使うにあります。ここではZenn固有の記法と公開条件に絞ります。ブログ全般の構成から校正までの流れはClaudeでブログ記事を作成・校正する手順が扱っています。
Zenn CLIを入れて、記事の雛形を作る
導入は、コンテンツを置きたいディレクトリでnpm initとnpm installを行い、npx zenn initで雛形を作る流れです。Node.js 14以上が必要とされています。
npm init --yes
npm install zenn-cli
npx zenn init
npx zenn new:article \
--slug claude-code-zenn-sample --title "サンプル" \
--type tech --emoji "📝"手元で実行すると、zenn --versionは0.2.3でした。zenn initはREADME.mdと.gitignoreのほか、articlesとbooksを作ります。生成された.gitignoreの中身はnode_modulesと.DS_Storeの2行です。
new:articleで作られた記事の先頭は次のとおりです。
---
title: "サンプル"
emoji: "📝"
type: "tech" # tech: 技術記事 / idea: アイデア
topics: []
published: false
---公式ガイドに載っているファイル例では、publishedの初期値はtrueです。手元の0.2.3が生成したのはfalseでした。バージョンで初期値が違う可能性があるため、生成直後に自分の目で確かめておくと安全です。npx zenn list:articlesで、slugとタイトルの一覧も出せます。
公開前に引っかかる決まりを表にする
Zennの公式ガイドから、Claudeに守らせたい決まりを拾うと次のとおりです。hookで機械的に確かめられるものと、人が判断するものに分けています。
| 決まり | 内容 | hookで検査 |
|---|---|---|
| slug | 内容a-z0-9、ハイフン、アンダースコアの12〜50字 | hookで検査できる |
| 画像の参照 | 内容/images/から始まる絶対パス。相対パスは不可 | hookで検査できる |
| 画像の置き場 | 内容リポジトリ直下の/images。3MB以内、拡張子はpng・jpg・jpeg・gif・webp | hookで検査一部できる |
| 公開設定 | 内容published: trueで公開、falseで下書き | hookで検査書き込みを止められる |
| 公開予約 | 内容published_atはYYYY-MM-DDかYYYY-MM-DD hh:mmでJST。一度設定すると変更できない | hookで検査人が判断 |
| 記事の更新 | 内容slugが同じでないと別記事として作られる | hookで検査人が判断 |
| 削除 | 内容ダッシュボードから行う。ファイルを消しても記事は消えない | hookで検査人が判断 |
slugはファイル名がそのままslugになります。記事を更新するつもりでファイル名を変えると、別の記事が作られます。Claudeに「ファイル名も整えて」と頼むと起きやすい事故です。
コミットメッセージに[ci skip]か[skip ci]を含めると、Zennでのデプロイはスキップされます。公開したつもりで反映されていないときは、メッセージとダッシュボードのデプロイ履歴を見ます。
画像の相対パスは、プレビューでも壊れて見える
画像はのように、/images/で始まる絶対パスで書きます。../images/example.pngのような相対パスは誤りだと明記されています。Claudeは相対パスで書きがちなので、後述のhookで止めます。
リポジトリから画像を消すと、Zenn上からも画像が消えます。記事から参照している画像は消さないようにします。
textlintをZenn向けに設定する
textlintとルールをローカルに入れます。Zenn CLIと同じpackage.jsonに入れて構いません。
npm install --save-dev textlint \
textlint-rule-preset-ja-technical-writing \
textlint-rule-prh \
textlint-filter-rule-allowlist手元の組み合わせは、textlint 15.8.0、preset-ja-technical-writing 12.0.2、prh 6.1.1、allowlist 4.0.0でした。
メッセージ記法の「:::」が句点エラーになる
Zennには:::messageや:::detailsといった独自記法があります。これをそのまま検査すると、記法の行が指摘されます。次のような記事で試しました。
:::message
ここはメッセージ記法のサーバです。
::::::の閉じ行が「文末が"。"で終わっていません」(ja-no-mixed-period)と指摘されました。開始行と本文の間に空行を入れた場合も、:::details 折りたたみの行が同じ指摘を受けました。記法の行は文ではないので、検査から外します。そのためのフィルタがallowlistです。
{
"filters": {
"allowlist": { "allow": ["/^:::.*$/m"] }
},
"rules": {
"preset-ja-technical-writing": {
"ja-no-weak-phrase": false,
"no-exclamation-question-mark": false
},
"prh": { "rulePaths": ["./prh.yml"] }
}
}この設定を.textlintrc.jsonに置くと、先ほどの2つの記事で:::の指摘は消えました。本文中のサーバはprhが引き続き指摘します。@[card](URL)の埋め込み記法は、この設定でも指摘されませんでした。
ja-no-weak-phraseとno-exclamation-question-markを切っているのは、Zennの技術記事で「〜かもしれない」や感嘆符を使う文体を許すためです。切る・残すは書き手の好みなので、自分の文体に合わせます。
prhの辞書は、チームで揃えたい表記を書きます。
version: 1
rules:
- expected: サーバー
pattern: /サーバ(?!ー)/
- expected: Claude Code
pattern: /ClaudeCode/hookで保存のたびに点検する
.claude/settings.jsonに、articles配下の編集後に走る2つのhookを置きます。どちらもPostToolUseで、Edit|Writeにマッチさせます。
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/zenn-textlint.sh",
"timeout": 60
},
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/zenn-check.sh"
}
]
}
]
}
}同じイベントに一致したhookは並列に走ります。2つは互いの結果に依存しない作りにしてあります。
textlintのhook
--fixで直せる分を書き換えてから、残りの指摘を終了コード2で返します。articlesとbooks配下のMarkdownだけを対象にします。
#!/bin/bash
# .claude/hooks/zenn-textlint.sh
file_path=$(jq -r '.tool_input.file_path // empty')
case "$file_path" in
"$CLAUDE_PROJECT_DIR"/articles/*.md|"$CLAUDE_PROJECT_DIR"/books/*/*.md) ;;
*) exit 0 ;;
esac
cd "$CLAUDE_PROJECT_DIR" || exit 1
npx --no-install textlint --fix "$file_path" >/dev/null 2>&1
output=$(npx --no-install textlint --format unix "$file_path" 2>&1)
if [ $? -ne 0 ]; then
echo "$output" | head -n 40 >&2
exit 2
fi
exit 0PostToolUseはツールが成功した後に走るため、終了コード2でも編集は取り消されません。それでもstderrはClaudeに届きます。終了コード0で出したstderrはClaudeに見えません。終了コードの扱いの全般はPostToolUse hookの解説にまとまっています。
frontmatterと画像パスのhook
slug、画像パス、必須キーを点検します。検査できるのは機械的な決まりだけで、本文の良し悪しは見ません。
#!/bin/bash
# .claude/hooks/zenn-check.sh
file_path=$(jq -r '.tool_input.file_path // empty')
case "$file_path" in
"$CLAUDE_PROJECT_DIR"/articles/*.md) ;;
*) exit 0 ;;
esac
errors=()
slug=$(basename "$file_path" .md)
if ! [[ "$slug" =~ ^[a-z0-9_-]{12,50}$ ]]; then
errors+=("slug(ファイル名)は a-z0-9 と - _ の12〜50字: $slug")
fi
img='!\[[^]]*\]\((\.\.?/|//)'
if grep -qE "$img" "$file_path"; then
errors+=("画像は /images/ から始まる絶対パスで指定: $(grep -nE "$img" "$file_path" | head -3 | tr '\n' ' ')")
fi
head -n 1 "$file_path" | grep -qx -- '---' \
|| errors+=("先頭行が --- ではありません")
for key in title emoji type topics published; do
sed -n '2,/^---$/p' "$file_path" | grep -q "^$key:" \
|| errors+=("frontmatter に $key がありません")
done
if [ ${#errors[@]} -gt 0 ]; then
printf '%s\n' "${errors[@]}" >&2
exit 2
fi
exit 0手元での動作確認
Claude Codeが渡す形のJSONを標準入力に流して、スクリプトを直接動かしました。../images/a.pngを含む記事では、次のように返ります。
export CLAUDE_PROJECT_DIR=$PWD
echo '{"tool_input":{"file_path":"'$PWD'/articles/bad-example-article-01.md"}}' \
| .claude/hooks/zenn-check.sh
echo "exit=$?"出力は画像は /images/ から始まる絶対パスで指定: 21:の1行で、終了コードは2でした。new:articleで作った記事を渡すと何も出ず0です。textlintのhookは、サーバとClaudeCodeを含む記事をサーバーとClaude Codeに書き換え、残る指摘がなければ0で終わりました。package.jsonを渡したときは何もせず0です。
published: trueは人が切り替える
公開はpublished: trueで決まります。Claudeに任せたくないのは、下書きのつもりの記事が、pushと同時に公開されてしまう事故です。そこでPreToolUseのhookで、published: trueを書き込む編集を止めます。
#!/bin/bash
# .claude/hooks/zenn-no-publish.sh
input=$(cat)
file_path=$(jq -r '.tool_input.file_path // empty' <<<"$input")
case "$file_path" in
"$CLAUDE_PROJECT_DIR"/articles/*.md) ;;
*) exit 0 ;;
esac
text=$(jq -r '.tool_input.new_string // .tool_input.content // empty' <<<"$input")
if grep -qE '^published:[[:space:]]*true' <<<"$text"; then
echo "published: true への変更は人が行います。false のまま保存してください" >&2
exit 2
fi
exit 0settings.jsonには、PreToolUseのEdit|Writeに登録します。PreToolUseは、ツール呼び出しの前に走り、終了コード2でその呼び出しを止められます。Editならnew_string、Writeならcontentに、書き込む文字列が入ります。許可・拒否・改変の使い分けはPreToolUse hookの解説を参照してください。
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/zenn-no-publish.sh"
}
]
}
]
},
"permissions": {
"deny": ["Bash(git push *)"]
}
}手元でpublished: falseをpublished: trueに変える編集のJSONを流すと、メッセージが出て終了コードは2でした。published: falseへの編集は0です。
pushをdenyに入れたのは、同期の引き金がpushだからです。denyルールの書き方は、Bash(git push *)の形が公式の例にそのまま載っています。人が手元でpublished: trueにして、自分でpushします。
公開予約には取り返しのつかない面がある
published: trueにpublished_atを足すと、未来の日時で公開予約になります。過去の日時を入れると、他のブログから移した記事の公開日時を保てます。どちらも、公開日時の指定は一度しかできず、設定済みの値は変更できません。Claudeに日時を入れさせると取り消せないので、published_atは人が書く欄に残します。
書かせるときの指示はCLAUDE.mdに置く
hookは違反を止めますが、最初から守らせるほうがやり直しが減ります。次のような断片をリポジトリのCLAUDE.mdに置きます。
## Zenn記事のルール
- 記事は `articles/` に、ファイル名(slug)を小文字・数字・ハイフンの12字以上で作る
- 新規記事は `npx zenn new:article` で作り、`published: false` のまま編集する
- 画像は `/images/<slug>/` に置き、本文では `/images/` から始まる絶対パスで参照する
- `published` と `published_at` は変更しない
- 保存後にtextlintの指摘が返ったら、同じファイルの該当箇所だけを直すCLAUDE.mdはClaudeが読む文脈であり、守られる保証はありません。hookと組み合わせる前提で使います。
公開前の流れとつまずき
Zenn記事を公開するまで
- 1
雛形を作る
npx zenn new:articleで作り、published: falseのままClaudeに本文を書かせます。 - 2
hookの指摘を直す
保存のたびに、textlintとfrontmatter・画像パスの点検が走ります。返った指摘をClaudeに直させます。
- 3
プレビューで確かめる
npx zenn previewで表示を確認します。既定のポートは8000で、--portで変えられます。 - 4
人が公開に切り替える
published: trueにして、コミットしてpushします。デプロイの結果はダッシュボードで見ます。
Bashで書き換えた記事は、hookが走らない
PostToolUseのEdit|Writeは、Bashのコマンドや外部のプロセスが同じファイルを書き換えたときには走りません。Claudeにsedで一括置換させると、検査をすり抜けます。ファイルの変更を契機にしたいときは、FileChangedという別のイベントが用意されています。一括置換もファイル編集ツールで行わせる運用にします。
本の章は対象外にしている
上のhookはarticlesの記事を主に想定しています。本のチャプターはslugの規則が1〜50字と異なり、config.yamlのchaptersに載らない章は同期されません。textlintはbooks/*/*.mdに効きますが、zenn-check.shは適用していません。本を書くときは、章ごとのfrontmatterとconfig.yamlの点検を別に足します。
プレビューで直った画像が、公開後に出ない
画像を差し替えた場合、デプロイ完了から最大1分ほど、古い画像が表示されることがあります。時間を置いてリロードします。画像は3MB以内で、拡張子はpng・jpg・jpeg・gif・webpに限られ、違反するとデプロイ時にエラーになります。
まとめ
Zenn記事をClaude Codeで書かせるときの要は、「書かせる範囲」と「人が決める範囲」を線引きすることです。本文と校正はhookの助けを借りてClaudeに任せ、publishedとpublished_at、pushは人の手に残します。
先に入れる順番は、textlintの:::対応、published: trueの書き込み止め、画像パスの点検です。この3つで、公開直前に気づく事故の多くは、保存の時点で止まります。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。