CLAUDE.md · git:20260912.bf4be92 · 2026-09-12 · sha256 a96915df3da5651d

CLAUDE.md git:20260912.bf4be92A

Immutable. This exact content is served forever at /api/v1/blob/a96915df3da5651d.

# CLAUDE.md

セットアップとデプロイのコマンドは README.md を参照。

## 日付の扱い

Netlify のビルドは **UTC** で走る。事前レンダリングした結果と閲覧者のブラウザでの
再計算が食い違うとハイドレーションが壊れるため、日付の判定・表示は必ず
`lib/jst.js` を通し、基準時刻は `new Date()` ではなく `build_info.updated`
(ビルド時刻)を使う。

この不具合はローカル(JST でビルド → JST で表示)では再現しない。検証は
`TZ=UTC pnpm build:skip` でビルドしてから、`.output/public` を配信して
**全ページをブラウザで開く**こと(トップだけ見て `/episodes/` を見落とした前例がある)。

日付ライブラリは dayjs。ロケールは `lib/jst.js` で `ja` に決めている。
Vite は使うものだけを読むため、ここを通さないと曜日が英語になる。
ロケールは副作用だけの import(`import 'dayjs/locale/ja'`)では効かず、
値として読んで `dayjs.locale(ja)` に渡す必要がある。

`scripts/` 側(フィードの取得)は素の `Date` で日付を扱う。RSS の pubDate は
`scripts/parse-pub-date.js`、収録時間の計算は `lib/format-seconds.js`。

## 音声の再生

音声は配信元の enclosure URL をブラウザから直接再生している
(`components/episode-player.vue` の `preparePlayer`)。配信者側の計測は配信
サーバーのログを IP と UA で数えるので、この形なら再生がそのまま各番組の
統計になる。次のことをすると、その数字を奪ってしまうのでしない。

- 音声を中継する・キャッシュする(配信元のログが Netlify の IP で埋まる)
- enclosure URL を「正規化」してプレフィックスやクエリを落とす
  (Podtrac などの計測が効かなくなる。`scripts/pf-util.js` の `audioUrl`)
- ファイルの保存(ダウンロード)機能を付ける
- 再生時の UA を偽装する。ブラウザからは変えられないし、変えるべきでもない
  (フィード取得側の UA は `scripts/wget-with-timeout.js` で名乗っている)
- `referrer` の meta を足す。既定のままなら配信元へ podcastfreaks.com が伝わる

フィードに `itunes:block` / `podcast:block` が指定された番組は `scripts/fetch-feeds.js` が
一覧から外し、`/errors` に「掲載を止めている番組」として出す。

## 生成物とデータ

- `static/downloads/`(RSS・カバー画像・`build_info.json`・`episodes/`)は
  `pnpm fetch-feeds` が生成する。gitignore 対象。ページは `build_info.json` を
  ビルド時に import するので、無いと中身のないサイトになる
- `static/registered.json` も `pnpm fetch-feeds` が `data/rss.json` から作る
  (gitignore 対象)。`/add` が「もう登録されている番組か」を判定するためだけの
  軽い一覧で、キー・フィード・番組名しか持たない。`build_info.json` は一覧の
  初期表示に載るサイズなので、判定のために読ませない
- エピソードは `build_info.json` ではなく `episodes/<key>.json` に番組ごとに
  置く。一覧で行を開いたときにしか使わないのに、`build_info.json` 全体の9割を
  占めていたため。一覧は行を開いた時点でその番組のぶんだけ読む
  (`pages/index.vue` の `loadEpisodes`)ので、`build_info.json` に何かを
  足すときは初期表示に載るかどうかを見る
- エピソードは1番組で1300話を超えることがある。一覧は30話ずつ描き、
  音声は再生を押すまで作らない(`components/episode-player.vue` の
  `preparePlayer`)。長さはフィードの `itunes:duration` から出している
- `pnpm build` は `fetch-feeds`(フィードとカバー画像の取得)から走る。取得済みの
  ものを使うなら `pnpm build:skip`。pnpm は npm のライフサイクル(`pre` の付く
  スクリプト)を既定で実行しないので、`build` の中で明示的に呼んでいる
- `data/added-at.json`(番組の登録日)と `data/apple-podcasts.json`(Apple Podcasts の
  リンク)は GitHub Actions が生成する。手で編集するのは後者の `"source": "manual"`
  の項目だけ
- フィードのエラーや警告への対処は `.claude/skills/feed-triage/` にまとめてある
- 番組を新しく登録する手順は `.claude/skills/add-channel/` にまとめてある

## テスト

`lib/` と `scripts/` の素の関数を Vitest で固めてある。

```sh
pnpm test        # 一度だけ走らせる
pnpm test:watch  # 直しながら見る
```

見ているのは、壊れると影響が大きくて、目で確かめるのが面倒なところ。

- `lib/compare.js` … 一覧の並べ替え。vue-tables-2 から自前の table へ移した
  ときに、234行の並びが1つも変わらないことを確かめながら移植した部分。
  同じ値でも 0 を返さない(Array#sort の作法から外れる)挙動も、
  そのままであることを確かめている
- `lib/jst.js` … 日本時間への固定。テストは `TZ=UTC` で走る
  (`vitest.config.js`)。手元が JST でも気づけるように
- `scripts/parse-pub-date.js` … RSS の pubDate。GMT のような名前の
  タイムゾーン、実際と食い違う曜日など、過去に踏んだ形をそのまま置いてある
- `scripts/pf-util.js` … 収録時間と更新頻度の集計。moment を外したときに、
  配信中の234フィードで旧実装と突き合わせながら書き換えた部分

画面の描画(一覧の絞り込みや子行の開け閉め)はここでは見ていない。
そちらを変えたときは、ブラウザで実際に動かして確かめる。

## スタイル

スタイルはプレーン CSS のネスト記法で書く(`.vue` の `<style>` と
`assets/common.css`)。Sass は使っていない。

ネストは Vite がビルド時に平坦なセレクタへ展開する(Nuxt 2 のときは
`postcss-preset-env` の `nesting-rules` を有効にしていた)。書くときの注意が3つ。

- ネストの中で**要素名から始まるセレクタには `&` を付ける**(`& svg`)。
  付けないとプロパティ名と解釈されて壊れる
- **`@media` の直下では `&` を付けない**。そこでの `&` は `:root` を指すので、
  `:root div.row` という別物のセレクタになる
- **`>` で始まるセレクタには要素かクラスを書く**(`>.badge`、`& > table`)。
  `>*` や `>:first-child` のように書くと、Vue 3 の scoped 変換が
  `[data-v-x]` を別の位置に差し込み、`.links [data-v-x]>:not(:first-child)`
  のような別物のセレクタになる(アイコンの間隔と About の凡例が崩れた)

ヘッダーなどの紫のグラデーションは、もとは Sass のミックスインだった。
`assets/common.css` の `:root` に `--brand-texture` などとして置いてある。

## 依存関係

Nuxt 4 / Vue 3 / Vite。Node は `.node-version` に固定。

パッケージマネージャは pnpm で、バージョンは `package.json` の
`packageManager` に書いてある(corepack が読む)。宣言していない依存が
見えないので、`package.json` に無いものを import していると気づける
(yarn のときは `axios` が `@nuxtjs/axios` 経由で使えてしまっていた)。

pnpm 10 以降は、入れたパッケージのビルドスクリプトを既定で走らせない。
esbuild(Vite のビルド)と sharp(カバー画像とアイコン)は必要なので、
`pnpm-workspace.yaml` の `allowBuilds` で許可している。ここに無いものが
必要になると `ERR_PNPM_IGNORED_BUILDS` で止まる。

pnpm の設定は `package.json` の `pnpm` フィールドではなく
`pnpm-workspace.yaml` に置く(pnpm 10 以降。単一のパッケージでも同じ)。

出力先は `.output/public`。`netlify.toml` の `publish` と `pnpm deploy` の
`--dir` もそこを指している。

そのために `nuxt.config.js` で Nitro のプリセットを `static` に固定している。
外すと Netlify の上だけ `netlify-static` が自動で選ばれ、手元と2つ食い違う。

- 出力先が `dist` に変わり、`netlify.toml` の `publish` と合わずデプロイが落ちる
- `_payload.json` が出力されない。事前レンダリングした HTML はこれを読む前提
  なので 404 になり、ハイドレーション後の状態が復元されない(一覧のチェックが
  全部外れ、Download OPML が押せなくなる)。**警告もエラーも出ない**

手元で Netlify と同じ条件を試すなら `NETLIFY=true pnpm build:skip` で再現できる。

## Netlify のビルドが手元と違う結果になるとき

まずログの冒頭にある `Starting to download cache of ... (Last modified: ...)` を
見る。Netlify は `node_modules` を丸ごとキャッシュして復元するため、**古い
パッケージマネージャが置いたディレクトリが残り続ける**。

yarn から pnpm へ移したとき、これで3回デプロイに失敗した。pnpm は
`node_modules/.pnpm` の下に実体を置き、`package.json` に書いた依存だけを
そこへリンクする。`vue` のように**直接の依存でないもの**は触らないので、
yarn 時代の `node_modules/vue`(Vue 2.7)がそのまま残り、Vite がそちらを
解決して `"createApp" is not exported by node_modules/vue/dist/vue.runtime.esm.js`
で止まっていた。ログのパスに `.pnpm/` が無いものは、pnpm が入れたものでは
ないと判断できる。

直すには Netlify の UI から `Trigger deploy` ▾ → `Clear cache and deploy site`。
ただし**クリアが効くのはその1回だけで、失敗したビルドは新しいキャッシュを
保存しない**。途中で別の理由で落ちると古いキャッシュが生き残り、「クリアした
のに直らない」ように見える。最後まで通るビルドを1回成功させる必要がある。

## コミットメッセージをファイルで渡すとき

`git commit -F` に渡すファイルは、`$TMPDIR/msg.txt` のような汎用名にしない。
zsh は noclobber が効いていて、同名のファイルが残っていると `cat > file` が
`file exists` で失敗する。それでも後続の `git commit -F` は成功してしまうため、
**別セッションが残した古いメッセージでコミットされる**(前例あり)。

固有の名前を付けて、`>|` で上書きする。

```sh
cat >| "$TMPDIR/pf-<内容>-msg.txt" <<'MSG'
...
MSG
```