# 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 対象）。`/request` が「もう登録されている番組か」を判定するためだけの
  軽い一覧で、キー・フィード・番組名しか持たない。`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"`
  の項目だけ
- 出すページは `nuxt.config.js` の `nitro.prerender.routes` に**全部書く**。
  リンクは辿らせない（`crawlLinks: false`）。辿らせると、同じページが末尾
  スラッシュの有無で2回描かれることがあった（`/request` と `/request/`）。
  nitro は `autoSubfolderIndex` が既定で true なので、どちらも
  `request/index.html` に書く。2つが同時に書くと中身が混ざり、短い方を
  書いたあとに長い方の余りが残って、ページの末尾に `/html>` が出る
  （payload に入る `"path"` の `\u002F` 6文字ぶんが余る）。本番でも起きていた。
  ページの末尾に変な文字が出ていたら、まずこれを疑う
- フィードのエラーや警告への対処は `.claude/skills/feed-triage/` にまとめてある
- 番組を新しく登録する手順は `.claude/skills/add-channel/` にまとめてある

## issue のラベル

**`Feed` は自動で付けない。** これはサイトを見た人が読む一覧で、About の
「フィードの取得状況」から
`is:issue is:open label:Feed` へリンクしている（`pages/about.vue`）。
配信者が自分の番組の状況を確かめに来る場所なので、開発の作業 issue が
混ざると読めなくなる。

付けるのは**訪問者に知らせたい既知の問題だけ**。フィードに関する内容でも、
直す作業そのものを書いた issue には付けない。

いま付いているものは、どちらも継続的に追記していくまとめ。書き方はこれに倣う。

- #5 登録できなかった番組（配信元ごとに、フィードの不備と参照先を並べたもの）
- #52 Requestがあったが登録見合わせたものとその理由（表で1行ずつ）

閉じると一覧から消えるので、役目が続くうちは開けたままにする。

他のラベル（`enhancement`・`UI`・`Feature Request`・`site-health`・
`register-request`）は開発側のもので、公開の一覧には出ていない。

## テスト

`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 の凡例が崩れた）

**ホバーは今までどおり `&:hover` と書けばよい。** タッチ端末では :hover が
離したあとも残る（指はポインタのように離れられない）ので、押したリンクが
薄いまま、押した行が紫のまま居座っていた。これを1つずつ `@media (hover: hover)`
で囲むと書き漏らすため、ビルドのときに `postcss-hover-media-feature` が
:hover を含むルールをまとめて囲んでいる（`nuxt.config.js` の `postcss`）。

そのため、`.vue` の中に手で書いた `@media (hover: hover)` は要らない。
すでに囲んであるものはプラグインが二重に囲まないので、残っていても害はない。

**文字を図形と横に並べて上下中央に置くときは、文字を少し持ち上げる。**
Noto Sans JP は字に対して上の余白が広く、15px のときの行ボックス21pxに対して
字の中心はその中心より 2.2px 下に来る（`measureText` の
`fontBoundingBox` と `actualBoundingBox` の差で測れる）。カバー画像や
アイコン、バッジは図形なのでぴったり中央に置かれるため、そのまま並べると
文字だけ沈んで見える。持ち上げ幅は `assets/common.css` の `--optical-shift`
に置いてあるので、`top: var(--optical-shift)` を当てる。

書体ごと直す方法も試したが、どちらも割に合わなかった。

- `@font-face` の `ascent-override` … Google Fonts が配る `@font-face` は
  書き換えられない。自前で持つには、日本語が `unicode-range` で120個ほどに
  分かれているぶんを全部抱えることになる
- `text-box-trim` … 継承しないので `*` に当てるしかなく、そうすると段落の
  余白まで詰まる（About ページで実測160px縮む）。`ideographic` の値は
  まだ使えず、対応していないブラウザとで見え方が2つに割れる

当てる場所は測ってから決める。ずれ幅は字の大きさと、その文字列に下へ
伸びる字（g・y）が入るかで変わり、1px前後のところに当てると逆に浮く。

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

## 例示に使う名前

画面に出す例（placeholder など）は自分たちのもので統一する。番組は `noracast`
（フィードは `https://noracast.jp/feed.xml`、X は `@noracast_`、ハッシュタグは
`#noracast`）。人は X が `@naokazu_terada`、GitHub が `naokazuterada`
（**アンダースコアの有無が違う**）。

他所の番組を例に出すと、その番組のフィードやアカウントが変わったときに、
こちらの画面だけ古い情報を出し続けることになる。

## 依存関係

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回成功させる必要がある。
