Publish html to pages
npx -y skills add ist-j-ichikawa/skills --skill publish-html-to-pagesAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
単体 HTML ドキュメントを、実行リポジトリの GitHub Pages 公開ブランチ (`review` / `gh-pages` 等、Pages 設定で決まる) 経由で GitHub Pages に公開する skill。push を伴う外向き操作のため自動起動はせず、ユーザーが明示的に `/publish-html-to-pages` を呼んだときだけ動く。公開ブランチと URL は `gh api .../pages` から動的取得し、対象 HTML 1 ファイルを別 worktree から commit + push する。push のたびに公開ブランチ上の全 HTML を走査してルートの `index.html` (公開済みドキュメントへの動線=ランディングページ) を自動再生成し、同じ commit に含める。本リポの worktree や他ブランチの history は触らない。
The file declares its own license as MIT. That is the author’s claim about this one file, and it is not the same thing as the license GitHub reports for the repository, which is listed with the other numbers below.
SKILL.md
43.9 KB, as published. Nobody here has run it
publish-html-to-pages
単体 HTML ドキュメント (レビュー資料 / 設計書 HTML 等) を、実行リポジトリの GitHub Pages 公開ブランチ経由で Pages に公開する skill。対象 HTML を 1 ファイルだけ 足したうえで、公開ブランチ上の全 HTML を走査してルートの index.html (公開済みドキュメント一覧=ランディングページ) を再生成し、同じ commit で push する。
push という外向き・実質不可逆な操作を含むため、モデルからの自動起動は無効化してある (disable-model-invocation: true)。ユーザーが /publish-html-to-pages を明示的に呼んだときだけ動く。
公開ブランチ名 (review 等) も公開 URL も リポジトリの Pages 設定で決まるため、ハードコードせず gh api repos/{owner}/{repo}/pages から実行時に取得する。
動機
Pages 公開ブランチ (Pages 専用、history 持ち込み禁止で運用されることが多い) に対象 HTML だけを足して push すると、自動で Pages の URL で見られる。だが手順を毎回手で再現すると以下を踏みやすい:
- main の history を公開ブランチに巻き込んでしまう (Pages 専用ブランチが汚れる)
- 関係ないファイルを
git add .で巻き込む - 進行中の別ブランチを公開ブランチの checkout で巻き戻してしまう
- 権限のない gh アカウントがアクティブなまま push して 403 (private リポや org 制約のある環境)
- ローカルの公開ブランチが古いまま push して non-fast-forward で弾かれる
この skill は、公開ブランチを本リポの外 (/tmp) の専用 worktree に隔離して「対象 HTML 1 ファイルだけを add する」フローに固定することで、これらを手順として踏まないようにする。worktree 操作を git -C "$WT" に固める運用でも同じ効果は出せるが、本 skill では誤爆を一段防ぐため EnterWorktree(path=...) で session ごと worktree に閉じ込める (§4 参照)。
また、HTML を 1 枚ずつ公開してもそれらを横断する入口 (動線) がないと、URL を都度共有しないと辿れない。そこで push のたびに公開ブランチ上の全 HTML を列挙し、ルートの index.html を「公開済みドキュメント一覧」として再生成する (§7)。これで Pages のルート URL がそのままランディングページになり、過去に publish した資料へも index から辿れる。
引数
- 必須: 公開したい HTML ファイル path (絶対 / 相対どちらでも可)
- 任意: commit message (省略時は
publish: <basename>)
前提
- CWD が対象リポジトリ内であること (
git rev-parse --show-toplevelで確認) - 本リポのメイン作業ツリーから起動すること。既に別の worktree session 内にいる場合、§4 の
EnterWorktree(path=/tmp/...)は.claude/worktrees/配下でない path を受け付けないため弾かれる。その場合は先にExitWorktreeでメインに戻ってから起動する (§ハマりどころ参照) - 対象リポジトリで GitHub Pages が有効化済みで、公開元がブランチ (legacy/branch ソース) であること。GitHub Actions ソースでは使えない (§1・§ハマりどころ参照)
ghCLI: 無ければ公式の方法で入れる (command -v ghで確認し、無ければbrew install gh。公式: https://cli.github.com)。対象リポに push 権限のあるアカウントでログイン済みであること (未ログインならgh auth loginを案内)- 実行環境が Claude Code であり
EnterWorktree/ExitWorktreetool が使えること (session を worktree 内に切り替える前提のため。CLI 単発実行には不向き)
ワークフロー
1. 入力検証 (ローカルのみ、ネットワーク不要)
入力 HTML を検証する:
- 引数の HTML path を解決し、絶対 path に正規化する (
realpath)。以降SRCとして使う - ファイルが存在しなければ即エラーで終了 (worktree も切らない)
- 拡張子が
.htmlでなければ即エラー。GitHub Pages 自体は.htmも配信するが、公開 URL を<basename>で素直に組むため本 skill は.htmlのみ受け付ける basename(例:2026-05-28-foo.html) を抽出しておく。index.htmlは予約名なので、対象ファイルの basename がindex.htmlだったら「ランディングページ用に自動生成する名前なので、別名にしてください」と案内して止める (§7 で再生成する index と衝突するため)
2. gh アクティブアカウント確認
private リポや org 制約のある環境では、対象リポに push 権限のあるアカウントがアクティブでないと、この後の Pages 設定取得 (§3) すら 404/403 で滑ったり、最終的な push が 403 になる。ネットワークを叩く §3 より前にここで確認しておく。
gh auth status --active
active が対象リポに push できるアカウントでなければ switch する。
gh auth status # 全アカウントを列挙
gh auth switch -u <対象リポに push 権限のあるアカウント>
どのアカウントを使うかが個人 CLAUDE.md / メモリで「この org ならこのアカウント」と決まっていればそれに従う。判断に迷ったらそのまま進めて、§3 や §8 が 403/404 で落ちたら switch して再試行する。
3. Pages 設定の取得
実行リポジトリの Pages 設定を取得する ({owner}/{repo} は gh が現リポに自動解決する):
gh api repos/{owner}/{repo}/pages \
--jq '{branch: .source.branch, path: .source.path, html_url: .html_url, public: .public, build_type: .build_type}'
ここから変数を確定する:
PAGES_BRANCH=.source.branch(例:review、gh-pages等)。以降このブランチに push するPAGES_BASE_URL=.html_url(例:https://xxxx.pages.github.io/)。公開 URL のベース。末尾/だけ叩けばランディング (§7 の index.html) が開く
使えないケースは先に弾く:
build_typeがworkflowの場合 → 公開元が「GitHub Actions」なので、ブランチに push しても公開されない。この skill は対象外。「Actions ソースの Pages なのでこの skill は使えない」と案内して止める (誤ったブランチに push しない)。build_typeがlegacyのときだけ続行する404/ 取得失敗 → 「Pages が未設定か、auth (§2) のアカウントに権限がない」と案内して終了.source.branchがnull/ 空 → ブランチ公開でない。やはり止める
.source.path が / でない (例: /docs) 場合は、その配下に置く必要がある (§5)。public: false なら private Pages (閲覧にログインが要る)。
なぜ
build_typeで判定するか: 「Actions ソースだとsource.branchがnullになる」のは観測的には起きるが GitHub の公開 schema には明記がない (schema 上 branch は required 扱い)。文書化されたbuild_type(legacy/workflow) を一次判定に使い、source.branchの有無は念のための二次チェックにしている。
4. 公開ブランチの worktree を用意して session を切り替える
PAGES_BRANCH を どの worktree でチェックアウトするかを最初に決める。実運用では、公開ブランチが既に別の worktree (例: .claude/worktrees/review) で常設されていることがある (レビュー用に開きっぱなしのリポなど)。その状態で自分用に git worktree add しようとすると '<branch>' is already used by worktree '...' で必ず失敗するので、先に既存を調べて分岐する。
# 公開ブランチを既にチェックアウトしている worktree がないか調べる
EXIST_WT=$(git worktree list --porcelain | awk -v b="refs/heads/$PAGES_BRANCH" '
/^worktree /{p=$2} /^branch /{if($2==b) print p}')
ケース A: 既存 worktree が $PAGES_BRANCH を握っている (EXIST_WT が非空)
それを 再利用する (自分で add すると衝突するため。また、他用途で常設された worktree を勝手に消さない方針に従う)。ただし他作業を壊さないよう、使う前に clean かつ origin と同期を確認する。
WT="$EXIST_WT"
git -C "$WT" fetch origin "$PAGES_BRANCH"
# 未コミットの変更があれば触らない (他の作業中かもしれない)
test -z "$(git -C "$WT" status --porcelain)" || { echo "既存 worktree $WT に未コミット変更あり。中断。"; exit 1; }
# origin より遅れている分だけ前進。乖離 (非 fast-forward) なら勝手に巻き戻さず中断
git -C "$WT" merge --ff-only "origin/$PAGES_BRANCH"
OWN_WT=0 (自分が作った worktree ではない) として覚えておき、§10 では 消さない。
ケース B: どの worktree も握っていない (EXIST_WT が空)
従来どおり自分用の worktree を /tmp に切り、最新化する。
# realpath で実体パスに正規化 (macOS の /tmp→/private/tmp 対策、理由は下)。
# タイムスタンプで必ずユニーク名にする (同時実行や残骸で衝突しないため)。
WT="$(realpath /tmp)/pages-publish-$(date +%s)"
git fetch origin "$PAGES_BRANCH"
# -B で local PAGES_BRANCH を origin の最新に揃えてチェックアウトするので、
# ローカルが古いまま push して non-fast-forward で弾かれる事故を防げる。
# 公開ブランチは「この skill が push するだけ」の前提なので、未 push 差分を捨てて困らない。
git worktree add -B "$PAGES_BRANCH" "$WT" "origin/$PAGES_BRANCH"
OWN_WT=1 として覚えておき、§10 で git worktree remove する。
なぜ
realpath /tmpか: macOS では/tmpは/private/tmpへの symlink で、git worktree add /tmp/fooしてもgit worktree listには/private/tmp/fooで登録される。EnterWorktree(path=...)は「渡した path がgit worktree listに載っていること」を要求するため、/tmp/...のまま渡すとパス表記の食い違いで弾かれうる。最初からrealpathで実体パスに寄せておけば、add・EnterWorktree・remove の全段で表記が揃う (Linux では/tmpは symlink でないので realpath は無害)。
その後、Claude Code の EnterWorktree tool を path モード で呼んで session を切り替える。
EnterWorktree(path=$WT)で session の CWD が worktree 内に移る- これにより以降のコマンドが本リポを指して暴れる事故を構造的に防げる
ケース A の既存 worktree は .claude/worktrees/ 配下なら既に worktree session 内にいても EnterWorktree(path=...) で入れる。ケース B の /tmp worktree は .claude/worktrees/ 配下でないため、既に別の worktree session 内から呼ばれた場合は EnterWorktree(path=/tmp/...) が弾かれる (§前提・§ハマりどころ)。なお、すべてのコマンドを git -C "$WT" で明示的に worktree 先に向ける運用でも代替できる (tool 必須ではない)。EnterWorktree が使えない状況ではこの git -C "$WT" フォールバックに切り替える。
session 切替直後に pwd と git branch --show-current が $PAGES_BRANCH になっていることを必ず確認する。違っていたら ExitWorktree で session を戻し、git worktree remove --force で片付けてエラー終了。
5. 対象 HTML を worktree 直下にコピー
worktree 直下 (リポルート) に <basename> でコピーする。
cp -- "$SRC" "./<basename>" # SRC は §1 で絶対化した入力 path
サブディレクトリに置かない (公開 URL を <PAGES_BASE_URL><basename> に揃えるため)。Pages の source.path が /docs 等の場合のみ、その配下 (例 ./docs/<basename>) に置く。/docs 公開でも配信 URL はルート扱い (URL に /docs は付かない) なので、公開 URL の組み方 (§9) は変わらない。
以降、index も対象 HTML も置く「公開ディレクトリ」を PUB_DIR と呼ぶ (source.path が / なら .、/docs なら ./docs)。
6. 上書き告知 (同名ファイルが既にあるとき)
「対象ファイルが既に公開ブランチに追跡されているか」で上書きかどうかを判定する。git add (§8) の 前に走るので、git ls-files で素直に見るのが確実。
if git ls-files --error-unmatch -- "<basename>" >/dev/null 2>&1; then
echo "上書き: <basename> は既に $PAGES_BRANCH にあり、今回の push で上書きされます"
fi
なぜ
git status --porcelainの文字コードで判定しないか: porcelain の 2 文字はXY(X=staged 側 / Y=worktree 側)。§8 のgit addより前の時点では、既存ファイルをcpで上書きしただけの状態はM(先頭スペース + M = 未 staged の modified)、新規ファイルは??で出る。M(M + スペース = staged 済み modified) とは位置が逆なので、Mを照合すると上書きケースを取りこぼし、告知が出ないまま黙って上書き push される。git ls-files --error-unmatchは「追跡対象に既にあるか」を直接見るので、コード位置やタイミングに依存しない。
- 上書きに当たるとき: 黙って push せず、
AskUserQuestionで一拍置いて確認する (例: 「<basename>は既に公開ブランチ$PAGES_BRANCHにあり、今回の push で上書きされます。続けますか?」)。上書き自体は Pages 的には想定内の挙動だが、既存資料を差し替える向きの操作なので明示同意を取る。止められたらここで終了し、§10 の後始末 (worktree 片付け) へ進む。 - 新規 (未追跡) のとき: 確認は挟まずそのまま続行する (新しく足すだけで既存を壊さないため)。
確認の対象はユーザーの対象 HTML だけ。§7 で再生成する
index.htmlは毎回上書きされるが、これは「動線を最新に保つ」ための想定内の挙動なので確認は挟まない (毎回確認を出すとノイズになる)。
7. index.html (ランディングページ) を階層生成
公開ディレクトリを走査し、root index と 各サブディレクトリの index を生成する。
root は「直下 HTML + サブディレクトリをフォルダ集約」、各サブは「配下 HTML 一覧 + トップへの戻り」。
§5 で対象 HTML を cp 済みなので新規公開分も列挙対象に入る (git 追跡前でもファイルとして存在するため find で列挙)。
cd "$PUB_DIR" # 公開ディレクトリ直下で作業 (source.path が /docs ならその中)
INDEX="index.html"
# --- バージョンガード ---------------------------------------------------
# 複数人が別々の skill バージョンで publish すると、旧版が新版の生成した index を
# まるごと上書き (ダウングレード) してしまい、誰が最後に叩いたかで品質が振れる。
# それを防ぐため、生成テンプレに版番号 (TPL_VER) を持たせ、既存の root index がこの skill より
# 新しければ「上書きせず中断」して skills update を促す。
# テンプレ (<head>/見出し/favicon/一覧の作り) を実質的に変えたら TPL_VER を 1 つ上げること。
# tpl=2: フラットリスト → 階層 index (root はサブディレクトリをフォルダ集約)、.fname 表示を廃止。
TPL_VER=2
EXIST_VER=0
if [ -f "$INDEX" ]; then
EXIST_VER=$(sed -n 's/.*name="generator"[^>]*tpl=\([0-9][0-9]*\).*/\1/p' "$INDEX" | head -1)
[ -z "$EXIST_VER" ] && EXIST_VER=0
fi
if [ "$EXIST_VER" -gt "$TPL_VER" ]; then
echo "中断: 既存 index.html はより新しい publish-html-to-pages (tpl=$EXIST_VER) で生成済み。" >&2
echo " この skill は tpl=$TPL_VER (旧版)。'npx skills update publish-html-to-pages -g' で" >&2
echo " 更新してから publish し直すこと (旧版で上書きするとランディングが劣化する)。" >&2
# ここで publish 全体を中断する。commit/push はせず、§10 の後始末 (ExitWorktree +
# 自分が作った worktree の削除) だけ行って終了する (対象 HTML の追加も今回は見送る)。
exit 1
fi
# 既存 ≤ 自分 / 既存なし / 版番号無し (ガード前の旧 index) は EXIST_VER=0 扱いで続行 →
# このまま再生成すれば新版テンプレで index がアップグレードされる。
# -----------------------------------------------------------------------
# リポジトリ情報を取得する。favicon (色・頭文字) とヘッダ (repo 名/リンク/公開ブランチ) の両方で使う。
# remote URL から owner/repo をオフライン抽出 (https/ssh 両形式)。WEB は GitHub の web URL
# (ssh 形式は https に変換)。SLUG=owner/repo, REPO=repo 名。
URL=$(git remote get-url origin 2>/dev/null); URL=${URL%.git}
REPO=${URL##*/}; REST=${URL%/*}; OWNER=${REST##*[:/]}; SLUG="$OWNER/$REPO"
[ -z "$REPO" ] && { SLUG=$(basename "$(git rev-parse --show-toplevel)"); REPO="$SLUG"; } # remote 無しの保険
case "$URL" in
git@*) WEB="https://$(printf '%s' "$URL" | sed -e 's#^git@##' -e 's#:#/#')" ;;
ssh://git@*) WEB="https://$(printf '%s' "$URL" | sed -e 's#^ssh://git@##' -e 's#:#/#')" ;;
*) WEB="$URL" ;;
esac
# favicon を「リポジトリ (owner/repo) から決定的に」生成する。Chrome のタブに複数 Pages が
# 並んでも色と頭文字で見分けられるように。色相は cksum ハッシュ、文字は repo 名の頭文字モノグラム。
# 決定的 (同じリポは常に同色・同文字) なので再生成しても index.html に余計な差分は出ない。
HUE=$(printf '%s' "$SLUG" | cksum | awk '{print $1 % 360}')
MONO=$(printf '%s' "${SLUG##*/}" | tr '/_.-' ' ' | awk '{ if (NF>=2) print toupper(substr($1,1,1) substr($2,1,1)); else print toupper(substr($1,1,2)) }')
# data URI は jq @uri で丸ごと percent-encode (hsl() の % やスペースが URL を壊さないように)。
SVG='<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect width="100" height="100" rx="18" fill="hsl('"$HUE"',60%,45%)"/><text x="50" y="50" dy=".34em" text-anchor="middle" font-family="-apple-system,Segoe UI,Helvetica,sans-serif" font-weight="700" font-size="52" fill="#ffffff">'"$MONO"'</text></svg>'
ICON="data:image/svg+xml,$(jq -rn --arg s "$SVG" '$s|@uri')"
# ---- 共通ヘルパ (root とサブ index で使い回す) ----
esc_html() { printf '%s' "$1" | sed 's/&/\&/g; s/</\</g; s/>/\>/g'; }
# パスは / で分割し各セグメントを percent-encode → / で再結合 (サブディレクトリ対応)。
# jq @uri を丸ごとかけると / が %2F に潰れて日本語ディレクトリ名のリンクが壊れるため。
enc_path() { jq -rn --arg s "$1" '$s | split("/") | map(@uri) | join("/")'; }
get_title() { # <title> 抽出 (なければファイル名)。BSD/GNU 両対応 ([Tt] 文字クラス、I フラグ不使用)
local f="$1" t
t=$(tr '\n' ' ' < "$f" | sed -n 's/.*<[Tt][Ii][Tt][Ll][Ee][^>]*>\(.*\)<\/[Tt][Ii][Tt][Ll][Ee]>.*/\1/p' | head -1)
t=$(printf '%s' "$t" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')
[ -z "$t" ] && t="$(basename "$f")"
printf '%s' "$t"
}
emit_head() { # 引数: ページタイトル (生テキスト)。<head> 〜 <h1> まで出す
local t_ptitle; t_ptitle=$(esc_html "$1")
cat <<'HEAD'
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
HEAD
# <title> / favicon / 版番号は変数を含むので、クォート heredoc の外で printf する。
printf '<title>%s</title>\n' "$t_ptitle"
printf '<link rel="icon" href="%s">\n' "$ICON"
# 版番号スタンプ。次回 publish のバージョンガードが root index のこれを読む (machine-readable)。
printf '<meta name="generator" content="publish-html-to-pages tpl=%s">\n' "$TPL_VER"
cat <<'HEAD2'
<style>
:root { color-scheme: light dark; }
body { font-family: system-ui, -apple-system, "Hiragino Sans", "Noto Sans JP", sans-serif;
max-width: 760px; margin: 3rem auto; padding: 0 1.2rem; line-height: 1.6; }
h1 { font-size: 1.5rem; margin-bottom: .2rem; }
.meta { font-size: .85rem; opacity: .75; margin: 0 0 1.6rem; padding-bottom: .8rem;
border-bottom: 1px solid currentColor; }
.meta a { color: inherit; }
.meta code { font-size: .9em; }
ul.docs { list-style: none; padding: 0; margin: 0; }
ul.docs li { padding: .6rem 0; border-bottom: 1px solid color-mix(in srgb, currentColor 15%, transparent); }
ul.docs a { text-decoration: none; font-size: 1.05rem; }
ul.docs a:hover { text-decoration: underline; }
ul.docs li.folder a::before { content: "📁 "; }
ul.docs li.folder { font-weight: 600; }
.count { font-size: .8rem; opacity: .6; font-weight: 400; }
footer { margin-top: 2rem; font-size: .8rem; opacity: .6; }
</style>
</head>
<body>
HEAD2
printf '<h1>%s</h1>\n' "$t_ptitle"
}
emit_foot() {
printf '<footer>このページは publish-html-to-pages skill が公開のたびに自動生成しています。</footer>\n</body>\n</html>\n'
}
# ---- 各サブディレクトリ (1 階層) の index を生成 ----
# 直下に *.html を持つサブディレクトリだけ index を作る (空ディレクトリはスキップ)。
# 直下にサブディレクトリが無いと `*/` がリテラルのまま回るが、find が空を返すので continue で抜ける。
for dir in */; do
dir=${dir%/}
subfiles=$(find "$dir" -maxdepth 1 -name '*.html' ! -name 'index.html' | sed 's#^\./##' | LC_ALL=C sort -r || true)
[ -z "$subfiles" ] && continue
{
emit_head "$dir"
printf '<p class="meta"><a href="../">← %s の Pages トップ</a></p>\n' "$(esc_html "$REPO")"
printf '<ul class="docs">\n'
printf '%s\n' "$subfiles" | while IFS= read -r f; do
[ -z "$f" ] && continue
base=$(basename "$f")
printf ' <li><a href="%s">%s</a></li>\n' "$(enc_path "$base")" "$(esc_html "$(get_title "$f")")"
done
printf '</ul>\n'
emit_foot
} > "$dir/index.html"
done
# ---- root index を生成 ----
# 直下 HTML を 1 ブロック、サブディレクトリを「📁 フォルダ (N 件)」の集約ブロックとして出す。
# 配下一覧は各サブ index に委ね、root には path/file.html を混ぜない。
ROOT_FILES=$(find . -maxdepth 1 -name '*.html' ! -name 'index.html' | sed 's#^\./##' | LC_ALL=C sort -r || true)
ROOT_DIRS=$(find . -maxdepth 1 -type d ! -name '.' ! -path './.*' | sed 's#^\./##' | LC_ALL=C sort || true)
{
emit_head "$REPO の Pages"
# メタ行 (repo リンク + 公開ブランチリンク)。$PAGES_BRANCH は §3 で確定済み。
printf '<p class="meta"><a href="%s">%s</a> ・ 公開ブランチ <a href="%s/tree/%s"><code>%s</code></a></p>\n' "$WEB" "$(esc_html "$SLUG")" "$WEB" "$PAGES_BRANCH" "$(esc_html "$PAGES_BRANCH")"
if [ -n "$ROOT_FILES" ]; then
printf '<ul class="docs">\n'
printf '%s\n' "$ROOT_FILES" | while IFS= read -r f; do
[ -z "$f" ] && continue
printf ' <li><a href="%s">%s</a></li>\n' "$(enc_path "$f")" "$(esc_html "$(get_title "$f")")"
done
printf '</ul>\n'
fi
if [ -n "$ROOT_DIRS" ]; then
printf '<ul class="docs">\n'
printf '%s\n' "$ROOT_DIRS" | while IFS= read -r d; do
[ -z "$d" ] && continue
n=$(find "$d" -maxdepth 1 -name '*.html' ! -name 'index.html' | wc -l | tr -d ' ')
[ "$n" = "0" ] && continue # 配下に HTML が無いディレクトリはフォルダ行に出さない
printf ' <li class="folder"><a href="%s/">%s <span class="count">(%s 件)</span></a></li>\n' "$(enc_path "$d")" "$(esc_html "$d")" "$n"
done
printf '</ul>\n'
fi
emit_foot
} > index.html
cd - >/dev/null # 元の worktree ルートに戻る (§8 の git add を相対パスで揃えるため)
設計メモ:
- 階層 index: 公開ディレクトリ直下に加え、サブディレクトリ (1 階層) ごとに index.html を生成する。root はサブディレクトリを「📁 フォルダ (N 件)」として集約し、配下一覧はサブ index に委ねる。深い階層 (2 階層以上) は対象外 (運用上 1 階層で足りる)。
- パスエンコードはセグメント単位。
jq @uriを丸ごとかけると/が%2Fに潰れてサブディレクトリリンクが壊れる。split("/") | map(@uri) | join("/")で各セグメントだけエンコードする。- ファイル名 (.fname) は表示しない。
<title>で内容が分かるため冗長。タイトルが取れない HTML は get_title が basename にフォールバックするので情報は失われない。- バージョンガード (TPL_VER)。index は publish のたびにテンプレごと再生成されるので、複数人が別々の skill バージョンで publish すると、旧版が新版の生成した index を上書きしてランディングの品質が振れる。これを防ぐため生成 index に
<meta name="generator" content="publish-html-to-pages tpl=N">を埋め、再生成前に既存 index のtpl=を読む。既存 > 自分なら上書きせず中断してskills updateを促す (旧版が新版をダウングレードできない)。既存 ≤ 自分・スタンプ無し (ガード前の旧 index) は続行し、新版なら index がアップグレードされる。テンプレ (<head>/見出し/favicon/一覧の作り) を実質的に変えたらTPL_VERを 1 つ上げること (上げ忘れると新テンプレが旧として扱われ、上書き合戦が起きる)。- 列挙はファイルシステム (
find) で行う。§5 でcpしたばかりの新規 HTML はまだ git に追跡されていないので、git ls-filesだと初回公開分が一覧から漏れる。「今ある HTML 全部」を素直に見るためファイルを列挙する。<title>抽出は素朴版。trで改行を潰すので複数行に跨る title も拾えるが、1 ファイルに<title>が複数ある異常系では最後までを greedy に拾う。実害が出るような HTML はまず無いので、その場合は basename フォールバックに任せる。- エスケープの順序は
&が先。元 title に既に&等の実体参照が入っていると二重エスケープ (&amp;) になりうるが、未エスケープの<>を流して一覧 HTML を壊すよりは安全側に倒している。- 見た目は外部依存ゼロのインライン CSSに固定 (再現性重視。CDN もフォント読み込みもしない)。凝った装飾は意図的に避けている。
- 見出しは
<repo> の Pages、その下にメタ行としてowner/repo(GitHub リポへのリンク) と公開ブランチ (<WEB>/tree/<branch>へのリンク) を出す。「公開ドキュメント」のような汎用見出しは使わない (どのリポの Pages か一目で分かるようにするため)。ブラウザのタブ名 (<title>) も同じ<repo> の Pagesにして favicon と合わせて識別性を上げる。repo の web URL は remote (origin) から導出し、ssh 形式 (git@host:owner/repo) は https に変換する。$PAGES_BRANCHは §3 で確定した公開ブランチをそのまま使う。- favicon は「リポジトリ (owner/repo) から決定的に生成した SVG」を data URI で埋め込む。固定の絵文字だと複数リポの Pages を Chrome のタブに並べたとき全部同じ顔で見分けられない (html-output skill が問題視する「識別不能なファビコン」)。そこで色相を
owner/repoのcksumハッシュから、文字を repo 名の頭文字モノグラム (skills→SK) から作り、リポごとに違う色・文字にしてタブで判別できるようにする。owner/repoをハッシュ元にするので別 org の同名リポも色が分かれる。決定的(同じリポは常に同じ favicon)なので、毎 push で index.html を再生成しても favicon 由来の差分は出ない。別ファイルを push せず 1 行で済むのも利点。配色や形を変えたいときはSVG=...の組み立て (hsl の彩度・明度、rx角丸、font 等) を調整する。
8. commit + push
git add は 対象 HTML + 影響を受けた index.html だけ。git add . / git add -A は禁止 (公開ブランチに無関係なファイルを混ぜないため)。add 対象は最大 3 つ:
- 対象 HTML 本体 (
<dir>/<basename>または<basename>) - root の
index.html(常に再生成される) - 対象 HTML をサブディレクトリに置いた場合のみ、そのサブの
<dir>/index.html
PUB_DIR が /docs 等なら、いずれも docs/ prefix を付けた相対パスで add する。
push の直前に、どこへ publish するかを一言告げる ($PAGES_BRANCH はリポによって review / gh-pages / main 等まちまちなので、ユーザーが意図と違うブランチへの push に気付けるように)。例: 「公開ブランチ review に <basename> と index.html を push します」。Pages の公開元は GitHub 側で 1 つに確定しているため選択肢を出す必要はないが、確定先を明示してから進む。
# サブディレクトリ <dir>/ に置いた場合 (root index + サブ index の両方が再生成される)
git add -- "<dir>/<basename>" "<dir>/index.html" "index.html"
# 直下に置いた場合 (従来どおり 2 ファイル)
git add -- "<basename>" "index.html"
git -c commit.gpgsign=false commit -m "<commit message>"
git push origin "$PAGES_BRANCH"
⚠️ §7 は全サブディレクトリの index を毎回再生成する。他サブの index に差分が出る (例: favicon・テンプレ変更時) ことは通常ないが、もし出たら git add 対象から漏れて「ローカルだけ変わって push されない」状態になりうる。確実を期すなら add 前に
git status --porcelainでindex.html系の差分を確認し、出ているものは全部 add する運用でもよい。
commit message は引数で指定が無ければ publish: <basename> を使う。
差分が空 (まったく同じ HTML を再 push し、index も変化なし) の場合は commit が空になるので、git diff --cached --quiet で事前検出して、その場合は push をスキップして「変更なし、Pages 側は既に最新です」と案内する。
push 時に remote から dependabot の脆弱性警告行 (GitHub found N vulnerabilities ...) が出ることがあるが、これはリポ全体の依存関係に対する GitHub 側の通知で、本 skill の push 内容 (HTML + index) とは無関係。push 失敗と誤解しないこと。
9. 公開 URL を表示
push 完了後、以下を 目立つ 2 行 で出す (PAGES_BASE_URL は末尾 / 付きなので basename をそのまま連結。ルート URL がそのままランディング=index.html):
公開: <PAGES_BASE_URL><basename>
動線 (一覧): <PAGES_BASE_URL>
private Pages (public: false) なら「ログイン済みブラウザでのみ閲覧可」を 1 行添える。Pages の反映には 1〜2 分のタイムラグがあることも添える。
basename に日本語など非 ASCII が含まれる場合、上記の素朴連結 URL はブラウザの直打ちでは通るが、Slack やチケットに貼ると壊れることがある。その時は percent-encode 版 (例: jq -rn --arg s "<basename>" '$s|@uri' で basename をエンコードして連結) も併記すると貼り付け先で確実に開ける。
10. ExitWorktree + git worktree remove で畳む
EnterWorktree を path モードで呼んだ場合、ExitWorktree は worktree のディレクトリを消さない (tool 仕様: path で入った worktree は session を戻すだけ)。worktree の実体を消すのは §4 ケース B で自分が作った /tmp の worktree だけ (OWN_WT=1)。ケース A で再利用した既存 worktree (例 .claude/worktrees/review) は他用途の常設物なので残す。
ExitWorktree(action="keep")
↓
# 自分で作った worktree のときだけ実体を消す。既存を再利用した場合 (OWN_WT=0) は残す。
[ "$OWN_WT" = 1 ] && git worktree remove --force "$WT" # 本リポ側 (session が戻った後) で実行
action="remove" を指定しても、path モードで入った worktree の実体は消えない (tool が消すのは EnterWorktree 自身が name モードで作った worktree だけ)。つまり remove を指定しても結果は keep と同じなので、迷わず keep を使い実体は git worktree remove で消す。--force を付けるのは、パス表記の揺れがあっても確実に外すため (既に消えていてもエラーは無視してよい)。
途中でエラーが出た場合も、最後に必ず ExitWorktree で session を戻す。git worktree remove --force で残骸を消すのは OWN_WT=1 (自分が作った /tmp の worktree) のときだけ。既存を再利用していた (OWN_WT=0) なら消さない。
やらないこと
- 公開ブランチの新規作成 (既に存在する前提。Pages 設定済みのブランチを使う)
- main や他ブランチの merge (公開ブランチは history 持ち込み禁止の運用が前提)
- 複数の対象ファイルの一括公開 (1 回 1 ファイル。複数あるなら skill を複数回呼ぶ。ただし index は毎回「公開ブランチ上の全 HTML」を一覧するので、過去分も自動で動線に載る)
- index に任意の中身を勝手に書く (index は「<repo> の Pages」見出し + メタ行 (repo/公開ブランチへのリンク) + 公開済み HTML へのリンク一覧に徹する。これ以外の説明文や手書きセクションは持たせない)
- 本リポ側 (メイン作業ツリー) でのファイル操作
- 自分が作っていない worktree の削除 (常設の
.claude/worktrees/*等は §4 ケース A で再利用するだけ。消すのは自分が切った/tmpのものだけ) git push --force(公開ブランチは単純 fast-forward 前提。最新は §4 のfetch+-Bで揃える)- Pages 設定の変更 (読み取りのみ。push だけで反映される)
- 2 階層以上のネスト (サブのサブ) の index 生成 (1 階層のフォルダ集約まで。深い階層が要るなら別途設計)
ハマりどころ
このフローを実際に回して踏んだ落とし穴。事前に知っておくと回避できる。
Pages 設定まわり
- GitHub Actions ソースの Pages では使えない: 公開元が「GitHub Actions」(
gh api .../pagesのbuild_typeがworkflow) のリポでは、ブランチに push しても公開されない。この skill は「ブランチに push すれば公開」される legacy/branch ソース専用。§3 でbuild_type == "legacy"を確認し、workflowなら「Actions ソースなので対象外」と案内して止める (source.branchがnullで返ることもあるが、判定は文書化されたbuild_typeを一次に使う)。 source.pathが/でないリポがある:/docs公開のリポでは、ルートに置いた HTML は公開されない。§3 で.source.pathを確認し、/docsならその配下に置く (§5)。index.html もその配下 (docs/index.html) に生成する。配信 URL はルート扱いなので公開 URL に/docsは付かない。- push 後すぐは 404: Pages の反映に 1〜2 分のラグがある。push 直後に 404 でも待てば出る。出続ける場合はファイル名の typo (大文字小文字 / 日本語 / 拡張子) か
source.path不一致を疑う。
index 生成まわり
- 初回公開分が一覧から漏れる罠: §5 で
cpしたばかりの新規 HTML はまだ git に追跡されていない。一覧をgit ls-filesで作ると初回公開ファイルが index に載らない。§7 は意図してfind(ファイルシステム) で列挙している。 <title>が複数行 / 無い HTML: §7 はtrで改行を潰してから抽出するので複数行 title も拾えるが、title が無い HTML はファイル名にフォールバックする。一覧の見出しが basename のままなら「その HTML に<title>が無い」と判断できる。- macOS の
findは-printf非対応: worktree 操作はローカル (darwin) で走る。GNU 専用のfind -printfやsedのIフラグ (大小無視) は使わず、sed 's#^\./##'と[Tt]文字クラスで代替している。書き換えるときも GNU 専用機能を持ち込まないこと。 - 日本語サブディレクトリ名のリンク:
jq @uriを丸ごとかけると/まで%2Fにエンコードされてリンクが壊れる (research%2Ffoo.htmlのように)。§7 のenc_pathはパスを/で分割して各セグメントだけエンコードするのでこれを回避する。data URI の favicon は/を含むが別変数 ($ICON) なので影響しない。 for dir in */のグロブ: 公開ディレクトリ直下にサブディレクトリが 1 つも無いと*/がリテラル*/のまま回る。find "$dir" -maxdepth 1 -name '*.html'が空を返すので[ -z "$subfiles" ] && continueで抜ける (実害なし)。気になるならshopt -s nullglobを併用。
worktree まわり
- 既に worktree session 内から呼ばれると
EnterWorktree(path=/tmp/...)が弾かれる:EnterWorktreeは「既に worktree 内にいる状態での path 切替」では、ターゲットが.claude/worktrees/配下であることを要求する。本 skill は意図して/tmpに置くため、この状況では path 切替が通らない。先にExitWorktree(action="keep")でメイン作業ツリーに戻ってから起動するか、EnterWorktreeを諦めて全コマンドをgit -C "$WT"で worktree に向ける (§4 のフォールバック)。 - macOS の
/tmpは/private/tmpへの symlink:git worktree add /tmp/fooしてもgit worktree listには/private/tmp/fooで登録される。EnterWorktree(path=...)の path 照合や後のgit worktree removeがパス不一致で滑る原因になる。§4 のようにWT="$(realpath /tmp)/..."で最初から実体パスに寄せておけば全段で表記が揃う。removeは念のためgit worktree remove --force "$WT"で確実に外す (既に消えていてもエラーは無視してよい)。 EnterWorktree(path=...)は worktree を消さない: 自分でgit worktree addした worktree をpathモードで開いた場合、ExitWorktreeは session を戻すだけで実体を残す (action="remove"を指定しても消えない)。本リポに戻った後に手動でgit worktree remove --force "$WT"する (§10)。- 公開ブランチを既存 worktree が握っている:
git worktree add -B "$PAGES_BRANCH" ...は、そのブランチが既にどこかの worktree でチェックアウトされていると'<branch>' is already used by worktree '...'で失敗する。これは 2 パターンある。(1) 中断で残った自分の/tmp/pages-publish-*の残骸 →git worktree remove --force <path>で掃除してよい。(2) レビュー用などに常設された正規の worktree (例.claude/worktrees/review) → 消さずに §4 ケース A で再利用する。どちらかは path で判断する (/tmp/pages-publish-*は自分の残骸、それ以外は他用途)。§4 のEXIST_WTチェックでこの分岐は自動で入る。
認証まわり
- active な gh アカウントが途中で変わる: 別セッションの操作やグローバル設定変更で active アカウントが切り替わり、Pages 設定取得 (§3) や
git pushがRepository not found/ 403 / 404 になることがある。ネットワークを叩く前 (§2) にgh auth status --activeを確認するのが確実。 - switch しても 403 が続く: そのアカウントが対象リポ (org) にそもそもアクセスできていない。スコープ不足ではなく権限の問題なので、org owner / リポ管理者に確認する。
例
ユーザー: /publish-html-to-pages docs/2026-05-28-設計レビュー.html
↓
1. ファイル存在 OK、.html OK、basename が index.html でないこと OK
SRC=$(realpath docs/2026-05-28-設計レビュー.html)
2. gh auth status --active → 対象リポに push できるアカウントか確認 (違えば switch)
3. gh api repos/{owner}/{repo}/pages \
--jq '{branch:.source.branch, path:.source.path, html_url:.html_url, public:.public, build_type:.build_type}'
→ build_type=legacy PAGES_BRANCH=review PAGES_BASE_URL=https://xxxx.pages.github.io/ public=false
(build_type=workflow や branch=null なら対象外として停止)
4. review を握る worktree を探す: EXIST_WT=$(git worktree list --porcelain ... )
・既存あり (例 .claude/worktrees/review): WT=それ; fetch + merge --ff-only で同期確認; OWN_WT=0 (消さない)
・既存なし: WT="$(realpath /tmp)/pages-publish-$(date +%s)"; git fetch origin review;
git worktree add -B review "$WT" origin/review; OWN_WT=1 (後で消す)
EnterWorktree(path=$WT) で session を切替 (/tmp かつ既に worktree 内なら git -C "$WT" で代替)
git branch --show-current で review を確認
5. cp -- "$SRC" ./2026-05-28-設計レビュー.html (PUB_DIR=. 。source.path=/docs なら ./docs 配下)
# 注: 入力 path は §1 で絶対化済み。worktree 内 CWD では本リポの相対 path は解決不能
6. git ls-files --error-unmatch -- 2026-05-28-設計レビュー.html で追跡済みか判定 (= 上書きなら告知・確認)
7. index.html を再生成: まずバージョンガード (既存 index の tpl= が自分より新しければ中断し
skills update を促す)。OK なら 見出し「<repo> の Pages」+ メタ行 (owner/repo リンク・公開ブランチ
リンク) + favicon (repo 由来 SVG) + 版番号 (generator meta) を付け、find で公開ディレクトリ直下の
*.html を列挙 (新規分含む) → 各 <title> を抽出してリンク一覧 (自己完結テンプレート) を書き出し
8. git add -- 2026-05-28-設計レビュー.html index.html
git -c commit.gpgsign=false commit -m "publish: 2026-05-28-設計レビュー.html"
git push origin review
9. 公開: https://xxxx.pages.github.io/2026-05-28-設計レビュー.html (private なら要ログイン)
動線 (一覧): https://xxxx.pages.github.io/
10. ExitWorktree(action="keep") → 本リポ側に戻ったあと、OWN_WT=1 のときだけ git worktree remove --force "$WT"
(既存 worktree を再利用した OWN_WT=0 の場合は残す)