[[Obsidian Publish]] で公開しているノートを SNS で共有したときのカード画像 (`og:image`) を、Cloudflare Workers で動的に生成する。ノートごとの画像を手作業で用意せず、タイトル入りの 1200x630 の PNG をリクエスト時に描画する。
Publish はホスティングされたサービスであり、テンプレートにもビルドにも手を入れられない。そこでサイトの前段に Worker を置き、Publish が返す応答の書き換えだけで実現する。
## 全体構成
Worker は 1 つで、2 つの役割を持つ。
- **HTML への注入**: Publish が返す HTML の `</head>` 直前に、HTMLRewriter で `og:image` と `twitter:card` の meta タグを注入する。画像 URL にはページの URL スラッグを埋める
- **画像の生成**: `/_ogp?slug={{ SLUG }}` へのリクエストを受けたら、スラッグからノートタイトルを解決し、カード画像の PNG を描画して返す
```mermaid
sequenceDiagram
participant C as クローラー
participant W as Worker
participant P as Obsidian Publish
participant G as Google Fonts
C->>W: GET /{{ SLUG }}
W->>P: そのまま転送
P-->>W: HTML
W-->>C: og:image を注入した HTML
C->>W: GET /_ogp?slug={{ SLUG }}
W->>P: cache/{{ SITE_UID }} でタイトル解決
W->>G: フォントサブセット取得
W->>W: BudouX で改行を確定し PNG を描画
W-->>C: カード画像 (PNG)
```
この Worker を、サイトのドメイン (Publish への proxied CNAME) に route (`{{ DOMAIN }}/*`) で被せる。DNS レコードはそのままに Worker が全リクエストを仲介し、route を外せば即座に元の状態へ戻せる。
## 画像の生成
### タイトルの解決
Publish が返す HTML の `<title>` は URL スラッグの echo でしかなく、本物のタイトルはクライアント側の JavaScript が描画する。つまり HTML を読んでもタイトルは手に入らない。
代わりに、全公開ノートのファイルパスと frontmatter を返すメタデータ API `https://publish-01.obsidian.md/cache/{{ SITE_UID }}` を使う。
この応答に対し、スラッグを `permalink` またはファイル名と突き合わせ、一致したノートのファイル名をタイトルに採用する。
> [!warning]
> この cache エンドポイントは非公開 API であり、仕様変更で壊れうる。`{{ SITE_UID }}` もサイトを作り直すと変わる。
### カードの描画
描画は workers-og (Satori + resvg の Workers 移植) で行い、カードを HTML と CSS の文字列で記述して PNG 化する。
ただし Satori の記法では、テキストを持つ div にすべて `display:flex` の明示が必要で、タグ間の空白も子ノードに数えられる。テンプレートは 1 行に詰めて組み立てる。
### フォントの取得
フォントは Google Fonts の `css2` エンドポイントに `text=` を付け、描画に使う文字だけのサブセットを取得する (Noto Sans JP の同梱は Worker のサイズ上限を超えるため)。
このとき古い Firefox の User-Agent を名乗ると、Satori が対応しない woff2 ではなく TTF の URL が返る。
### 日本語タイトルの折り返し
日本語タイトルの折り返しは Satori に任せず、BudouX で文節に区切り、Worker 側で貪欲法により行へ詰めてから行単位で描画側に渡す (Satori の自動折り返しは文節を無視するため)。
フォントサイズは 58 / 52 / 46px を大きい順に試し、3 行に収まった最初のサイズを採用する。最小サイズでも収まらない長文は、3 行目の末尾を `…` で切り詰める。
## HTML への注入
### クローラーごとに異なる HTML
Publish はクローラーの User-Agent にだけ、自動生成カード (`ogimage.obsidian.md` の画像) 入りの HTML を返す。
素朴に「`og:image` が既にあれば注入しない」と実装すると、カードを見るクローラーすべてに自動生成カードを譲ってしまう。そこで `og:image` の値で分岐する。
```mermaid
flowchart TD
A["応答 HTML の og:image を確認"]
A -->|"無い (ブラウザ向け HTML)"| B["自前の meta タグを注入"]
A -->|"ogimage.obsidian.md で始まる"| C["自動生成カード:<br>自前の画像 URL に差し替え"]
A -->|"それ以外"| D["frontmatter image の明示指定:<br>手を付けない"]
```
### 公開直後の permalink 解決の遅れ
もう 1 つの書き換えは permalink 解決の遅れへの対応である。Publish のクローラー向けレンダラーは permalink の解決にメタデータ API とは別の索引を使い、公開直後は更新が間に合わないことがある。
その間の応答は、`og:title` がスラッグの echo に、説明文が「File ... does not exist」のエラーになる。
Worker は `og:title` の値がスラッグと一致していたら未解決と判定し、先に更新されるメタデータ API でタイトルを引き直して書き換える。説明文も、解決したファイルパスの `.md` 本文から散文を繋いだテキストで差し替える。
## キャッシュと再描画
キャッシュは Cache API で 3 層に分ける。生成した PNG (7 日)、メタデータ API の応答 (10 分)、フォントのサブセット (7 日) を持ち、2 回目以降のリクエストでは描画もタイトル解決も走らない。
デザイン変更時の無効化は、内部キャッシュの削除だけでは足りない。Slack や X は画像を URL 単位で自前のプロキシにキャッシュするため、カードのバージョン番号を `og:image` の URL に `v=` パラメータとして含め、変更時にインクリメントして URL ごと切り替える。
## 関連ノート
なし