Claude Media
Claude CodeでZenn記事を書く — textlintと公開前チェックをhookで回す

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・webphookで検査一部できる
公開設定内容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/example-article-1/image1.png)のように、/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 0

PostToolUseはツールが成功した後に走るため、終了コード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:![図](../images/a.png)の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 0

settings.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. 1

    雛形を作る

    npx zenn new:articleで作り、published: falseのままClaudeに本文を書かせます。

  2. 2

    hookの指摘を直す

    保存のたびに、textlintとfrontmatter・画像パスの点検が走ります。返った指摘をClaudeに直させます。

  3. 3

    プレビューで確かめる

    npx zenn previewで表示を確認します。既定のポートは8000で、--portで変えられます。

  4. 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つで、公開直前に気づく事故の多くは、保存の時点で止まります。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. code.claude.com/docs/en/hooks
  2. code.claude.com/docs/en/permissions
  3. zenn.dev/zenn/articles/zenn-cli-guide
  4. zenn.dev/zenn/articles/install-zenn-cli
  5. zenn.dev/zenn/articles/deploy-github-images
この記事を共有:XはてブLinkedIn