Studio を閉じて、旧 URL は Cloudflare で転送する ― 公開・通知メール・DNS 移行・LOG への改番を1日で

前回(LOG342)で、サイト用のドメイン pixelstomotors.com を取り、Cloudflare Workers で動く新しいサイトにつなぎました。ただ、ログインした本人しか見られない鍵がかかったままで、公開はしていませんでした。その続きです。

やったことを先に書きます。鍵を外して pixelstomotors.com を公開し、仮の住所を閉じました。メッセージフォームに届いた内容がメールで届くようにしました。旧サイトの住所 adaptdesign.jp の DNS を Cloudflare に移し、旧 URL を記事まで届く形で転送するようにして、Studio のサイトを非公開にし、プランを Free に下げました。トップの動画の節の体裁を直し、記事の番号の呼び方を DAY から LOG に変えて、一覧を番号順に並べ直しました。最後に、記事を作るときの決まり(article_rules.md)を、Studio に貼る前提から、GitHub に push すると公開される前提に書き換えました。

使ったコードと、確かめたときのコマンドと出力は、記事の後半に全文で載せています。

Access の範囲を「All traffic」から「Previews only」にして、pixelstomotors.com を公開した

前回つないだ時点で、pixelstomotors.com を外から開くとログイン画面(Cloudflare Access)へ回されました。原因は、鍵の設定が Worker 全体にかかっていたことでした。Worker の「Access」タブを開くと、Worker Access の範囲が「All traffic(すべての通信)」になっていました。

Studio を閉じて、旧 URL は Cloudflare で転送する ― 公開・通知メール・DNS 移行・LOG への改番を1日で|Access の範囲を「All traffic」から「Previews only」にして、pixelstomotors.com を公開した

「Manage access」を押すと、範囲を選ぶ欄がありました。「Previews only(試しの版だけ)」と「All traffic」の2択です。

Studio を閉じて、旧 URL は Cloudflare で転送する ― 公開・通知メール・DNS 移行・LOG への改番を1日で|Access の範囲を「All traffic」から「Previews only」にして、pixelstomotors.com を公開した
範囲鍵がかかる住所
All trafficpixelstomotors.com・仮の住所(workers.dev)・試しの版(*-p2m-site.…workers.dev)のすべて
Previews only試しの版だけ

「Previews only」にして保存しました。これで pixelstomotors.com と仮の住所の両方が、誰でも開ける状態になります。赤い「Remove Worker-specific Access」は押していません。押すと試しの版の鍵まで外れます。

公開と同時に、仮の住所は閉じました。同じサイトが2つの住所で見える状態を残さないためです。Worker の Settings にある「Worker URL」の、Production(p2m-site.pixelstomotors.workers.dev)のスイッチを切りました。Preview のスイッチは入れたままです。

Studio を閉じて、旧 URL は Cloudflare で転送する ― 公開・通知メール・DNS 移行・LOG への改番を1日で|Access の範囲を「All traffic」から「Previews only」にして、pixelstomotors.com を公開した

外から確かめた結果です。

開いた URL結果
https://pixelstomotors.com/200
https://pixelstomotors.com/record/day1200
https://pixelstomotors.com/pixelstomotors/record/day1301 → /record/day1
トップの動画(Range 指定)206・video/mp4
https://p2m-site.pixelstomotors.workers.dev/404(閉じた)

ここで、新しいサイトが公開になりました。

メッセージフォームの内容は R2 に貯まるだけだったので、Cloudflare の送信の仕組みでメールにした

公開した時点で、1つ知っていたことがありました。メッセージフォームから送られた内容は、Cloudflare の保管場所(R2 の p2m-messages)に JSON で貯まるだけで、届いたことを知る手段がありませんでした。

Cloudflare には、Worker からメールを送る仕組み(send_email の binding)があります。送れる宛先は、Cloudflare の Email Routing で「確認済み」になっているメールアドレスだけです。その代わり、無料で使えます。

手順は次のとおりでした。

  1. Cloudflare の Email Routing で pixelstomotors.com を登録する(DNS に必要な行が自動で足される)
  2. 「Destination addresses」に pixelstomotors@adaptdesign.jp を足し、届いた確認メールのリンクを押す
  3. wrangler.jsonc に send_email の binding を足し、worker/index.js に送る処理を書く
  4. GitHub に push する(自動で公開される)
Studio を閉じて、旧 URL は Cloudflare で転送する ― 公開・通知メール・DNS 移行・LOG への改番を1日で|メッセージフォームの内容は R2 に貯まるだけだったので、Cloudflare の送信の仕組みでメールにした

3 は 2 が終わるまで push しませんでした。確認済みでない宛先が binding に書いてあると、公開の作り直しが失敗して、サイトの更新が止まるためです。

worker/index.js では、R2 に保存したあとに notify() を呼びます。メールが送れなくても、保存は済んでいるので、フォームを送った人には成功と返します。送り元は form@pixelstomotors.com、返信先(Reply-To)にフォームを送った人のメールを入れ、そのまま返信できるようにしました。

メールの本文は、Cloudflare の EmailMessage に渡す「生のメール」の形(ヘッダーと本文を改行で区切った文字列)を自分で組み立てています。npm の mimetext を入れれば楽ですが、依存を増やしたくなかったので、必要な行だけ書きました。

件名が壊れた原因は、符号化した文字列を 76 文字ごとに折り返していたこと

1通目の試験メールは、迷惑メールに入り、件名が「=?utf-8?B?W1BpeGVscyB0…」という符号のままで、本文の先頭にヘッダーの残りが流れ込んでいました。

Studio を閉じて、旧 URL は Cloudflare で転送する ― 公開・通知メール・DNS 移行・LOG への改番を1日で|件名が壊れた原因は、符号化した文字列を 76 文字ごとに折り返していたこと

原因はコードの1か所でした。日本語の件名はそのままメールのヘッダーに書けないので、RFC 2047 という決まりで「=?utf-8?B?(base64)?=」の形に符号化します。私は本文と件名で同じ base64() を使っていて、その関数は 76 文字ごとに改行を入れる作りでした。本文(Content-Transfer-Encoding: base64)では正しい作法ですが、件名の中に改行が入ると、ヘッダーがそこで終わったことになります。残りの行はヘッダーとして読めないので、本文として扱われました。迷惑メール扱いも、この壊れたヘッダーが原因と見ています。

直す前直した後
件名も本文も、76 文字ごとに改行を入れた base64件名と返信先の名前は改行なし。本文だけ 76 文字ごとに改行

直したあとの2通目は、件名が日本語で読め、本文も正しく並びました。ただ、迷惑メールに入りました。Gmail の説明は「cloudflare-email.net から以前に受信したメールは迷惑メールに分類されたため」でした。1通目を迷惑メールと判断したことを覚えていて、同じ経路の2通目も疑った形です。「迷惑メールでないことを報告」を押すと、受信箱に移りました。

Studio を閉じて、旧 URL は Cloudflare で転送する ― 公開・通知メール・DNS 移行・LOG への改番を1日で|件名が壊れた原因は、符号化した文字列を 76 文字ごとに折り返していたこと

本文に「保存先: p2m-messages/{ファイル名}」を入れてあるので、メールが届かなくても、R2 を見れば同じ内容が残っています。

Studio の転送はトップにしか飛ばず、Free プランでは消えるので、adaptdesign.jp の DNS を Cloudflare に移した

公開したあと、旧サイトの URL(adaptdesign.jp/pixelstomotors/…)で来た人を新しいサイトへ送る転送が要ります。前回の時点で、Studio の転送機能は1ページずつしか登録できないことが分かっていました。エイイチが Studio 側で、adaptdesign.jp 全体を pixelstomotors.com へ転送する設定を入れました。

外から確かめると、2つのことが分かりました。

開いた URL行き先
adaptdesign.jp/pixelstomotorspixelstomotors.com/
adaptdesign.jp/pixelstomotors/record/day1pixelstomotors.com/(記事ではなくトップ)
adaptdesign.jp/(ADAPT DESIGN のトップ)pixelstomotors.com/
adaptdesign.jp/web_production_list(Web 制作実績)pixelstomotors.com/

1つは、記事の URL が記事ではなくトップに飛ぶこと。Studio の転送は「URL の続きを引き継ぐ」ことができないので、X や LinkedIn に貼った過去のリンクを開いた人は、トップから探すことになります。もう1つは、ADAPT DESIGN 本体のトップや実績ページまで飛ぶこと。これはエイイチに確かめ、このまま全部 pixelstomotors.com へ送ってよい、と決まりました。

ADAPT DESIGN のサイトは、このまま全部 pixelstomotors.com に送って OK

そして、Studio のプランの話です。Studio は Business プラン(月 3,980 円)で、転送だけのために払い続けるのはもったいない。ところが Free に下げると、独自ドメイン(adaptdesign.jp)がつなげなくなり、転送どころか adaptdesign.jp 自体が開かなくなります。

Studio を閉じて、旧 URL は Cloudflare で転送する ― 公開・通知メール・DNS 移行・LOG への改番を1日で|Studio の転送はトップにしか飛ばず、Free プランでは消えるので、adaptdesign.jp の DNS を Cloudflare に移した
Studio を閉じて、旧 URL は Cloudflare で転送する ― 公開・通知メール・DNS 移行・LOG への改番を1日で|Studio の転送はトップにしか飛ばず、Free プランでは消えるので、adaptdesign.jp の DNS を Cloudflare に移した
Studio のプラン独自ドメイン転送
Business(今)使える1ページずつ。URL の続きは引き継げない
Free使えないなし

結論は、転送を Studio から Cloudflare に移すことでした。adaptdesign.jp の DNS(名前を住所に変える台帳)を Cloudflare に移せば、Cloudflare の Redirect Rules(転送の決まり)が無料で使え、URL の続きを引き継いだ転送ができます。そのあと Studio を Free に下げます。

多分すぐ非公開になるので DNS を移して

ダウングレードの画面に「プラン移行日にサイトが自動的に非公開になります」とあり、移行日がいつか分からなかったので、先に DNS を移すことにしました。

DNS の移行は、控え → Cloudflare に追加 → DNSSEC を外す → DS が消えるのを待つ → ネームサーバーを変える、の順で行った

adaptdesign.jp は Squarespace に登録されていて、DNS は Google Cloud DNS(ns-cloud-c1〜c4.googledomains.com)にありました。メール(Google Workspace)も同じドメインで動いています。DNS を移すときに一番避けたいのは、メールが止まることでした。

順番を決めて進めました。

順やったこと誰が
1今の DNS の内容を外から引いて、iCloud の site_archive/dns/ に控えるClaude
2Cloudflare に adaptdesign.jp を追加する(Free プラン)。既存の行は自動で読み取られるエイイチ
3読み取られた行を控えと突き合わせるClaude
4Squarespace で DNSSEC をオフにするエイイチ
5.jp の台帳から DS レコードが消えるのを外から確かめるClaude
6Squarespace でネームサーバーを Cloudflare の2つに変えるエイイチ
7切り替わりを外から確かめる。メールの行(MX)が引けることも確かめるClaude

控えた DNS の内容

外から引けたのは次の行でした。A は Studio のサーバー、MX 5本と SPF・DKIM は Google Workspace のメール、TXT の2本は Google と Brave の所有確認です。

名前種類値
adaptdesign.jpA35.194.122.208
adaptdesign.jpMX1 aspmx.l.google.com / 5 alt1 / 5 alt2 / 10 alt3 / 10 alt4
adaptdesign.jpTXTv=spf1 include:_spf.google.com ~all
adaptdesign.jpTXTgoogle-site-verification=…
adaptdesign.jpTXTbrave-ledger-verification=…
google._domainkey.adaptdesign.jpTXTv=DKIM1; k=rsa; p=…

DNSSEC(DNS の改ざんを防ぐ署名)も有効で、DS レコードが .jp の台帳に登録されていました。これが順番を決める理由になります。

Cloudflare はメールの行まで自動で読み取った

Cloudflare で「Connect a domain」から adaptdesign.jp を入れ、Free プランを選ぶと、今の DNS を読み取った一覧が出ました。

Studio を閉じて、旧 URL は Cloudflare で転送する ― 公開・通知メール・DNS 移行・LOG への改番を1日で|Cloudflare はメールの行まで自動で読み取った
控えCloudflare の一覧
A(Studio)あり
MX 5本あり
TXT 3本(SPF・Google 確認・Brave)あり
DKIMあり

Squarespace が入れていた _domainconnect の CNAME が1行増えていましたが、あってもなくても影響はないので、そのままにしました。次の画面に、Cloudflare のネームサーバーが2つ表示されました。

Studio を閉じて、旧 URL は Cloudflare で転送する ― 公開・通知メール・DNS 移行・LOG への改番を1日で|Cloudflare はメールの行まで自動で読み取った

DNSSEC は先に外す。DS が消えるまで 45 分待った

DNSSEC が有効なまま、ネームサーバーだけを Cloudflare に変えると、.jp の台帳にある鍵(DS)と、Cloudflare が返す中身が食い違います。署名を確かめる DNS サーバーは、その応答を偽物とみなして捨てます。サイトもメールも引けない時間ができる、ということです。前回の codex の報告でも、この順番が指摘されていました。

Squarespace の「DNSSEC」でスイッチをオフにしました。

Studio を閉じて、旧 URL は Cloudflare で転送する ― 公開・通知メール・DNS 移行・LOG への改番を1日で|DNSSEC は先に外す。DS が消えるまで 45 分待った

オフにしたあと、エイイチはすぐにネームサーバーの画面に進みましたが、外から引くと DS レコードはまだ残っていました。Squarespace が .jp の台帳に消去を届け出て、それが反映されるまでに時間がかかります。ここで待ちました。

時刻DS レコード
20:15オフにした直後。まだ1件残っている
20:16まだ残っている。ネームサーバーの変更は待つ
21:02消えた

消えたのを確かめてから、Squarespace の「ドメイン ネームサーバー」で「カスタム ネームサーバーを使用」を選び、Google の4つを消して Cloudflare の2つを入れました。

Studio を閉じて、旧 URL は Cloudflare で転送する ― 公開・通知メール・DNS 移行・LOG への改番を1日で|DNSSEC は先に外す。DS が消えるまで 45 分待った

切り替わりまで 10 分ほど。その間に転送の決まりを作った

ネームサーバーを変えても、.jp の台帳がすぐに新しい値を返すわけではありません。21:07 の時点では Google のまま、21:30 に Cloudflare の2つに変わっていました。待っている間に、Cloudflare 側で転送の決まりを作っておきました。切り替わった瞬間から効くようにするためです。

転送の決まり(Redirect Rules)は2つです。Wildcard pattern(* で URL のパターンを書く形)で作りました。

順名前条件(Request URL)行き先(Target URL)続きの引き継ぎ
1P2M pathshttps://adaptdesign.jp/pixelstomotors*https://pixelstomotors.com${1}あり(query も残す)
2Everything elsehttps://adaptdesign.jp/*https://pixelstomotors.com/なし

${1} は、* に一致した部分です。/pixelstomotors/record/day1 なら ${1} は /record/day1 になります。/pixelstomotors だけで来たときは ${1} が空なので、pixelstomotors.com に飛びます。決まりは上から順に評価されるので、1 が 2 より上にあることを一覧で確かめました。

最初に Request URL を *adaptdesign.jp/pixelstomotors* と書いたら、「プロトコル(https://)を含めてください。先頭の * は https://subdomain.example.com まで拾ってしまう」という注意が出ました。https:// から書く形に直しています。http:// で来た人は、Cloudflare が先に https:// へ回すので、この2つで拾えます。

Studio を閉じて、旧 URL は Cloudflare で転送する ― 公開・通知メール・DNS 移行・LOG への改番を1日で|切り替わりまで 10 分ほど。その間に転送の決まりを作った

切り替わったあとの確認

開いた URL結果
adaptdesign.jp/pixelstomotors/record/day1301 → pixelstomotors.com/record/day1(最後まで開くと 200)
adaptdesign.jp/pixelstomotors301 → pixelstomotors.com/
adaptdesign.jp/301 → pixelstomotors.com/
adaptdesign.jp/web_production_list301 → pixelstomotors.com/
http://adaptdesign.jp/pixelstomotors/manga/第1話?x=1301 → https://(その先で上の転送)
MX(メール)5本とも引ける
TXT3本とも引ける

記事の URL が記事まで届くようになりました。前回の記事で「次に決める」と書いた、旧 URL の転送方法はこれで決まりです。

Studio は非公開にして Free に下げた。古い DNS のキャッシュから来る 404 は数時間で消える

転送を確かめたあと、Studio の「公開ステータス」を非公開にし、プランを Free に下げました。Studio の CMS の中身は、LOG334 で作ったアーカイブ(iCloud の site_archive)と新しいサイトに全部あるので、Free の上限で消えても困りません。

非公開にした直後、外から何度か開くと、たまに 404 が返りました。この応答には Cloudflare の印(cf-ray というヘッダー)がなく、Cloudflare を通っていません。まだ古いネームサーバー(Google)を覚えている DNS キャッシュが、非公開になった Studio のサーバーに向けていました。ネームサーバーの情報の有効期限は 21600 秒(6 時間)だったので、その時間が過ぎれば全部 Cloudflare 側に切り替わります。

試行結果
1回目301 → pixelstomotors.com/record/day1(Cloudflare)
2回目404(cf-ray なし。古い DNS 経由で Studio へ)
3回目301 → pixelstomotors.com/record/day1(Cloudflare)

トップの動画の節は、ほかの節と同じ白い枠に入れ、スクロールバーの原因は 16px の食み出しだった

サイトを公開して、エイイチがトップを見て指摘しました。

動画セクションに上下のパディングがないのと、角丸設定、コンテンツ幅などが違います。確認してください。

Studio を閉じて、旧 URL は Cloudflare で転送する ― 公開・通知メール・DNS 移行・LOG への改番を1日で|トップの動画の節は、ほかの節と同じ白い枠に入れ、スクロールバーの原因は 16px の食み出しだった

トップの「Video | 解説動画」は、YouTube の新着を並べる埋め込み(iframe)です。Studio のときから使っていたものをそのまま置いていましたが、ほかの節(Archive・Series・Record)は .sec(上下 40px の余白)の中に .panel(幅 1280px・白い背景・角丸 16px・内側 32px)がある作りで、動画の節だけがその枠に入っていませんでした。埋め込みの中身の背景が白だったので、角丸も余白もない白い帯に見えていました。

直し方は、動画の節もほかと同じ .sec > .panel に入れ、埋め込みの背景を透明にすることです。左右の余白は埋め込み側(32px)をやめて、枠(.panel)に任せます。

直したあとに、もう1つ指摘がありました。

動画セクションにスクロールバーが出ちゃってるのも修正

Studio を閉じて、旧 URL は Cloudflare で転送する ― 公開・通知メール・DNS 移行・LOG への改番を1日で|トップの動画の節は、ほかの節と同じ白い枠に入れ、スクロールバーの原因は 16px の食み出しだった

埋め込みの中のスライダーは、カードの影が切れないように、窓(.ptm-viewport)を左右 16px ずつ外へ広げる作りでした(margin: -8px -16px 0)。前は埋め込み自体に 32px の余白があったので、その中に収まっていました。余白を 0 にしたことで、窓が iframe の幅を 16px はみ出し、横スクロールバーが出て、その高さぶんの縦スクロールバーも出ていました。

直し方は、埋め込み側に左右 16px の余白を戻し、サイト側の枠を同じ 16px だけ外へ出す(.embed-wide { margin: 0 -16px })ことです。カードの端はほかの節の内容と揃い、影の分の余白も確保されます。念のため、埋め込みの html/body を overflow: hidden にしました。

Studio を閉じて、旧 URL は Cloudflare で転送する ― 公開・通知メール・DNS 移行・LOG への改番を1日で|トップの動画の節は、ほかの節と同じ白い枠に入れ、スクロールバーの原因は 16px の食み出しだった
項目直す前直した後
枠なし(白い帯).sec > .panel(ほかの節と同じ)
上下の余白040px
角丸なし16px
幅1280px + 左右 32px1280px(枠の内側 32px)
埋め込みの背景白透明
「チャンネルを見る」の上の余白24px40px(「もっと見る」と同じ)
スクロールバー出る出ない

エイイチの画面では直っていないように見えましたが、ブラウザのキャッシュでした。外から取り直すと新しい版が返っていて、⌘+Shift+R で表示も変わりました。

記事の番号は DAY から LOG に変えた。表示と本文は置き換え、ファイル名とコードの中は変えない

DAY を変更したい。もう1日に何度も投稿するようになってしまったため。LOG に変更したい。

Studio を閉じて、旧 URL は Cloudflare で転送する ― 公開・通知メール・DNS 移行・LOG への改番を1日で|記事の番号は DAY から LOG に変えた。表示と本文は置き換え、ファイル名とコードの中は変えない

記事の番号「DAY342」は、もともと1日1本の記録だった名残です。今は1日に何本も出すので、DAY が日数を表さなくなっていました。LOG に変え、番号は続きにしました(DAY342 の次は LOG343。この記事です)。

URL は変えていません。古い記事の /record/day1 のような URL は、外から貼られたリンクが切れるので、そのまま残します。新しい記事はすでに意味のある slug なので、URL に番号は出ません。

何を変えて何を変えないかを、先に決めました。

対象扱い
記事カード・記事の見出しの札(DAY342)LOG342 に
記事本文・説明文・目次・図解の中の呼び方(「前回(DAY340)で」)LOG340 に
ファイル名(DAY342_01_….png、DAY340.md)変えない。R2 やリポジトリの実体と一致させる
コードブロック・実機ログの中変えない。再現性の記録のため
識別子(MM_PER_DEG_ELBOW_Z_DAY189)変えない
CMS 原本の表記ゆれ「DA13」LOG13 に
英語版の「DAY187EN」LOG187EN に

置き換えはスクリプトで行いました。見分けの決まりは正規表現1本です。

(?<![A-Za-z0-9_])DAY ?(\d{1,3})(EN)?(?![_\d]|\.[A-Za-z])

前に英数字や下線がない・後ろに下線や数字や「.拡張子」が続かない DAY+数字だけを対象にします。最初は前の境界を \b で書いていて、「後にDAY184で」のように日本語に続く DAY を拾えませんでした(Python の \b は日本語の文字も単語の一部とみなすので、「に」と「D」の間に境界がない)。前を「英数字と下線でない」に書き直して、拾えるようになりました。

HTML の本文では <pre> と <code> の中を飛ばし、Markdown では囲みコード・行内コード・【画像】【図解】の行を飛ばします。図解の HTML は <script>・<style>・タグの中を飛ばして、見える文字だけを置き換えました。

対象変えたもの
記事カードの一覧(cards.json)341件の札
Studio から移した記事(JSON)札 341件、本文の呼び方 570か所
Markdown の記事5本・49か所
図解の HTML58本・140か所
サムネイルのファイル名(public/thumbs/)DAY{N}.html → LOG{N}.html(26本)

一覧は番号の大きい順に並べた

あと記事は番号順に並べ直してください。

一覧は公開日時の新しい順でした。1日に複数出すと、公開した時刻の順に並び、番号と前後することがあります。LOG 番号の大きい順に変え、番号が同じか取れないもの(英語版など)だけ公開日時で並べるようにしました。

直す前の先頭は LOG339・LOG340・LOG342 の順で、直した後は LOG342・LOG340・LOG339・LOG338・… になりました。

記事の作り方の決まりを、Studio に貼る前提から、push すると公開される前提に書き換えた

これからはチャットに情報を出さないでもサイト側や、PC やクラウドにデータ情報があれば問題なくなります。

これまで article_rules.md には、記事・サムネイル・図解の HTML をチャットにも全文出力する決まりがありました。エイイチが Studio にコピペで貼るためです。サイトが GitHub から自動で作られるようになったので、この決まりは要らなくなりました。

変えた決まりは次のとおりです。

項目前後
公開Claude が出した本文を、エイイチが Studio に貼るClaude が p2m-site に入れて push する(画像は Mac 連携で R2 へ)
チャットに出すもの記事・HTML・SNS 本文の全文公開した URL と保存場所だけ
SNS の本文チャット末尾にベタ貼りtxt と json に保存。エイイチは iCloud の txt を開いて投稿する
図解の埋め込みStudio の embed ブロック記事の【図解】の行の位置にサイトが iframe で入れる
番号DAY{NNN}LOG{NNN}(ファイル名・画像フォルダも)

公開の手順は、画像を R2 へ(tools/upload_media.py)→ 記事を p2m-site へ(tools/add_article.py)→ build が通ることを確かめて push → 公開された URL を開いて確かめる、の4段です。MASTER.md のデータ保管にも、公開サイトのコード(p2m-site)と R2(画像・動画・メッセージ)の行を足しました。

あわせて、X・Facebook・LinkedIn・YouTube・GitHub のプロフィールのリンク、YouTube に公開済みの動画の概要欄、動画の作業フォルダにある概要欄と投稿文のファイル(82ファイル・153か所)の URL を、pixelstomotors.com に書き換えました。プロフィールと YouTube の概要欄はエイイチ、ファイルは Claude が直しています。

できたものと、残っていること

できたもの:

  • pixelstomotors.com を公開。仮の住所(workers.dev)は閉じた
  • メッセージフォームの内容が pixelstomotors@adaptdesign.jp にメールで届く(R2 にも残る)
  • adaptdesign.jp の DNS を Cloudflare に移し、旧 URL は記事まで届く形で pixelstomotors.com へ転送。メールは止めずに移せた
  • Studio のサイトを非公開にし、プランを Free に下げた
  • トップの動画の節をほかの節と同じ枠にし、スクロールバーを消した
  • 記事の番号を DAY から LOG に。一覧は番号順
  • article_rules.md と MASTER.md を、GitHub から自動公開する前提に書き換えた
  • SNS のプロフィール・YouTube の概要欄・動画の作業ファイルの URL を新ドメインに

残っていること:

  • アクセス解析を入れる(Cloudflare Web Analytics・Microsoft Clarity)。あわせてプライバシーポリシーを更新する
  • 訪れた人の迷いを読み取って、サイトを少しずつ変えていく仕組み(LOG342 の構想)
  • Cloudflare の Email Routing は今は送信にだけ使っている。受信の転送(form@ などへ来たメール)は決めていない

使ったコードの全文

リポジトリは GitHub の PixelstoMotors/p2m-site(非公開)です。この回に書いたか直したファイルの、直した後の全文です。

worker/index.js — サイトの入口。メッセージフォームの保存のあとにメールで知らせる notify() を足した。件名の符号化は改行なし、本文の base64 だけ 76 文字で折り返す

// サイトの入口。ほとんどのページは作っておいた静的ファイル(dist/)をそのまま返す。
// ここで処理するのは3つだけ:
// - /pixelstomotors/…   旧 URL の形。/pixelstomotors を外した URL へ 301 で転送する
// - /media/{ファイル名}  記事・4コマの画像と動画を R2(p2m-media)から返す(GitHub には入れないため)
// - /api/message         メッセージフォームの送信を R2(p2m-messages)に保存し、メールで知らせる
import { EmailMessage } from 'cloudflare:email';

export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    // 旧 URL の形(/pixelstomotors/…)で来たら、/pixelstomotors を外した URL へ恒久転送する
    if (url.pathname === '/pixelstomotors' || url.pathname.startsWith('/pixelstomotors/')) {
      const to = new URL(url.pathname.slice('/pixelstomotors'.length) || '/', url);
      to.search = url.search;
      return Response.redirect(to.toString(), 301);
    }
    if (url.pathname.startsWith('/media/')) return media(request, env, url);
    if (url.pathname === '/api/message') return message(request, env);
    return env.ASSETS.fetch(request);
  },
};

async function media(request, env, url) {
  if (request.method !== 'GET' && request.method !== 'HEAD') return new Response('Method Not Allowed', { status: 405 });
  const key = decodeURIComponent(url.pathname.slice('/media/'.length));
  if (!key || key.includes('/') || key.startsWith('.')) return new Response('Not Found', { status: 404 });
  // 動画の途中から再生できるよう、Range(一部だけの取得)にも答える
  const obj = await env.MEDIA.get(key, { range: request.headers, onlyIf: request.headers });
  if (obj === null) return new Response('Not Found', { status: 404 });
  const headers = new Headers();
  obj.writeHttpMetadata(headers);
  headers.set('etag', obj.httpEtag);
  headers.set('accept-ranges', 'bytes');
  headers.set('cache-control', 'public, max-age=86400');
  if (!('body' in obj) || !obj.body) return new Response(null, { status: 304, headers });
  let status = 200;
  if (obj.range && request.headers.has('range')) {
    // R2 の range は {offset, length} か {suffix}。キーがあっても値が undefined のことがあるため、値で判定する
    // ('suffix' in r で判定すると NaN になり、ブラウザが動画を再生しなかった)
    const r = obj.range;
    let start, end;
    if (typeof r.suffix === 'number') {
      start = Math.max(0, obj.size - r.suffix);
      end = obj.size - 1;
    } else {
      start = typeof r.offset === 'number' ? r.offset : 0;
      end = typeof r.length === 'number' ? Math.min(obj.size - 1, start + r.length - 1) : obj.size - 1;
    }
    headers.set('content-range', `bytes ${start}-${end}/${obj.size}`);
    headers.set('content-length', String(end - start + 1));
    status = 206;
  }
  return new Response(request.method === 'HEAD' ? null : obj.body, { status, headers });
}

async function message(request, env) {
  if (request.method !== 'POST') return new Response('Method Not Allowed', { status: 405 });
  let d;
  try { d = await request.json(); } catch { return json({ ok: false, error: '送信の形が正しくありません' }, 400); }
  const name = String(d.name || '').trim().slice(0, 200);
  const email = String(d.email || '').trim().slice(0, 300);
  const body = String(d.body || '').trim().slice(0, 10000);
  if (!name || !email || !body || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) || d.agree !== true) {
    return json({ ok: false, error: '入力を確かめてください' }, 400);
  }
  const at = new Date().toISOString();
  const key = `${at.replace(/[:.]/g, '-')}-${crypto.randomUUID().slice(0, 8)}.json`;
  await env.MESSAGES.put(key, JSON.stringify({ at, name, email, body, ua: request.headers.get('user-agent') || '' }, null, 1),
    { httpMetadata: { contentType: 'application/json' } });
  // 保存できたら、メールで知らせる。メールが送れなくても、保存は済んでいるので送信者には成功と返す
  try {
    await notify(env, { at, name, email, body, key });
  } catch (e) {
    console.error('メール通知に失敗', e && e.message);
  }
  return json({ ok: true });
}

// 届いたメッセージを MAIL(Cloudflare の送信の仕組み)でエイイチのメールへ送る。
// 送り元はこのドメインの form@pixelstomotors.com。返信先(Reply-To)に送信者のメールを入れる
async function notify(env, m) {
  if (!env.MAIL) return;
  const from = 'form@pixelstomotors.com';
  const to = 'pixelstomotors@adaptdesign.jp';
  const subject = `[Pixels to Motors] メッセージ: ${m.name}`;
  const text = [
    `名前: ${m.name}`,
    `メール: ${m.email}`,
    `受け取り: ${m.at}`,
    `保存先: p2m-messages/${m.key}`,
    '',
    m.body,
  ].join('\n');
  const raw = [
    `From: Pixels to Motors <${from}>`,
    `To: <${to}>`,
    `Reply-To: ${encodeWord(m.name)} <${m.email}>`,
    `Subject: ${encodeWord(subject)}`,
    `Date: ${new Date(m.at).toUTCString()}`,
    `Message-ID: <${m.key.replace(/\.json$/, '')}@pixelstomotors.com>`,
    'MIME-Version: 1.0',
    'Content-Type: text/plain; charset=utf-8',
    'Content-Transfer-Encoding: base64',
    '',
    base64(text),
  ].join('\r\n');
  await env.MAIL.send(new EmailMessage(from, to, raw));
}

// 日本語の見出しをメールの決まり(RFC 2047)で符号化する。見出しは1行に収めるので改行を入れない
function encodeWord(s) {
  return `=?utf-8?B?${base64(s, false)}?=`;
}

// fold=true のとき、本文用に 76 文字ごとに改行を入れる(見出しに入れると途中で切れて壊れる)
function base64(s, fold = true) {
  const bytes = new TextEncoder().encode(s);
  let bin = '';
  for (const b of bytes) bin += String.fromCharCode(b);
  const out = btoa(bin);
  return fold ? out.replace(/(.{76})/g, '$1\r\n') : out;
}

function json(o, status = 200) {
  return new Response(JSON.stringify(o), { status, headers: { 'content-type': 'application/json; charset=utf-8' } });
}

wrangler.jsonc — Cloudflare Workers の設定。send_email の binding(MAIL)を足した。宛先は Email Routing で確認済みのアドレスだけ

{
  // Cloudflare Workers の設定。
  // ページは作っておいた静的ファイル(dist/)。画像と動画・メッセージフォームだけ worker/index.js が受け持つ
  "name": "p2m-site",
  "main": "./worker/index.js",
  "compatibility_date": "2026-10-01",
  "assets": {
    "directory": "./dist",
    "binding": "ASSETS",
    "not_found_handling": "404-page",
    "html_handling": "auto-trailing-slash",
    // /media/ と /api/ と旧 URL の形(/pixelstomotors/…)は、静的ファイルより先に worker/index.js へ回す
    "run_worker_first": ["/media/*", "/api/*", "/pixelstomotors", "/pixelstomotors/*"]
  },
  "r2_buckets": [
    // 記事・4コマの画像と動画(GitHub には入れない。tools/upload_media.py で Mac から入れる)
    { "binding": "MEDIA", "bucket_name": "p2m-media" },
    // メッセージフォームで送られた内容
    { "binding": "MESSAGES", "bucket_name": "p2m-messages" }
  ],
  // メッセージフォームに届いた内容をメールで知らせる(Cloudflare の Email Routing で確認済みの宛先にだけ送れる)
  "send_email": [
    { "name": "MAIL", "destination_address": "pixelstomotors@adaptdesign.jp" }
  ]
}

src/lib/records.ts — 記事の一覧。札を LOG に、並び順を LOG 番号の大きい順に

import { getCollection } from 'astro:content';
import cards from '../data/cards.json';
import mangaCards from '../data/mangaCards.json';
import englishCards from '../data/englishCards.json';

export type Card = { day: string; series: string; published: string; title: string; slug: string; cover: string | null; thumb: string | null };
export type MangaCard = { slug: string; title: string; label: string; character: string; description: string; published: string; cover: string; comic: string; cover_alt: string; comic_alt: string };

// 一覧に並べる記事:Claude が書いた Markdown の記事 + Studio から移した記事
// 並び順は LOG 番号の大きい順(1日に何本も出すようになり、公開日時の順だと番号と前後することがあるため)。
// 番号が同じか取れないもの(英語版の LOG187EN など)は公開日時で並べる
export async function allCards(): Promise<Card[]> {
  const md = (await getCollection('records')).map((e) => ({
    day: `LOG${e.data.day}`, series: e.data.series, published: e.data.date.toISOString(),
    title: e.data.title, slug: e.data.slug, cover: null, thumb: `/thumbs/LOG${e.data.day}.html`,
  }));
  const seen = new Set(md.map((c) => c.slug));
  const studio = (cards as Card[]).filter((c) => !seen.has(c.slug));
  const num = (c: Card) => { const m = /^LOG(\d+)/.exec(c.day); return m ? Number(m[1]) : -1; };
  return [...md, ...studio].sort((a, b) => (num(b) - num(a)) || b.published.localeCompare(a.published));
}

export async function seriesCards(series: string): Promise<Card[]> {
  return (await allCards()).filter((c) => c.series === series);
}

export const allManga = mangaCards as MangaCard[];
export const allEnglish = englishCards as MangaCard[];

// シリーズのページ(URL → 記事のシリーズ名)。Studio の一覧の絞り込み条件と同じ
export const SERIES_PAGES: Record<string, { series: string; title: string; pageTitle: string; limit: number }> = {
  series1: { series: 'HRI', title: 'Drug Delivery Robot | 薬剤搬送ロボットハコブくん', pageTitle: 'Series1 | 薬剤搬送ロボット', limit: 12 },
  series2: { series: 'R2R2R', title: 'R2R2R | デジタルツイン学習-R2R2R(Real to Render to Real)', pageTitle: 'Series2 | R2R2R', limit: 12 },
  series3: { series: 'UV-C農業ロボット', title: 'UV-C Agricultural Robot | UV-C農業ロボット', pageTitle: 'Series3 | UV-C農業ロボット', limit: 20 },
  series4: { series: 'ロボティクスデザイン', title: 'Robotics Design Workflow | ロボティクスデザイン実務フロー', pageTitle: 'Series4 | ロボティクスデザイン実務フロー', limit: 12 },
  series5: { series: '考察記事', title: 'Deep Dive | 考察記事', pageTitle: 'Series5 | 考察記事', limit: 12 },
  series6: { series: 'フィジカルAI研究室', title: 'Physical AI Lab | フィジカルAI研究室', pageTitle: 'Series6 | フィジカルAI研究室', limit: 12 },
  series7: { series: '画像認識・物体検出', title: 'Image recognition and Object detection | 画像認識と物体検出', pageTitle: 'Series7 | 画像認識と物体検出', limit: 12 },
};

src/pages/index.astro — トップ。解説動画の節を .sec > .panel に入れ、埋め込みの枠を左右 16px 外へ出した

---
import Base from '../layouts/Base.astro';
import RecordCard from '../components/RecordCard.astro';
import { ymd } from '../lib/format';
import { media } from '../lib/media';
// SNS アイコン(Font Awesome Free 6.7.2・CC BY 4.0。ライセンス表記は各 SVG の中に残している)
import icGithub from '../icons/github.svg?raw';
import icLinkedin from '../icons/linkedin.svg?raw';
import icX from '../icons/square-x-twitter.svg?raw';
import icYoutube from '../icons/youtube.svg?raw';
import { allCards } from '../lib/records';
import MangaCard from '../components/MangaCard.astro';
import LoadMore from '../components/LoadMore.astro';
import { allManga } from '../lib/records';
// Studio のトップと同じく、4コマは新しい4話
const mangaCards = allManga.slice(0, 4);

const series = [
  { href: '/series1', img: 's-1920x1318_v-frms_4cd7792b-68a1-4239-ad08-0785c4e538c5.webp', en: 'Drug Delivery Robot', ja: '薬剤搬送ロボットハコブくん',
    desc: 'A multidisciplinary documentation of Hakobu — a hospital drug-delivery robot — covering HRI design, UI, autonomous navigation in Unity, and daily development logs, told alongside 4-panel manga.' },
  { href: '/series6', img: 's-1672x941_76620d86-fa7b-491f-b554-99c2f8981d8d.webp', en: 'Physical AI Lab', ja: 'フィジカルAI研究室',
    desc: 'Building a Physical AI research environment at home, from zero — reproducible by anyone. The first build: an arm that learns from failure.' },
  { href: '/series7', img: 's-1800x1200_v-frms_82af05c4-20df-4ea1-b88d-355ed8fabd63.webp', en: 'Image recognition and Object detection', ja: '画像認識と物体検出',
    desc: 'Relearning image recognition from the bottom up, one measurable step at a time — pixels, colour spaces, thresholds, morphology, contours, and the metrics that judge them. Nothing is assumed: every step is measured on real photographs first.' },
  { href: '/series5', img: 's-2528x1694_7a2b46a9-02c8-49d6-92c4-2e7994843726.webp', en: 'Deep Dive', ja: '考察記事',
    desc: 'Essays that go beyond the build log — examining why things work, what they mean, and where the field is heading.' },
  { href: '/series2', img: 's-1920x1318_v-frms_0d775000-730b-4ac2-90b5-d814a9f53fc6.webp', en: 'R2R2R', ja: 'デジタルツイン学習-R2R2R(Real to Render to Real)',
    desc: 'A hands-on project proving that robot arm motion can be designed without a robotics background — using only an iPhone for 3D scanning and Unity for simulation.' },
  { href: '/series4', img: 's-4480x3680_2e3487ec-2968-4b89-8bca-7062b48f6093.webp', en: 'Robotics Design Workflow', ja: 'ロボティクスデザイン実務フロー', desc: '' },
];
const records = await allCards();
---
<Base title="Pixels to Motors | 人とロボットの関わりを、シミュレーションでデザインする"
  description="29年のデザイナー経験を、いまロボティクスに応用しています。テーマはHRIシミュレーション——人がロボットをどう感じ、どう関わるかを、実機の前にデジタル上で設計すること。"
  ogImage="/site/ffb300c2-1071-4714-978c-87d386d5dfd3.png" path="/">

  <section class="hero">
    <div class="hero-text">
      <h1>Pixels to Motors | 人とロボットの関わりをシミュレーションでデザインする</h1>
      <p>29年のデザイナー経験を、いまロボティクスに応用しています。テーマはHRIシミュレーション——人がロボットをどう感じ、どう関わるかを、実機の前にデジタル上で設計すること。Unityで空間もシナリオもゼロから組み、人の間を動いて運び・手渡すサービスロボットの振る舞いを、成否の基準を物理精度ではなく人の体験に置いてつくります。実機はその先。過程の失敗も含めて公開しています。</p>
    </div>
    <video class="hero-video" src={media("/media/s-848x1056_e5500e41-e73b-4177-914e-b09a2d11f83c.mp4") + "#t=0.01"} controls muted autoplay loop playsinline></video>
  </section>

  <!-- 解説動画:YouTube の新着を GitHub の videos.json から読む埋め込み(Studio で使っていたものをそのまま) -->
  <section class="sec"><div class="panel">
    <div class="embed-wide"><iframe src="/embeds/top-video.html" sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox" title="解説動画" data-embed style="height:450px"></iframe></div>
  </div></section>

  <section class="sec"><div class="panel">
    <h2 class="sec-h">Archive | 4コママンガ</h2>
    <div class="grid4">{mangaCards.map((m) => <MangaCard item={m} />)}</div>
    <div class="center"><a class="btn-more" href="/manga-archive">もっと見る<i class="ms">arrow_forward</i></a></div>
  </div></section>

  <section class="sec"><div class="panel">
    <h2 class="sec-h">Series | シリーズ</h2>
    <div class="grid2">
      {series.map((s) => (
        <a class="scard" href={s.href}>
          <img src={media(`/media/${s.img}`)} alt="" loading="lazy" />
          <div class="sc-meta">
            <div class="sc-line"><span class="sc-en">{s.en}</span><span class="sc-ja">{s.ja}</span></div>
            {s.desc && <p class="sc-desc">{s.desc}</p>}
          </div>
        </a>
      ))}
    </div>
  </div></section>

  <section class="sec"><div class="panel">
    <h2 class="sec-h">Record | 記録</h2>
    <LoadMore limit={12} total={records.length}>{records.map((c) => <RecordCard item={c} />)}</LoadMore>
    <div class="embed-banner"><iframe src="/embeds/top-banner.html" sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox" title="お知らせ" data-embed style="height:240px"></iframe></div>
  </div></section>

  <section class="sec"><div class="panel">
    <h2 class="sec-h">Sole Prop | 事業所について</h2>
    <div class="prop">
      <div class="prop-person">
        <video class="prop-photo" src={media("/media/s-1280x720_ff64009f-cd40-4e7d-8ba3-350ff0d6c3ad.mp4")} poster={media("/media/s-2048x2048_5af0bc9f-8d23-4bdb-85e4-ada51bfd7bbd.webp")} muted autoplay loop playsinline></video>
        <p class="prop-role">Robotics UX &amp; HRI Designer</p>
        <p class="prop-name">島津 栄一</p>
      </div>
      <div class="prop-body">
        <p class="prop-lead">画面(Pixels)のデザインから、動き(Motors)のデザインへ</p>
        <p class="prop-text">29年にわたるデザイナーとしての知見を土台に、人とロボットの関わり(HRI)をシミュレーションで設計するデザイン事務所です。Unityなどのツールで空間もシナリオもゼロから組み、AIエージェント(Claude Code)を設計と実装の相棒に、人がロボットをどう感じ・どう関わるかを実機の前にデザインします。基準は物理精度ではなく体験の成立。画面上の「Pixels(デジタル)」の設計を、やがて現実の「Motors(モーター)」へ——その前半、体験を確かめるフェーズを公開しています。</p>
        <dl class="prop-facts">
          <div><dt>事業所名</dt><dd>ADAPT DESIGN</dd></div>
          <div><dt>設立</dt><dd>2021年10月</dd></div>
          <div class="wide"><dt>研究・実践分野</dt><dd>HRIシミュレーション / ロボティクスUX設計 / デジタルツイン / Unity / Physical AI / R2R2R</dd></div>
          <div><dt>SNS</dt><dd class="sns">
            <a href="https://github.com/PixelstoMotors/r2r2r-content" aria-label="GitHub" set:html={icGithub} />
            <a href="https://www.linkedin.com/in/eiichi-shimazu/" aria-label="LinkedIn" set:html={icLinkedin} />
            <a href="https://x.com/adaptdesignjp" aria-label="X" set:html={icX} />
            <a href="https://www.youtube.com/@PixelstoMotors" aria-label="YouTube" set:html={icYoutube} />
          </dd></div>
        </dl>
      </div>
    </div>
  </div></section>
</Base>

<style>
  .hero { max-width: 964px; margin: 0 auto; padding: 104px 0 40px; display: flex; gap: 40px; align-items: flex-start; }
  .hero-text { flex: 1 1 588px; min-width: 0; }
  .hero h1 { margin: 0 0 16px; font: 600 29px/40.6px var(--sans); }
  .hero p { margin: 0; font: 400 19px/1.6 'Noto Sans JP', sans-serif; color: #333; }
  .hero-video { flex: 0 0 336px; width: 336px; height: 320px; background: #111; border-radius: 4px; object-fit: cover; }
  .embed-wide { margin: 0 -16px; } /* 埋め込み側の左右 16px の余白ぶん外へ出し、カードの端をほかの節と揃える */
  .embed-wide iframe, .embed-banner iframe { display: block; width: 100%; border: 0; }
  .embed-banner { max-width: 964px; margin: 48px auto 0; }

  .grid4 { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 24px; }

  .grid2 { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 32px; }
  .scard { display: block; background: #fff; border-radius: 8px; box-shadow: 0 8px 16px rgba(0,0,0,.08); overflow: hidden; }
  .scard img { width: 100%; aspect-ratio: 3 / 2; object-fit: cover; }
  .sc-meta { padding: 16px; display: flex; flex-direction: column; gap: 8px; }
  .sc-line { display: flex; align-items: baseline; gap: 8px 12px; flex-wrap: wrap; }
  .sc-en { font: 600 24px/24px var(--sans); }
  .sc-ja { font: 500 14px/16.8px var(--sans); }
  .sc-desc { margin: 0; font: 400 13px/1.6 var(--sans); color: #555; }

  .center { display: flex; justify-content: center; margin-top: 40px; }
  .btn-more { display: inline-flex; align-items: center; gap: 16px; padding: 8px 24px; border-radius: 4px; background: var(--dark); color: #fff; font: 500 20px/30px var(--sans); }

  .prop { display: flex; gap: 40px; }
  .prop-person { flex: 0 0 160px; text-align: center; }
  .prop-photo { width: 160px; height: 160px; border-radius: 50%; object-fit: cover; display: block; }
  .prop-role { margin: 12px 0 4px; font: 300 12px/1.3 var(--sans); color: #888; }
  .prop-name { margin: 0; font: 500 20px/20px var(--sans); }
  .prop-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 12px; }
  .prop-lead { margin: 0; padding: 10px 8px; background: #f7f7f7; font: 600 18px/1.4 var(--sans); }
  .prop-text { margin: 0; font: 400 14px/1.5 var(--sans); color: #333; }
  .prop-facts { margin: 8px 0 0; display: flex; flex-wrap: wrap; gap: 16px 40px; }
  .prop-facts dt { font: 300 12px/15.6px var(--sans); color: #888; }
  .prop-facts dd { margin: 4px 0 0; font: 400 14px/1.5 var(--sans); }
  .sns { display: flex; gap: 12px; }
  .sns a { display: inline-flex; color: #242424; }
  .sns a :global(svg) { height: 18px; width: auto; display: block; }
  .sns a:hover { color: #666; }

  @media (max-width: 1023px) {
    .hero { padding: 88px 24px 32px; flex-direction: column; }
    .hero-video { width: 100%; flex-basis: auto; }
    .grid4 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  }
  @media (max-width: 599px) {
    .hero { padding: 72px 24px 24px; }
    .hero h1 { font-size: 24px; line-height: 1.5; }
    .hero p { font-size: 16px; line-height: 1.75; }
    .grid2 { grid-template-columns: 1fr; gap: 24px; }
    .grid4 { gap: 16px; }
    .prop { flex-direction: column; align-items: center; gap: 24px; }
  }
</style>

public/embeds/top-video.html — 解説動画の埋め込み。背景を透明に、左右 16px の余白、overflow: hidden

<!DOCTYPE html><html lang="ja"><head><meta charset="utf-8"></head><body>
<!--
  Pixels to Motors | Video | 解説動画セクション(Studio 埋め込み用・スライダー版)

  ■ 動画の一覧は、DATA_URL の videos.json から読む。
    videos.json は GitHub(PixelstoMotors/pixelstomotors-videos)の Actions が 6 時間ごとに
    YouTube の新着から作り直す(ショートは除く)。
    動画を上げたあと、このコードを貼り直す必要はない。
  ■ 読めないときは、下の FALLBACK_VIDEOS(貼った時点の一覧)を出す。
  ■ 埋め込みボックスの高さ:PC は 450px 前後(見出し+カード+操作の行+ボタン)。
-->


<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="">
<link href="https://fonts.googleapis.com/css?display=swap&amp;family=Inter:300,400,500,600|Noto+Sans+JP:300,400,500,700" rel="stylesheet">
<style>
  /* サイト本体と同じ Google Fonts の読み込み(Inter・Noto Sans JP)。
     Studio の埋め込みでは <head> の <link> が効かないことがあるため、<style> の中からも読み込む */
  @import url("https://fonts.googleapis.com/css?display=swap&family=Inter:300,400,500,600|Noto+Sans+JP:300,400,500,700");
  /* 背景はサイト側の白い枠(.panel)に任せ、ここは透明にする */
  html, body { margin: 0; padding: 0; background: transparent; overflow: hidden; }
  * { box-sizing: border-box; }

  .ptm-video {
    padding: 0 16px;
    font-family: Inter, "Noto Sans JP", sans-serif;
    color: #242424;
    -webkit-font-smoothing: antialiased;
  }
  .ptm-video p { margin: 0; }
  .ptm-video h2 { margin: 0 0 32px; font-size: 24px; font-weight: 600; line-height: 36px; }

  /* スライダーの窓。影が切れないよう上下左右に余白を持たせ、同じだけ外側を引く */
  .ptm-viewport { margin: -8px -16px 0; padding: 8px 16px 16px; overflow: hidden; }
  /* 左右 16px は、影を切らずにスライダーの窓を外へ広げるための余白。サイト側の枠(.embed-wide)が同じ 16px だけ外へ出している */
  .ptm-track {
    display: flex;
    gap: 32px;
    transition: transform .6s cubic-bezier(.22, .61, .36, 1);
    will-change: transform;
    touch-action: pan-y;
  }
  .ptm-slide { flex: 0 0 100%; min-width: 0; }

  .ptm-card {
    display: flex;
    background: #fff;
    border-radius: 8px;
    box-shadow: 0 8px 16px rgba(0, 0, 0, 0.08);
    overflow: hidden;
    color: inherit;
    text-decoration: none;
    -webkit-user-drag: none;
  }
  .ptm-thumb { position: relative; flex: 0 0 432px; width: 432px; height: 243px; background: #141414; }
  .ptm-thumb img { display: block; width: 100%; height: 100%; object-fit: cover; transition: opacity .2s ease; -webkit-user-drag: none; }
  .ptm-card:hover .ptm-thumb img { opacity: .88; }
  .ptm-dur {
    position: absolute; right: 8px; bottom: 8px; padding: 2px 6px; border-radius: 4px;
    background: rgba(0, 0, 0, 0.8); color: #fff; font-size: 12px; font-weight: 500; line-height: 16px;
  }
  .ptm-body { flex: 1; min-width: 0; padding: 24px; display: flex; flex-direction: column; gap: 12px; }
  .ptm-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; }
  .ptm-tag { padding: 2px 6px; border-radius: 2px; background: #EDF0F0; color: #888; font-size: 10px; font-weight: 500; line-height: 13px; }
  .ptm-new { padding: 2px 6px; border-radius: 2px; background: #242424; color: #fff; font-size: 10px; font-weight: 600; line-height: 13px; letter-spacing: .06em; }
  .ptm-date { color: #888; font-size: 10px; font-weight: 300; line-height: 13px; }
  .ptm-title { font-size: 18px; font-weight: 600; line-height: 27px; }
  .ptm-desc { color: #555; font-size: 14px; font-weight: 400; line-height: 24px; }

  /* 操作の行:左に番号、中央に点、右に矢印 */
  .ptm-ctrl { display: flex; align-items: center; gap: 16px; margin-top: 0; }
  .ptm-count { min-width: 64px; color: #888; font-size: 12px; font-weight: 500; letter-spacing: .08em; font-variant-numeric: tabular-nums; }
  .ptm-count b { color: #242424; font-weight: 600; }
  .ptm-dots { flex: 1; display: flex; justify-content: center; gap: 8px; }
  .ptm-dot {
    width: 8px; height: 8px; padding: 0; border: 0; border-radius: 4px; background: #D6DADA; cursor: pointer;
    transition: width .3s ease, background .3s ease;
  }
  .ptm-dot[aria-current="true"] { width: 28px; background: #242424; }
  .ptm-arrows { display: flex; gap: 8px; }
  .ptm-arrow, .ptm-dot { font-family: inherit; }
  .ptm-arrow {
    width: 40px; height: 40px; border-radius: 50%; border: 1px solid #D6DADA; background: #fff; color: #242424;
    display: grid; place-items: center; cursor: pointer; transition: border-color .2s ease, background .2s ease;
  }
  .ptm-arrow:hover { border-color: #242424; }
  .ptm-arrow:disabled { opacity: .35; cursor: default; border-color: #D6DADA; }
  .ptm-arrow svg { width: 18px; height: 18px; }

  .ptm-more { display: flex; justify-content: center; margin-top: 40px; }
  .ptm-btn {
    display: inline-flex; align-items: center; gap: 16px; min-height: 48px; padding: 8px 24px; border-radius: 4px;
    background: #242424; color: #fff; font-size: 20px; font-weight: 500; line-height: 30px; text-decoration: none; transition: opacity .2s ease;
  }
  .ptm-btn:hover { opacity: .8; }
  .ptm-btn svg { width: 20px; height: 20px; flex: none; }

  @media (max-width: 923px) {
    .ptm-thumb { flex-basis: 320px; width: 320px; height: 180px; }
    .ptm-body { padding: 16px 20px; gap: 8px; }
    .ptm-desc { display: none; }
  }
  @media (max-width: 663px) {
    .ptm-thumb { flex-basis: 240px; width: 240px; height: 135px; }
    .ptm-body { padding: 12px 16px; }
    .ptm-title { font-size: 16px; line-height: 24px; }
  }
  @media (max-width: 507px) {
    .ptm-slide { flex-basis: 100%; }
    .ptm-card { flex-direction: column; max-width: 382px; }
    .ptm-thumb { flex-basis: auto; width: 100%; height: auto; aspect-ratio: 16 / 9; }
    .ptm-body { padding: 12px 16px 16px; gap: 8px; }
    .ptm-title { font-size: 18px; line-height: 27px; }
    .ptm-count { min-width: 48px; }
    .ptm-arrow { width: 36px; height: 36px; }
  }
  @media (max-width: 540px) {
    .ptm-video { padding: 0 16px; }
    .ptm-btn { min-height: 42px; font-size: 16px; line-height: 26px; }
    .ptm-btn svg { width: 16px; height: 16px; }
  }
  @media (prefers-reduced-motion: reduce) {
    .ptm-track { transition: none; }
  }
</style>


<section class="ptm-video" aria-roledescription="carousel" aria-label="解説動画">
  <h2>Video | 解説動画</h2>

  <div class="ptm-viewport">
    <div class="ptm-track" id="ptm-track" style="transform: translateX(calc(-100% - 32px));"><div class="ptm-slide" role="group" aria-roledescription="slide" aria-label="1 / 6"><a class="ptm-card" href="https://www.youtube.com/watch?v=drHOxzvrcn8" target="_blank" rel="noopener" draggable="false" tabindex="-1"><div class="ptm-thumb"><img src="https://i.ytimg.com/vi/drHOxzvrcn8/maxresdefault.jpg" data-fallback="https://i.ytimg.com/vi/drHOxzvrcn8/hqdefault.jpg" alt="" width="1280" height="720" draggable="false"><span class="ptm-dur">13:36</span></div><div class="ptm-body"><div class="ptm-meta"><p class="ptm-new">NEW</p><p class="ptm-tag">フィジカルAI解説</p><p class="ptm-date">2026/10/15</p></div><p class="ptm-title">AIエージェントにロボットのコードを書かせたら、原因を7回取り違えた</p><p class="ptm-desc">エイイチは、本業のデザイナーをしながら、ロボティクスの研究と開発をしています。その中で分かったことと、そこから考えた見立てを、ハコブくんがお伝えします。</p></div></a></div><div class="ptm-slide" role="group" aria-roledescription="slide" aria-label="2 / 6"><a class="ptm-card" href="https://www.youtube.com/watch?v=48XTmtYkNRg" target="_blank" rel="noopener" draggable="false" tabindex="0"><div class="ptm-thumb"><img src="https://i.ytimg.com/vi/48XTmtYkNRg/maxresdefault.jpg" data-fallback="https://i.ytimg.com/vi/48XTmtYkNRg/hqdefault.jpg" alt="" width="1280" height="720" loading="lazy" draggable="false"><span class="ptm-dur">10:28</span></div><div class="ptm-body"><div class="ptm-meta"><p class="ptm-new">NEW</p><p class="ptm-tag">フィジカルAI解説</p><p class="ptm-date">2026/10/14</p></div><p class="ptm-title">ジャングリア沖縄の赤字89億円、AIとロボットで埋められるか勝手に試算してみた</p><p class="ptm-desc">エイイチは、本業のデザイナーをしながら、ロボティクスの研究と開発をしています。その中で分かったことと、そこから考えた見立てを、ハコブくんがお伝えします。</p></div></a></div><div class="ptm-slide" role="group" aria-roledescription="slide" aria-label="3 / 6"><a class="ptm-card" href="https://www.youtube.com/watch?v=wFg77gRg8RU" target="_blank" rel="noopener" draggable="false" tabindex="-1"><div class="ptm-thumb"><img src="https://i.ytimg.com/vi/wFg77gRg8RU/maxresdefault.jpg" data-fallback="https://i.ytimg.com/vi/wFg77gRg8RU/hqdefault.jpg" alt="" width="1280" height="720" loading="lazy" draggable="false"><span class="ptm-dur">14:17</span></div><div class="ptm-body"><div class="ptm-meta"><p class="ptm-new">NEW</p><p class="ptm-tag">フィジカルAI解説</p><p class="ptm-date">2026/10/14</p></div><p class="ptm-title">フィジカルAIのお金はどこへ流れているか|日本・米国・中国のスタートアップ投資</p><p class="ptm-desc">エイイチは、本業のデザイナーをしながら、ロボティクスの研究と開発をしています。その中で分かったことと、そこから考えた見立てを、ハコブくんがお伝えします。</p></div></a></div><div class="ptm-slide" role="group" aria-roledescription="slide" aria-label="4 / 6"><a class="ptm-card" href="https://www.youtube.com/watch?v=3lwqALyXiY8" target="_blank" rel="noopener" draggable="false" tabindex="-1"><div class="ptm-thumb"><img src="https://i.ytimg.com/vi/3lwqALyXiY8/maxresdefault.jpg" data-fallback="https://i.ytimg.com/vi/3lwqALyXiY8/hqdefault.jpg" alt="" width="1280" height="720" loading="lazy" draggable="false"><span class="ptm-dur">14:43</span></div><div class="ptm-body"><div class="ptm-meta"><p class="ptm-new">NEW</p><p class="ptm-tag">フィジカルAI解説</p><p class="ptm-date">2026/10/13</p></div><p class="ptm-title">自動運転は「暴走」するのか ― ロボタクシーのリコールの届け出に、ハッキングの記載はなかった</p><p class="ptm-desc">エイイチは、本業のデザイナーをしながら、ロボティクスの研究と開発をしています。その中で分かったことと、そこから考えた見立てを、ハコブくんがお伝えします。</p></div></a></div><div class="ptm-slide" role="group" aria-roledescription="slide" aria-label="5 / 6"><a class="ptm-card" href="https://www.youtube.com/watch?v=wGlD_LJrlPo" target="_blank" rel="noopener" draggable="false" tabindex="-1"><div class="ptm-thumb"><img src="https://i.ytimg.com/vi/wGlD_LJrlPo/maxresdefault.jpg" data-fallback="https://i.ytimg.com/vi/wGlD_LJrlPo/hqdefault.jpg" alt="" width="1280" height="720" loading="lazy" draggable="false"><span class="ptm-dur">12:30</span></div><div class="ptm-body"><div class="ptm-meta"><p class="ptm-new">NEW</p><p class="ptm-tag">フィジカルAIの現在地</p><p class="ptm-date">2026/10/12</p></div><p class="ptm-title">人型ロボットの出荷は4倍。ロボットにも「ChatGPTの瞬間」は来たのか</p><p class="ptm-desc">エイイチは、本業のデザイナーをしながら、ロボティクスの研究と開発をしています。その中で分かったことと、そこから考えた見立てを、ハコブくんがお伝えします。</p></div></a></div><div class="ptm-slide" role="group" aria-roledescription="slide" aria-label="6 / 6"><a class="ptm-card" href="https://www.youtube.com/watch?v=iaJLQYEg_vE" target="_blank" rel="noopener" draggable="false" tabindex="-1"><div class="ptm-thumb"><img src="https://i.ytimg.com/vi/iaJLQYEg_vE/maxresdefault.jpg" data-fallback="https://i.ytimg.com/vi/iaJLQYEg_vE/hqdefault.jpg" alt="" width="1280" height="720" loading="lazy" draggable="false"><span class="ptm-dur">2:54</span></div><div class="ptm-body"><div class="ptm-meta"><p class="ptm-tag">フィジカルAI解説</p><p class="ptm-date">2026/03/24</p></div><p class="ptm-title">R2R2Rプロジェクト - mechArm 270Pi 開封の儀</p><p class="ptm-desc">エイイチは、本業のデザイナーをしながら、ロボティクスの研究と開発をしています。その中で分かったことと、そこから考えた見立てを、ハコブくんがお伝えします。</p></div></a></div></div>
  </div>

  <div class="ptm-ctrl" id="ptm-ctrl">
    <p class="ptm-count" id="ptm-count"><b>02</b> / 06</p>
    <div class="ptm-dots" id="ptm-dots" role="tablist"><button class="ptm-dot" type="button" role="tab" aria-label="1本目へ" aria-current="false"></button><button class="ptm-dot" type="button" role="tab" aria-label="2本目へ" aria-current="true"></button><button class="ptm-dot" type="button" role="tab" aria-label="3本目へ" aria-current="false"></button><button class="ptm-dot" type="button" role="tab" aria-label="4本目へ" aria-current="false"></button><button class="ptm-dot" type="button" role="tab" aria-label="5本目へ" aria-current="false"></button><button class="ptm-dot" type="button" role="tab" aria-label="6本目へ" aria-current="false"></button></div>
    <div class="ptm-arrows">
      <button class="ptm-arrow" id="ptm-prev" type="button" aria-label="前の動画">
        <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M7.825 13H20v-2H7.825l5.6-5.6L12 4l-8 8 8 8 1.425-1.4-5.6-5.6Z"></path></svg>
      </button>
      <button class="ptm-arrow" id="ptm-next" type="button" aria-label="次の動画">
        <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M16.175 13H4v-2h12.175l-5.6-5.6L12 4l8 8-8 8-1.425-1.4 5.6-5.6Z"></path></svg>
      </button>
    </div>
  </div>

  <div class="ptm-more">
    <a class="ptm-btn" href="https://www.youtube.com/@PixelstoMotors" target="_blank" rel="noopener">
      <span>チャンネルを見る</span>
      <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M16.175 13H4v-2h12.175l-5.6-5.6L12 4l8 8-8 8-1.425-1.4 5.6-5.6Z"></path></svg>
    </a>
  </div>
</section>

<script>
(function () {
  // GitHub Actions が更新する videos.json の URL(読めないときは FALLBACK_VIDEOS を使う)
  var DATA_URL = "https://raw.githubusercontent.com/PixelstoMotors/pixelstomotors-videos/main/site/youtube/videos.json";
  var AUTOPLAY_MS = 6000;     // 自動で次へ送る間隔。0 にすると自動送りしない
  var NEW_DAYS = 7;           // 公開からこの日数以内は「NEW」を付ける

  var FALLBACK_VIDEOS = [
  {
    "id": "drHOxzvrcn8",
    "url": "https://www.youtube.com/watch?v=drHOxzvrcn8",
    "thumb": "https://i.ytimg.com/vi/drHOxzvrcn8/maxresdefault.jpg",
    "thumb_fallback": "https://i.ytimg.com/vi/drHOxzvrcn8/hqdefault.jpg",
    "title": "AIエージェントにロボットのコードを書かせたら、原因を7回取り違えた",
    "tag": "フィジカルAI解説",
    "date": "2026/10/15",
    "published": "2026-10-15T09:30:26+00:00",
    "duration": "13:36",
    "description": "エイイチは、本業のデザイナーをしながら、ロボティクスの研究と開発をしています。その中で分かったことと、そこから考えた見立てを、ハコブくんがお伝えします。",
    "is_short": false
  },
  {
    "id": "48XTmtYkNRg",
    "url": "https://www.youtube.com/watch?v=48XTmtYkNRg",
    "thumb": "https://i.ytimg.com/vi/48XTmtYkNRg/maxresdefault.jpg",
    "thumb_fallback": "https://i.ytimg.com/vi/48XTmtYkNRg/hqdefault.jpg",
    "title": "ジャングリア沖縄の赤字89億円、AIとロボットで埋められるか勝手に試算してみた",
    "tag": "フィジカルAI解説",
    "date": "2026/10/14",
    "published": "2026-10-14T10:00:23+00:00",
    "duration": "10:28",
    "description": "エイイチは、本業のデザイナーをしながら、ロボティクスの研究と開発をしています。その中で分かったことと、そこから考えた見立てを、ハコブくんがお伝えします。",
    "is_short": false
  },
  {
    "id": "wFg77gRg8RU",
    "url": "https://www.youtube.com/watch?v=wFg77gRg8RU",
    "thumb": "https://i.ytimg.com/vi/wFg77gRg8RU/maxresdefault.jpg",
    "thumb_fallback": "https://i.ytimg.com/vi/wFg77gRg8RU/hqdefault.jpg",
    "title": "フィジカルAIのお金はどこへ流れているか|日本・米国・中国のスタートアップ投資",
    "tag": "フィジカルAI解説",
    "date": "2026/10/14",
    "published": "2026-10-13T23:45:17+00:00",
    "duration": "14:17",
    "description": "エイイチは、本業のデザイナーをしながら、ロボティクスの研究と開発をしています。その中で分かったことと、そこから考えた見立てを、ハコブくんがお伝えします。",
    "is_short": false
  },
  {
    "id": "3lwqALyXiY8",
    "url": "https://www.youtube.com/watch?v=3lwqALyXiY8",
    "thumb": "https://i.ytimg.com/vi/3lwqALyXiY8/maxresdefault.jpg",
    "thumb_fallback": "https://i.ytimg.com/vi/3lwqALyXiY8/hqdefault.jpg",
    "title": "自動運転は「暴走」するのか ― ロボタクシーのリコールの届け出に、ハッキングの記載はなかった",
    "tag": "フィジカルAI解説",
    "date": "2026/10/13",
    "published": "2026-10-13T10:30:23+00:00",
    "duration": "14:43",
    "description": "エイイチは、本業のデザイナーをしながら、ロボティクスの研究と開発をしています。その中で分かったことと、そこから考えた見立てを、ハコブくんがお伝えします。",
    "is_short": false
  },
  {
    "id": "wGlD_LJrlPo",
    "url": "https://www.youtube.com/watch?v=wGlD_LJrlPo",
    "thumb": "https://i.ytimg.com/vi/wGlD_LJrlPo/maxresdefault.jpg",
    "thumb_fallback": "https://i.ytimg.com/vi/wGlD_LJrlPo/hqdefault.jpg",
    "title": "人型ロボットの出荷は4倍。ロボットにも「ChatGPTの瞬間」は来たのか",
    "tag": "フィジカルAIの現在地",
    "date": "2026/10/12",
    "published": "2026-10-12T07:37:22+00:00",
    "duration": "12:30",
    "description": "エイイチは、本業のデザイナーをしながら、ロボティクスの研究と開発をしています。その中で分かったことと、そこから考えた見立てを、ハコブくんがお伝えします。",
    "is_short": false
  },
  {
    "id": "iaJLQYEg_vE",
    "url": "https://www.youtube.com/watch?v=iaJLQYEg_vE",
    "thumb": "https://i.ytimg.com/vi/iaJLQYEg_vE/maxresdefault.jpg",
    "thumb_fallback": "https://i.ytimg.com/vi/iaJLQYEg_vE/hqdefault.jpg",
    "title": "R2R2Rプロジェクト - mechArm 270Pi 開封の儀",
    "tag": "フィジカルAI解説",
    "date": "2026/03/24",
    "published": "2026-03-24T11:34:32+00:00",
    "duration": "2:54",
    "description": "エイイチは、本業のデザイナーをしながら、ロボティクスの研究と開発をしています。その中で分かったことと、そこから考えた見立てを、ハコブくんがお伝えします。",
    "is_short": false
  }
];

  var track = document.getElementById("ptm-track");
  var dotsEl = document.getElementById("ptm-dots");
  var countEl = document.getElementById("ptm-count");
  var prev = document.getElementById("ptm-prev");
  var next = document.getElementById("ptm-next");
  var ctrl = document.getElementById("ptm-ctrl");
  var videos = [], index = 0, timer = null, paused = false;

  function esc(s) {
    return String(s == null ? "" : s).replace(/[&<>"']/g, function (c) {
      return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c];
    });
  }
  function isNew(v) {
    var t = Date.parse(v.published);
    return !isNaN(t) && (Date.now() - t) < NEW_DAYS * 86400000;
  }
  function pad(n) { return (n < 10 ? "0" : "") + n; }

  function render(list) {
    videos = list.filter(function (v) { return v && v.id && !v.is_short; });
    if (!videos.length) return;
    track.innerHTML = videos.map(function (v, i) {
      return '<div class="ptm-slide" role="group" aria-roledescription="slide" aria-label="' + (i + 1) + ' / ' + videos.length + '">' +
        '<a class="ptm-card" href="' + esc(v.url) + '" target="_blank" rel="noopener" draggable="false">' +
          '<div class="ptm-thumb">' +
            '<img src="' + esc(v.thumb) + '" data-fallback="' + esc(v.thumb_fallback || "") + '" alt="" width="1280" height="720" ' + (i ? 'loading="lazy"' : '') + ' draggable="false">' +
            (v.duration ? '<span class="ptm-dur">' + esc(v.duration) + '</span>' : '') +
          '</div>' +
          '<div class="ptm-body">' +
            '<div class="ptm-meta">' +
              (isNew(v) ? '<p class="ptm-new">NEW</p>' : '') +
              (v.tag ? '<p class="ptm-tag">' + esc(v.tag) + '</p>' : '') +
              '<p class="ptm-date">' + esc(v.date) + '</p>' +
            '</div>' +
            '<p class="ptm-title">' + esc(v.title) + '</p>' +
            (v.description ? '<p class="ptm-desc">' + esc(v.description) + '</p>' : '') +
          '</div>' +
        '</a></div>';
    }).join("");
    // maxresdefault が無い動画は hqdefault に切り替える(YouTube は無いとき 120px の灰色画像を返す)
    Array.prototype.forEach.call(track.querySelectorAll("img"), function (img) {
      function check() {
        if (img.naturalWidth && img.naturalWidth < 200 && img.dataset.fallback) {
          img.src = img.dataset.fallback; img.dataset.fallback = "";
        }
      }
      img.addEventListener("load", check);
      img.addEventListener("error", function () { if (img.dataset.fallback) { img.src = img.dataset.fallback; img.dataset.fallback = ""; } });
      if (img.complete) check();
    });
    dotsEl.innerHTML = videos.map(function (v, i) {
      return '<button class="ptm-dot" type="button" role="tab" aria-label="' + (i + 1) + '本目へ"></button>';
    }).join("");
    Array.prototype.forEach.call(dotsEl.children, function (b, i) {
      b.addEventListener("click", function () { go(i); restart(); });
    });
    ctrl.style.display = videos.length > 1 ? "" : "none";
    index = 0;
    go(0, true);
    restart();
  }

  function go(i, instant) {
    if (!videos.length) return;
    index = (i + videos.length) % videos.length;
    if (instant) { track.style.transition = "none"; }
    track.style.transform = "translateX(calc(" + (-index * 100) + "% - " + (index * 32) + "px))";
    if (instant) { track.offsetHeight; track.style.transition = ""; }
    Array.prototype.forEach.call(dotsEl.children, function (b, k) { b.setAttribute("aria-current", k === index ? "true" : "false"); });
    countEl.innerHTML = "<b>" + pad(index + 1) + "</b> / " + pad(videos.length);
    Array.prototype.forEach.call(track.querySelectorAll(".ptm-card"), function (a, k) { a.tabIndex = k === index ? 0 : -1; });
  }

  function restart() {
    if (timer) clearInterval(timer);
    if (!AUTOPLAY_MS || videos.length < 2) return;
    if (window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
    timer = setInterval(function () { if (!paused && !document.hidden) go(index + 1); }, AUTOPLAY_MS);
  }

  prev.addEventListener("click", function () { go(index - 1); restart(); });
  next.addEventListener("click", function () { go(index + 1); restart(); });
  var section = document.querySelector(".ptm-video");
  section.addEventListener("mouseenter", function () { paused = true; });
  section.addEventListener("mouseleave", function () { paused = false; });
  section.addEventListener("focusin", function () { paused = true; });
  section.addEventListener("focusout", function () { paused = false; });
  section.addEventListener("keydown", function (e) {
    if (e.key === "ArrowLeft") { go(index - 1); restart(); }
    if (e.key === "ArrowRight") { go(index + 1); restart(); }
  });

  // スワイプ(指・マウスのドラッグ)。少しだけ動かしたときはリンクのクリックとして扱う
  var startX = null, startY = 0, dx = 0, dragging = false, moved = false;
  track.addEventListener("pointerdown", function (e) {
    startX = e.clientX; startY = e.clientY; dx = 0; dragging = false; moved = false;
  });
  window.addEventListener("pointermove", function (e) {
    if (startX === null) return;
    dx = e.clientX - startX;
    if (!dragging && Math.abs(dx) > 8 && Math.abs(dx) > Math.abs(e.clientY - startY)) {
      dragging = true; paused = true; track.style.transition = "none";
    }
    if (dragging) {
      moved = true;
      track.style.transform = "translateX(calc(" + (-index * 100) + "% - " + (index * 32) + "px + " + dx + "px))";
    }
  });
  window.addEventListener("pointerup", function () {
    if (startX === null) return;
    track.style.transition = "";
    if (dragging) {
      var w = track.parentNode.clientWidth;
      if (dx < -w * 0.15) go(index + 1); else if (dx > w * 0.15) go(index - 1); else go(index);
      restart();
    }
    startX = null; dragging = false; paused = false;
  });
  track.addEventListener("click", function (e) { if (moved) { e.preventDefault(); moved = false; } }, true);

  render(FALLBACK_VIDEOS);
  if (DATA_URL) {
    fetch(DATA_URL + (DATA_URL.indexOf("?") < 0 ? "?" : "&") + "t=" + Math.floor(Date.now() / 600000), { cache: "no-store" })
      .then(function (r) { if (!r.ok) throw new Error(r.status); return r.json(); })
      .then(function (d) { if (d && d.videos && d.videos.length) render(d.videos); })
      .catch(function () { /* 読めなければ FALLBACK_VIDEOS のまま */ });
  }
})();
</script>

<script>(function(){var last=0;
function measure(){var b=document.body;if(!b)return 0;var cs=getComputedStyle(b);var bottom=0;
for(var i=0;i<b.children.length;i++){var el=b.children[i];var s=getComputedStyle(el);if(s.position==='fixed'||s.display==='none'||el.tagName==='SCRIPT'||el.tagName==='STYLE')continue;
var r=el.getBoundingClientRect();var mb=parseFloat(s.marginBottom)||0;if(r.bottom+mb>bottom)bottom=r.bottom+mb;}
return Math.ceil(bottom+window.scrollY+(parseFloat(cs.paddingBottom)||0)+(parseFloat(cs.marginBottom)||0));}
function send(){var h=measure();if(h>0&&Math.abs(h-last)>1){last=h;parent.postMessage({p2mHeight:h},'*');}}
window.addEventListener('load',send);window.addEventListener('resize',send);
if(window.ResizeObserver&&document.body){new ResizeObserver(send).observe(document.body);}setInterval(send,1000);send();})();</script></body></html>

tools/add_article.py — 記事を入れる道具。LOG{N}_article.md・LOG{N}_images/ を読むように

"""記事の作業フォルダ(iCloud)から、記事1本をサイトに入れる。

使い方:
  python3 tools/add_article.py <作業フォルダ> <LOG番号> <シリーズ名>

やること:
- LOG{N}_article.md      → src/content/records/LOG{N}.md(先頭の項目に series を足す)
- LOG{N}_thumbnail.html  → public/thumbs/LOG{N}.html
- 本文の【図解】の HTML  → public/embeds/(図解の高さを親ページに知らせる仕組みを足す)
- 本文の【画像】の画像    → あるかを確かめるだけ。画像は GitHub に入れず、Mac で tools/upload_media.py を使って R2 に入れる
"""
import re, shutil, sys
from pathlib import Path

work, day, series = Path(sys.argv[1]), sys.argv[2], sys.argv[3]
site = Path(__file__).resolve().parent.parent
md_src = work / f"LOG{day}_article.md"
text = md_src.read_text(encoding="utf-8")

# 先頭の項目(---で囲まれた部分)に series を入れる
fm_end = text.index("\n---", 3)
fm, body = text[:fm_end], text[fm_end:]
if re.search(r"^series:", fm, flags=re.M):
    fm = re.sub(r"^series:.*$", f'series: "{series}"', fm, flags=re.M)
else:
    fm += f'\nseries: "{series}"'
out = site / "src/content/records" / f"LOG{day}.md"
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(fm + body, encoding="utf-8")

reporter = re.search(r'HEIGHT_REPORTER = """(.*?)"""',
                     (site / "tools/import_all.py").read_text(encoding="utf-8"), re.S).group(1)

thumb = work / f"LOG{day}_thumbnail.html"
if thumb.exists():
    (site / "public/thumbs").mkdir(parents=True, exist_ok=True)
    shutil.copy2(thumb, site / "public/thumbs" / f"LOG{day}.html")

for name in re.findall(r"^【図解】\s*(\S+\.html)\s*$", body, flags=re.M):
    doc = (work / name).read_text(encoding="utf-8")
    doc = doc.replace("</body>", reporter + "</body>", 1) if "</body>" in doc else doc + reporter
    (site / "public/embeds" / name).write_text(doc, encoding="utf-8")

missing = []
for name in re.findall(r"^【画像】\s*(\S+)\s*$", body, flags=re.M):
    src = work / f"LOG{day}_images" / name
    if not src.exists():
        missing.append(name)

print(f"LOG{day}: 記事 {out.name}, シリーズ {series}" + (f", 見つからない画像 {missing}" if missing else ""))
if missing:
    sys.exit(1)

DAY を LOG に置き換えたスクリプト(cards.json・記事の JSON・Markdown)

import json,re,glob,os,subprocess
# 「DAY123」「DAY 123」「DAY123EN」を LOG に。ファイル名(DAY123_…・DAY123.md など)とコードの中は変えない
LABEL=re.compile(r'(?<![A-Za-z0-9_])DAY ?(\d{1,3})(EN)?(?![_\d]|\.[A-Za-z])')
def sub_text(s): return LABEL.sub(lambda m:'LOG'+m.group(1)+(m.group(2) or ''),s)
# HTML: <pre>…</pre> と <code>…</code> の中は触らない
SPLIT_HTML=re.compile(r'(<pre\b.*?</pre>|<code\b.*?</code>)',re.S)
def sub_html(s):
    return ''.join(p if SPLIT_HTML.fullmatch(p) else sub_text(p) for p in SPLIT_HTML.split(s))
stats={}
# 1) cards.json
p='src/data/cards.json'; cards=json.load(open(p,encoding='utf-8'))
for c in cards:
    if c['day']=='DA13': c['day']='DAY13'
    c['day']=sub_text(c['day'])
open(p,'w',encoding='utf-8').write('[\n'+',\n'.join(json.dumps(c,ensure_ascii=False,indent=0) for c in cards)+'\n]\n'); stats['cards']=len(cards)
# 2) record json
n=0;m=0
for f in sorted(glob.glob('src/data/records/*.json')):
    d=json.load(open(f,encoding='utf-8')); before=json.dumps(d,ensure_ascii=False)
    if d.get('day')=='DA13': d['day']='DAY13'
    d['day']=sub_text(d['day'])
    for k in ('title','description'):
        if isinstance(d.get(k),str): d[k]=sub_text(d[k])
    if isinstance(d.get('toc'),list):
        for t in d['toc']:
            if isinstance(t.get('text'),str): t['text']=sub_text(t['text'])
    if isinstance(d.get('body'),str):
        nb=sub_html(d['body']); m+=len(LABEL.findall(d['body']))-len(LABEL.findall(nb)); d['body']=nb
    if json.dumps(d,ensure_ascii=False)!=before:
        open(f,'w',encoding='utf-8').write(json.dumps(d,ensure_ascii=False)); n+=1
stats['json_changed']=n; stats['json_body_replaced']=m
# 3) markdown: frontmatter はそのまま。本文は、囲みコード・行内コード・【画像】【図解】の行を除いて置き換える
INLINE=re.compile(r'(`[^`\n]*`)')
def sub_md(src):
    fm_end=src.find('\n---',3); fm,body=src[:fm_end+4],src[fm_end+4:]
    out=[];fence=None;cnt=0
    for line in body.split('\n'):
        s=line.strip()
        fm_=re.match(r'^(`{3,}|~{3,})',s)
        if fm_:
            if fence is None: fence=fm_.group(1)
            elif s.startswith(fence[0]) and len(fm_.group(1))>=len(fence): fence=None
            out.append(line); continue
        if fence or s.startswith('【画像】') or s.startswith('【図解】'): out.append(line); continue
        new=''.join(p if INLINE.fullmatch(p) else sub_text(p) for p in INLINE.split(line))
        cnt+=len(LABEL.findall(line))-len(LABEL.findall(new)); out.append(new)
    # title in frontmatter
    fm2=re.sub(r'^(title:.*)$',lambda mm:sub_text(mm.group(1)),fm,flags=re.M)
    fm2=re.sub(r'^(meta_description:.*)$',lambda mm:sub_text(mm.group(1)),fm2,flags=re.M)
    return fm2+'\n'.join(out),cnt
k=0;mdn=0
for f in sorted(glob.glob('src/content/records/*.md')):
    src=open(f,encoding='utf-8').read(); new,c=sub_md(src)
    if new!=src: open(f,'w',encoding='utf-8').write(new); mdn+=1; k+=c
stats['md_changed']=mdn; stats['md_replaced']=k
print(stats)

出力(1回目は \b の境界で、2回目は前を「英数字と下線でない」に直して実行):

{'cards': 341, 'json_changed': 341, 'json_body_replaced': 530, 'md_changed': 5, 'md_replaced': 49}
{'cards': 341, 'json_changed': 24, 'json_body_replaced': 40, 'md_changed': 0, 'md_replaced': 0}

図解の HTML の見える文字だけを置き換えたスクリプト

import re,glob
LABEL=re.compile(r'(?<![A-Za-z0-9_])DAY ?(\d{1,3})(EN)?(?![_\d]|\.[A-Za-z])')
SKIP=re.compile(r'(<script\b.*?</script>|<style\b.*?</style>|<pre\b.*?</pre>|<code\b.*?</code>|<!--.*?-->|<[^>]+>)',re.S)
n=0;files=0
for f in sorted(glob.glob('public/embeds/*.html')):
    t=open(f,encoding='utf-8').read()
    parts=SKIP.split(t); out=[];k=0
    for p in parts:
        if p and SKIP.fullmatch(p): out.append(p)
        else:
            new=LABEL.sub(lambda m:'LOG'+m.group(1)+(m.group(2) or ''),p); k+=len(LABEL.findall(p)); out.append(new)
    if k: open(f,'w',encoding='utf-8').write(''.join(out)); n+=k; files+=1
print('figure files changed',files,'labels',n)

出力:

figure files changed 58 labels 140

作ったページの中に、見える DAY の札が残っていないかを確かめたスクリプト

import re,glob
SPLIT=re.compile(r'(<pre\b.*?</pre>|<code\b.*?</code>|<script\b.*?</script>|<iframe\b[^>]*>)',re.S)
n=0
for f in glob.glob('dist/**/*.html',recursive=True):
    t=open(f,encoding='utf-8').read()
    for part in SPLIT.split(t):
        if SPLIT.fullmatch(part): continue
        n+=len(re.findall(r'(?<![A-Za-z0-9_])DAY ?\d{1,3}(?![_\d]|\.[A-Za-z])',part))
print('visible DAY labels left:',n)

出力(1回目は \b の版のあと、2回目は図解まで直したあと):

visible DAY labels left: 152
visible DAY labels left: 0

adaptdesign.jp の DNS を外から引いた確認(DNS over HTTPS)

import json,urllib.request
def q(n,t,ep='https://cloudflare-dns.com/dns-query'):
    r=json.load(urllib.request.urlopen(urllib.request.Request(f'{ep}?name={n}&type={t}',headers={'accept':'application/dns-json'})))
    return [a['data'] for a in r.get('Answer',[])]
for t in ['NS','A','MX','TXT','DS']:
    print(t,q('adaptdesign.jp',t))

移す前:

NS ['ns-cloud-c1.googledomains.com.', 'ns-cloud-c2.googledomains.com.', 'ns-cloud-c3.googledomains.com.', 'ns-cloud-c4.googledomains.com.']
A ['35.194.122.208']
MX ['1 aspmx.l.google.com.', '5 alt1.aspmx.l.google.com.', '5 alt2.aspmx.l.google.com.', '10 alt3.aspmx.l.google.com.', '10 alt4.aspmx.l.google.com.']
DS ['10579 8 2 14ed8129cd8a4afdac8af195dc5ca4ea773205b0a6da7b2391c292d4641d61ff']

DNSSEC をオフにした直後(20:16)と 21:02:

DS records: 1
DS []
NS ['ns-cloud-c1.googledomains.com.', 'ns-cloud-c3.googledomains.com.', 'ns-cloud-c2.googledomains.com.', 'ns-cloud-c4.googledomains.com.']

ネームサーバーを変えたあと(21:07 と 21:30):

NS (cloudflare resolver): ['ns-cloud-c4.googledomains.com.', 'ns-cloud-c3.googledomains.com.', 'ns-cloud-c1.googledomains.com.', 'ns-cloud-c2.googledomains.com.']
NS (google resolver): ['ns-cloud-c2.googledomains.com.', 'ns-cloud-c4.googledomains.com.', 'ns-cloud-c1.googledomains.com.', 'ns-cloud-c3.googledomains.com.']
NS: ['courtney.ns.cloudflare.com.', 'lennon.ns.cloudflare.com.']

転送の確認(curl)

for u in "https://adaptdesign.jp/pixelstomotors/record/day1" "https://adaptdesign.jp/pixelstomotors" "https://adaptdesign.jp/" "https://adaptdesign.jp/web_production_list" "http://adaptdesign.jp/pixelstomotors/manga/第1話?x=1"; do printf "%s\n   -> " "$u"; curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' --max-time 20 "$u"; done
curl -sL -o /dev/null -w 'final: %{http_code} %{url_effective}\n' --max-time 30 "https://adaptdesign.jp/pixelstomotors/record/day1"
https://adaptdesign.jp/pixelstomotors/record/day1
   -> 301 https://pixelstomotors.com/record/day1
https://adaptdesign.jp/pixelstomotors
   -> 301 https://pixelstomotors.com/
https://adaptdesign.jp/
   -> 301 https://pixelstomotors.com/
https://adaptdesign.jp/web_production_list
   -> 301 https://pixelstomotors.com/
http://adaptdesign.jp/pixelstomotors/manga/第1話?x=1
   -> 301 https://adaptdesign.jp/pixelstomotors/manga/%e7%ac%ac1%e8%a9%b1?x=1
final: 200 https://pixelstomotors.com/record/day1
MX 5 ['1 aspmx.l.google.com.', '5 alt1.aspmx.l.google.com.']
TXT 3 ['google-site-verification=HYGb4KfK-ISlVJO', 'brave-ledger-verification=8b758d8894b9ab']

Studio を非公開にした直後(古い DNS キャッシュ経由の 404 が混じる):

HTTP/2 301  location: https://pixelstomotors.com/record/day1 cf-ray: a4b722abbe3aa9dd-IAD
HTTP/2 404
HTTP/2 301  location: https://pixelstomotors.com/record/day1 cf-ray: a4b722b1dfa2d6c9-IAD

公開直後の確認と、通知メールの試験

for u in https://pixelstomotors.com/ https://p2m-site.pixelstomotors.workers.dev/ https://pixelstomotors.com/record/day1; do printf "%s " "$u"; curl -s -o /dev/null -w '%{http_code}\n' --max-time 20 "$u"; done
curl -s --max-time 20 -X POST https://pixelstomotors.com/api/message -H 'content-type: application/json' -d '{"name":"通知テスト2(Claude)","email":"studio@adaptdesign.jp","body":"件名を直したあとの試験です。件名が日本語で読めていれば成功です。","agree":true}'
https://pixelstomotors.com/ 200
https://p2m-site.pixelstomotors.workers.dev/ 404
https://pixelstomotors.com/record/day1 200
{"ok":true}

件名の符号化が1行に収まることの確認

const base64=(s,fold=true)=>{const by=new TextEncoder().encode(s);let b='';for(const c of by)b+=String.fromCharCode(c);const o=btoa(b);return fold?o.replace(/(.{76})/g,'$1\r\n'):o;};
const h='=?utf-8?B?'+base64('[Pixels to Motors] メッセージ: 通知テスト(Claude)長い名前でも一行に収まるか確かめる',false)+'?=';
console.log(h.includes('\r\n')?'NG':'OK one line', h.length);
OK one line 164