Gosuke Miyashita archive about feed

Peithoにツイートやリンクの埋め込み機能を入れた

5 August 2026

自作プレゼンツール Peitho の話の続き。ツイートやYouTubeの動画のURLを書くと、スライドにその中身を貼れるようにした( PR #401 #403 #404 )。貼られるのはスクリーンショットかカードで、動画が再生できるわけではない。

書き方はこう。

```embed
https://x.com/gosukenator/status/2074821309259973046
```

embed というタグのコードフェンスにURLを1行書くだけ。ビルド時に解決されて、present、preview、PDFエクスポート、公開用の dist/ のどれでも同じものが出る。


出力にスクリプトを入れない

まず前提として、Peithoでは本文にHTMLタグを書くとビルドエラーになる。CommonMarkはHTMLをそのまま出力に通す仕様だけど、Peithoでは怒られて止まる(コメントだけは例外で、ページ設定と発表者ノートに使っている)。なので、Xが配っている公式の埋め込みコードを貼る、という選択肢は最初からない。

仮に許したとしても動かない。公式の埋め込みは、blockquoteと widgets.js の読み込みをセットで貼る形になっていて、スクリプトがページ全体からそのblockquoteを探し出して中身を差し替える、という段取りで動く。ところがPeithoはスライドを1枚ずつShadow DOMの中に入れて、デッキのCSSもその中に流し込んでいる。デッキのスタイルが発表者ツールなどのシェル側のUIに漏れ出さないように、また逆にシェル側のスタイルがスライドに効かないようにするため。Shadow DOMの中身は外から普通に検索しても引っかからないので、 widgets.js は差し替える相手を見つけられないまま終わる。

そもそも、スライドを表示するたびにスクリプトが走って埋め込みを組み立てる、という作りには無理がある。ネットワークのない会場では widgets.js をXから取ってくるところで止まるし、Xの側で表示のされ方が変われば、過去のスライドの見た目も変わる。PDFエクスポートも、スクリプトの実行を待ちきれるかどうかというタイミングの問題を抱え込むことになる。発表の当日に埋め込みが出るかどうかが、その場の環境次第になってしまう。

なので全部ビルド時に解決して、出力には静的な成果物だけが入るようにした。


埋め込みは3種類

同じ embed と書いても、出来上がるものは3種類ある。書いたURLと、オプションを付けたかどうかで決まる。

ツイートのスクリーンショット(デフォルト)

X(Twitter)のステータスURLをオプションなしで書くと、ビルド時にヘッドレスChromeで公式のウィジェットを描画して、そのPNGを撮る。撮った画像はキャッシュされて、以降のビルドではネットワークに出ない。

公式のXウィジェットのスクリーンショットが貼られたスライド

アイコンも動画のサムネイルも、いいねの数や「Read 1 reply」まで、公式の埋め込みの見た目そのまま入る。

自分でスクリーンショットを撮っていたのを、機械にやらせるようにしただけとも言える。

ツイートが消されていたり非公開だったりして、ウィジェットがいつまでも描画されない場合は、行番号つきのビルドエラーにしている。空白の画像が黙って出るよりは、ビルドが落ちたほうがいい。

カードモード

mode=card を付けると、スクリーンショットではなくHTMLのカードになる。

```embed mode=card
https://x.com/gosukenator/status/2074821309259973046
```

こっちはoEmbedのAPIを叩いてデータを取ってきて、Peitho側でカードのHTMLを組み立てる。画像ではなく本文なので、テキストが選択できるし、デッキのテーマの色とフォントに従う。Chromeを起動しないので、カードだけのデッキならChromeなしでビルドできる。

カードモードで描画された同じツイート。デッキのフォントと色になっている

同じツイートだけど、さっきのスクリーンショットとは見た目が違う。アイコンもXのロゴも入っていないし、フォントはデッキのもの。本物そっくりではないかわりに、スライドの中で浮かない。

oEmbedのAPIが返してくるJSONには、投稿者名や日付といったデータに混じって、 html というフィールドが入っている。中身は結局あの公式の埋め込みコードで、blockquoteがそのまま文字列で入っている。これをスライドに貼れば手っ取り早いけど、そうはしていない。

このHTMLは表示するためのものとしては扱わず、データとして読むだけにしている。本文とリンクだけを抜き出して、あとは投稿者名などのJSONのフィールドと組み合わせ、カードのHTMLはPeitho側で組み立て直す。抜き出した文字列はすべてエスケープしてから埋める。Xから受け取ったHTMLが、そのままの形で出力に出ていくことはない。自分で書いたHTMLすらビルドエラーで弾いているのに、外から取ってきたHTMLを素通しするのでは筋が通らない。

汎用のoEmbedカード

X以外のURLをオプションなしで書くと、汎用のカードになる。

```embed
https://www.youtube.com/watch?v=dQw4w9WgXcQ
```

ビルド時にまずそのページのHTMLを取ってきて、 <link rel="alternate" type="application/json+oembed"> という行を探す。oEmbedに対応しているサイトは、埋め込み用のデータをどこから取れるかを、この形でページ内に書いている。あとはそこに書かれているURLを叩けば、カードに必要なデータが返ってくる。

なので、Peithoの側に「対応サービス一覧」のようなものはない。YouTubeやMastodonを名指しで書いてあるわけではなく、この行が置いてあるページなら何でも通る。

返ってきたデータにサムネイル画像が入っていればそれを使ったカードに、入っていなければタイトルと投稿者とサービス名(「YouTube」など)だけのテキストカードになる。

YouTubeの動画がサムネイル付きのカードとして描画されたスライド

サムネイルはビルド時にダウンロードして、自分で ![]() で貼った画像と同じ扱いで、ハッシュ名のローカルアセットとして公開される。リンク先は常にデッキに書いたURLで、oEmbedのレスポンスに入っているURLで上書きはしない。

こっちはスクリーンショットを撮らない。返ってくる html も使っていない。YouTubeのそれは動画プレイヤーのiframeで、Mastodonのそれは「View on Mastodon」と書かれた枠と、それを実際の投稿に置き換えるスクリプトのセットになっている。どちらも表示にはネットワークかスクリプトが要るので、ビルド時に静的な成果物を作る、という方針とは噛み合わない。かといって、Xと同じようにスクリーンショットを撮る方向にも行きにくい。Xの公式ウィジェットは描画が終わったことを自分で教えてくれるので、それを待ってから撮ればいい。ところが一般のiframeにそういう仕組みはない。読み込みが終わったことは分かっても、中身が描画し終えたかどうかは外から知りようがないので、適当に何秒か待って撮る、みたいな作りになってしまう。なので汎用側は常にPeithoが組み立てたカード、と割り切った。


URLを1つ書くだけの記法にした

記法をどうするかは少し考えた。

本文中にURLをそのまま書いたら自動で埋め込みになる、という案は却下した。「ここはただのリンクにしたい」と区別がつかないし、書いていないことが黙って起きるのはPeithoの方針に合わない。

![](https://...) の画像記法を拡張する案も却下。Peithoの ![]() はリモートURLを受け付けないし、拡張子もpngやjpgといった画像のものしか受け付けない。ツイートのURLを書く場所としては、どちらの条件にも合っていない。

結果として embed という専用のフェンスになった。既に mermaidmath がフェンスのタグで組み込みのレンダラに振り分けられる仕組みがあるので、そこに embed を足す形で収まっている。 code_images.embed: で外部コマンドに差し替えるのも、他のタグと同じようにできる。

オプションをどこに書くかも、実装の途中で変えた。最初は本文の、URLの次の行に書く案だった。

```embed
https://x.com/gosukenator/status/2074821309259973046
mode: card
```

これだと本文の中に「URLの行」と「オプションの行」という2つの文法が混ざる。今は embed と書いている行に、続けて書くようにしている。

```embed mode=card
https://x.com/gosukenator/status/2074821309259973046
```

本文は常にURLが1行だけ、という状態を保てるし、後から themewidth を足すときも文法を作り直さなくて済む。


キャッシュの置き場所

取ってきたものは .peitho/embeds-cache/ に置いている。スクリーンショットのPNG、oEmbedのレスポンス、ダウンロードしたサムネイル画像がここに入る。全部揃っていればオフラインでビルドできるし、足りないものだけを取りに行く。

有効期限のようなものはなく、勝手に取り直したりもしない。更新したいときは消す。何かに失敗したときのエラーには、消すべきキャッシュファイルのパスをそのまま出すようにしてある。


サンプルデッキは Tweet Embedの例 に置いてある。3枚のスライドで、スクリーンショット、カードモード、汎用のoEmbedカードがそれぞれ出る。

参考: