SUZ-LAB

作ったものそのもので見せる — 制作物ショーケースに AI 生成カバーのギャラリーを追加

ラボの制作物ショーケース(/showcase)の最初のセクションとして AI 生成カバーのギャラリーを実装し、そこから数手かけて磨き込んだ記録。画像の出所を content/assets.ts 一本に畳んでビルド時に実在を保証しつつ、ログのカバーは getAllLogs() から動的にタイル化してギャラリーが自動で増える作りにし、cover-first 規約に例外を作らずショーケース自身のカバーも用意した。途中で踏んだ 2 つのインフラ的なつまずき(Vercel だけビルドが落ちる/この記事の表が表にならない)と、ログの拡張子を .mdx → .md に整理した片づけまで、実際のコードと一次情報で残します。

何をやったか

制作物ショーケース(/showcase)の最初のセクションとして、 各ページ・各ログのカバー(Gemini で生成したグラレコ)を 1 か所に集めた AI 生成カバーのギャラリーを実装した。そこから数手、次のように磨き込んだ。

  1. ギャラリーを、ログを足すたびに自動で増える動的な作りにした。
  2. ショーケース自身も例外なく cover-first にした(専用カバー showcase.png を生成)。
  3. 途中で 2 つのインフラ的なつまずき(Vercel だけビルドが落ちる / この記事の表が 表にならない)を踏み、どちらも仕組みで直した。
  4. ついでにログの拡張子を .mdx.md に整理した。

一連の作業で触った主なファイルは次のとおり(複数コミットにまたがる)。

ファイル役割
content/assets.tsカバー画像の宣言+生成プロンプト(showcase.png・この記事のカバーを追加)
content/showcase.tsページ文言+ページカバーのキュレーション(PAGE_COVERS
lib/gallery.tsタイルの組み立て(ページカバー+ログのカバーを動的合成)※新規
components/sections/gallery.tsxカバーの格子を描くセクション
app/showcase/page.tsxショーケースページ本体(先頭に <Cover> = cover-first)
lib/structured-data.tsImageGallery の JSON-LD
components/mdx.tsxapp/logs/[slug]/page.tsx本文の GFM テーブル対応(remark-gfm + セルのスタイル)
components/site-header.tsxapp/sitemap.tsナビ/sitemap に /showcase を登録
content/roadmap.tscover-gallery を done・showcase を doing に更新

Roadmap では AI 生成カバーのギャラリーdone、 その器である ラボの制作物ショーケースdoing にした。

ショーケースは「構想 → 実働プロトタイプ → 公開サービス」の橋渡しを、 作ったものそのもので見せる場だ。その最初のコンテンツとして、いちばん手元に ある制作物=この web サイト自身のビジュアル(各ページのグラレコ)を並べた。

なぜ、ギャラリーから始めたか

ショーケースで見せたい制作物(この web・apps/video など)は これから増える。だが「増えてから作る」では器がいつまでも空のままだ。いま確実に 在るもの=各ページのカバー群を最初のセクションにすれば、ショーケースの器を すぐ世に出せる。空の器を磨き続けるより、1 セクションでも中身を入れて公開したほうが、 以降を実データ(解析・検索・回遊)の上で回せる——まず器を出す という前回の判断と同じ発想だ。

そのうえでカバーのギャラリーは、単なる画像置き場ではなく「AI を使い倒す」 制作プロセスの見せ場になる。Gemini(Nano Banana Pro)で生成したグラレコが、 ブランドのビジュアル言語を一望できる形で並ぶ。行動原則をページの文章で説明するのでは なく、成果物そのもので証明する狙いだ。

設計 — 画像は 1 か所、タイルは「固定+動的」で組む

ギャラリーは「画像」「タイル(表示コピー+リンク)」「描画」を別々のファイルに分け、 間をファイル名(src)で突き合わせる構成にした。責務が 1 か所ずつに閉じ、 どれか 1 つを直しても他に波及しない。

  1. 画像の出所 = content/assets.ts。生成プロンプトつきで全アセットを宣言する 唯一の出所。画像生成スクリプト(scripts/generate-images.ts)もここを読む。
  2. タイルの組み立て = lib/gallery.tsgetGalleryItems()。後述の 2 種類の カバー(固定のページカバー+動的なログのカバー)を連結して返す。
  3. 描画 = components/sections/gallery.tsx。組み上がったタイルを格子に並べるだけ。

「増えるカバー」は動的に、「増えないカバー」は手で

カバーには性質の違う 2 種類がある。ここを分けたのが今回のキモだ。

  • ページカバーhome.pngvmost.png… =固定の少数)。href・タイトル・ キャプションが他のどこにも無いので、content/showcase.tsPAGE_COVERS で手キュレーション。 ページを 1 枚まるごと足すのは稀(ナビ・sitemap も同時に触る)なので、そのとき 1 行足す。
  • 実験ログのカバーlog-*.png =記事が増えるたびに増える)。これは getAllLogs() から動的に生成する。ログの frontmatter がそのままタイルになる (画像=logCover、タイトル=title、説明=summary、リンク=slug)。
// lib/gallery.ts — ページカバー(固定)+ログのカバー(動的)を連結して返す
export function getGalleryItems(): GalleryItem[] {
  const logItems: GalleryItem[] = getAllLogs().map((log) => ({
    src: logCover(log),          // 明示 cover か柱バックドロップ(常に実在)
    title: log.title,
    caption: log.summary,
    href: `/logs/${log.slug}`,
  }));
  const items = [...PAGE_COVERS, ...logItems];
  // …この後ろで src の実在を突き合わせる(次項)
  return items;
}

こうすると、新しい実験ログを 1 本足すだけで、ギャラリーは何も編集せずにその カバーが自動で増える。 ギャラリーは常に「今あるログ」を写す鏡になり、追加を 手で二重管理して片方を忘れる、という事故がそもそも起きない。描画側(Gallery)と JSON-LD 側(page.tsx)はどちらもこの getGalleryItems() だけを見るので、タイル本体と 構造化データの画像一覧が食い違うこともない。

綴り間違いを「壊れたタイルを描く前に」ビルドで落とす

src の綴りやログの cover 指定を 1 文字間違えたら、本番で欠けた画像を描く前に ビルドで失敗させたい。そこで組み上げた全タイルを ASSETS と突き合わせ、実在しない src があればその場で throw する。getGalleryItems() はビルド時(サーバー)にのみ走る。

// lib/gallery.ts — 全タイルの画像が assets.ts に実在することを保証。誤記は壊れたタイルではなくビルド失敗に。
for (const item of items) {
  if (!ASSETS.some((a) => a.src === item.src)) {
    throw new Error(
      `showcase gallery: content/assets.ts に "${item.src}" のエントリがありません`,
    );
  }
}

これは 実験ログの frontmatter 検証(未知の pillar や必須項目の欠落をその場で slug つきで落とす)や、カノニカル URL の一本化 と同じ、「黙って欠落に化けさせない」 の一貫した適用だ。壊れるなら、本番で 静かにではなく、ビルドで大声で壊れてほしい。

描画 — Cover ではなく AssetImage を直接使う

このサイトは各ページ先頭に全面カバーを敷く共通の Cover コンポーネントを持つ。 だがギャラリーのタイルは「先頭の全面表示」ではなくサムネの格子なので、 Cover ではなく AssetImage を直接使った(/topics/logs の一覧カードと同じ扱い)。 レスポンシブなグリッド(sm:grid-cols-2 lg:grid-cols-3)に、16:9 のタイルを並べる。

// components/sections/gallery.tsx — 画像・タイトルのどちらをクリックしても該当ページへ
<Link href={item.href} className="group block">
  <AssetImage
    src={item.src}
    alt={item.title}
    className="aspect-[16/9] w-full rounded-xl border transition-shadow group-hover:shadow-md"
    sizes="(min-width: 1024px) 22rem, (min-width: 640px) 45vw, 100vw"
  />
</Link>

AssetImageassetSrc(src) を通して public/assets/ を参照する。この assetSrc内容ハッシュ付きのキャッシュバスターを URL に足すので、画像を --force で焼き直してバイト列が変われば URL も変わり、CDN の古いキャッシュを 踏まない。sizes を実際のレイアウト幅(デスクトップは 3 列で約 22rem)に合わせて 宣言し、next/image が無駄に大きい解像度を配らないようにしている。

ログのタイルはタイトル・要約を記事の frontmatter からそのまま使うので長さがまちまちだ。 見出しは line-clamp-2、説明は line-clamp-3 で行数を揃え、格子の高さが暴れないようにした。

cover-first 規約は、例外を作らない

このサイトは全ページ先頭に専用カバー(16:9 全面グラレコ)を敷く cover-first 規約で そろえてある。当初ショーケースだけは、カバーを敷き詰めたギャラリーの格子そのものが 主役ビジュアルだから「先頭にもう 1 枚カバーを置くと〈カバーの上にカバー〉になる」と 考えて省いていた。だが、やめた。ショーケースも例外にしない。 理由は 2 つ。

  • ページのカバーと、ギャラリーの中身は別物。 先頭カバー(showcase.png)は 「このページは何か(=制作物ショーケース/構想 → プロトタイプ → 公開サービスの 橋渡し)」を 1 枚で示すもの。ギャラリーは「各ページのカバーを集めた展示」で、 粒度も役割も違う。だから並んでも重複にはならない。
  • 例外は規約を蝕む。 1 ページだけ cover-first を外すと、共通の Cover コンポーネント・sizes・優先読み込み・OGP 画像の前提から外れ、そのページだけ 別扱いの分岐が生える。「全ページが揃っている」こと自体が資産なので、特別扱いを やめて Cover に乗せ直した。

そこで専用カバー showcase.png を新たに Gemini で生成し(展示壁を案内する棒人間と、 構想 → 実働プロトタイプ → 公開サービスの橋渡しを描いたグラレコ)、他ページ (/logs/topics)と同じく <Cover src="showcase.png" className="mb-10" />Container の先頭に置いた。Cover は比率・角丸・枠線・sizes・優先読み込みを 1 か所に固定した共通コンポーネントなので、乗せるだけで他ページと寸分たがわず揃う。

SEO — ナビ・sitemap・ImageGallery の JSON-LD

新しいページは 3 か所に登録して初めて「サイトの一部」になる。

  • ナビ: site-header.tsxNAV{ href: "/showcase", label: "Showcase" } を追加。
  • sitemap: sitemap.ts の静的ルートに /showcase(priority 0.6・monthly)を追加。
  • 構造化データ: ギャラリーを schema.org の ImageGallery として JSON-LD で出す。

JSON-LD の名前・説明・画像リストは content 側から渡し、galleryStructuredData は schema.org の形に写して URL を絶対化するだけ——ここでも出所は上流に置き、lib は 変換に徹する。

// lib/structured-data.ts — 画像 URL は SITE.url で絶対化し、WebSite を isPartOf で参照
export function galleryStructuredData(params: {
  name: string; description: string; path: string;
  images: { src: string; caption: string }[];
}) {
  return {
    "@context": "https://schema.org",
    "@type": "ImageGallery",
    url: `${SITE.url}${params.path}`,        // 例 /showcase#gallery → 絶対 URL
    inLanguage: "ja-JP",
    isPartOf: { "@id": `${SITE.url}/#website` }, // サイト全体(WebSite)の一部として紐付け
    image: params.images.map((img) => ({
      "@type": "ImageObject",
      contentUrl: `${SITE.url}${assetSrc(img.src)}`, // ハッシュ付き相対 → 絶対 URL
      caption: img.caption,
    })),
  };
}

SITE.urlドメイン接続のとき 1 か所に畳んだ正規 URL の出所。 ギャラリーの JSON-LD もそこに乗るので、ドメインが変わっても画像 URL は自動で追従する。

つまずき① — ローカルは通るのに Vercel だけ落ちた

ギャラリー本体のコミットを push したら、Vercel のビルドだけが失敗した。手元の pnpm build は通っていたのに、だ。

原因は単純だった。app/showcase/page.tsx が import している galleryStructuredData定義を lib/structured-data.ts にコミットし忘れて いた。手元の作業ツリーには関数が書いてあり、git add していないファイルのまま ビルドが通っていたので、ローカルでは気づけない。Vercel は push された内容だけを clone してビルドするので、存在しない export を import しているとして正しく落ちた。

# Vercel のビルドログ(要約)
Attempted import error: 'galleryStructuredData' is not exported
  from '@/lib/structured-data'

症状の非対称が本質だ。 ローカルの pnpm build が緑でも、それは 「(未ステージ変更を含む)作業ツリー」が通っただけ。CI が見るのは 「コミットされ push された木」で、両者は別物になりうる。今回は「参照側 (page.tsx)はコミットしたが、定義側(structured-data.ts)を取りこぼした」 という、新しい export を足したときに起きがちな片側コミットだった。修正は定義を 追加して 1 コミット足すだけで済んだ。教訓のほうが本体だ。

つまずき② — この記事の「表」が、表にならなかった

皮肉なことに、上の「何をやったか」で書いた Markdown の表が、ブラウザでは表ではなく 1 段落のベタ書きに潰れて表示された。自分の記事で、自分でバグを踏んだ。

原因は 2 つ重なっていた。

  • 本文を描く next-mdx-remoteremark-gfm を挿していなかった。GFM(GitHub Flavored Markdown)のテーブル記法は素の Markdown パーサでは解釈されず、| … | の 行がただの段落に落ちる。
  • 仮にパースできても、components/mdx.tsxtable/th/tdスタイルマップが 無かった@tailwindcss/typography を使わず要素ごとに手当てする方針のため)。

対応も 2 つ。まず MDXRemoteremark-gfm を渡して GFM(テーブル・打ち消し線・ タスクリスト)を有効化する。

// app/logs/[slug]/page.tsx — 本文 Markdown に GFM を効かせる
<MDXRemote
  source={log.content}
  components={mdxComponents}
  options={{ mdxOptions: { remarkPlugins: [remarkGfm] } }}
/>

次に mdx.tsxtable/thead/th/td をサイトのトークンで定義し、横に溢れる表は ラッパ側で横スクロールさせて本文カラムを崩さないようにした。この修正は以降すべての ログの表に効く(現にこの記事の表は、その成果で表として描かれている)。

ついでに整理 — ログを .mdx から .md

ログ本文はどれも素の Markdown で、JSX(MDX 固有の記法)を一度も使っていなかった。 にもかかわらず拡張子が .mdx だと「MDX 前提のファイル」に見える。実態に名前を合わせて 全ログを .mdx.md にリネームし、読み取り層(lib/logs.ts の glob と slug 生成)と READMEAGENTS.md の記述もそろえた。

描画は引き続き next-mdx-remote なので、将来 <Details> などの MDX 記法が必要に なれば .md のままでも書ける——拡張子は lib/logs.ts探索キーとしての意味だけで、 描画の挙動は変わらない。「看板(拡張子)と中身(素の Markdown)を一致させる」だけの 片づけだが、次に読む人の誤解を 1 つ減らせる。

検証

  • pnpm --filter @suz-lab/web typecheck / lint を通した(AGENTS.md の必須チェック)。
  • content/assets.ts に無い src をわざと PAGE_COVERS に書くと、狙いどおり getGalleryItems()showcase gallery: … のエントリがありません を投げて ビルドが失敗することを確認(設計どおりの fail-fast)。
  • 実験ログを 1 本足すと、ギャラリー側を一切触らずにそのカバー・タイトル・要約・リンクが タイルとして自動で並ぶことを確認(=ログのカバーは動的生成)。生成 HTML の /showcase11 タイル(ページ 7 + ログ 4)が出力されることを確認。
  • この記事の Markdown 表が、生成 HTML で <table>thead/th/td)として描画される ことを確認(GFM 対応後)。
  • 全ログを .md にリネーム後も、4 記事が /logs/[slug] として静的生成されることを確認。
  • Vercel の本番ビルドが成功し、/showcase がカバーの格子で描画され、 各タイル(画像・タイトル)から該当ページ・記事へ遷移することを確認。
  • ページソースに ImageGallery の JSON-LD が出力され、各画像 contentUrlhttps://suz-lab.co.jp 始まりのハッシュ付き絶対 URL になっていることを確認。
  • グローバルナビに Showcase が並び、sitemap.xml/showcase が載ることを確認。

学び

  • 「作ったもので見せる」場は、いま在るものから始める。 制作物が増えるのを 待たず、確実に手元にあるカバー群を最初のセクションにして器を先に公開した。 中身は後から足せる。
  • 「増えるもの」は動的に、「増えないもの」だけ手で持つ。 記事のたびに増える ログのカバーは getAllLogs() から自動生成し、めったに増えないページカバーだけ 手キュレーションにした。二重管理をなくすと「ログは足したがギャラリーに出し忘れた」 という同期ズレが構造的に起きない——手で持つのは、他に出所が無いものだけでいい。
  • 出所を 1 本に畳み、綴り間違いはビルドで落とす。 画像は assets.ts、タイルの 組み立ては lib/gallery.ts、描画は gallery.tsx——と役割を分け、間を src で 突き合わせてビルド時に検証する。誤記は壊れたタイルではなくビルド失敗になる。
  • 規約に例外を作らない。 ショーケースだけ cover-first を外しかけたが、 「全ページが揃っていること」自体が資産だと考え直し、専用カバー showcase.png を 作って他ページと同じ Cover に乗せた。特別扱いは、いずれ分岐とズレを生む—— 揃えるコストより、揃っていないコストのほうが後で高くつく。
  • ローカルの pnpm build が通っても、それは『作業ツリー』が通っただけ。 CI(Vercel)は push された木しか見ない。新しい export を足すコミットでは、 参照側と定義側が同じコミットに入っているかを push 前に必ず確認する。具体策は 2 つ——(1) git status で未ステージ・未追跡ファイルが無いことを確認する、 (2) 迷ったら git stash -u && pnpm build && git stash popクリーンな木を 一度ビルドし、コミット漏れを手元で捕まえる。
  • 自分の成果物を、自分で使うと抜けが出る(ドッグフーディング)。 この記事に表を書くまで、ログ基盤が GFM テーブルに対応していないことに気づかなかった。 書いて出して初めて分かる欠けがある——「まず、やってみる」は、機能を試す検知器でもある。 ここで踏んだ「開発者バイアス(作り手は自分で欠けに気づけない)」の突破は、 ドッグフーディングの解説に一般論としてまとめた。

これで AI 生成カバーのギャラリー は完了。次は器である ショーケースに、YouTube ショートと web の相互導線 など他の制作物のセクションを足していく。

#Next.js#TypeScript#JSON-LD#SEO#単一の出所#Vercel#Markdown

同じ柱のほかのログ

この柱の Roadmap を見る