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

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

- 出典: SUZ-LAB（実験ログ / 一次情報）
- URL: https://suz-lab.co.jp/logs/building-showcase-gallery
- 公開: 2026-07-05
- 更新: 2026-07-05
- 著者: Hiro（主任研究員・https://suz-lab.co.jp/researchers/hiro・X: https://x.com/suz_lab_hiro）
- 柱: テクノロジー
- タグ: #Next.js #TypeScript #JSON-LD #SEO #単一の出所 #Vercel #Markdown
- カバー画像: https://suz-lab.co.jp/assets/logs/building-showcase-gallery.png?v=ba814399

---


## 何をやったか

制作物ショーケース（[/showcase](/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.ts` | `ImageGallery` の JSON-LD |
| `components/mdx.tsx` ＋ `app/logs/[slug]/page.tsx` | 本文の GFM テーブル対応（`remark-gfm` ＋ セルのスタイル） |
| `components/site-header.tsx`・`app/sitemap.ts` | ナビ／sitemap に `/showcase` を登録 |
| `content/roadmap.ts` | `cover-gallery` を done・`showcase` を doing に更新 |

Roadmap では [AI 生成カバーのギャラリー](/roadmap#cover-gallery) を **done**、
その器である [ラボの制作物ショーケース](/roadmap#showcase) を **doing** にした。

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

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

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

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

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

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

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

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

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

- **ページカバー**（`home.png`・`vmost.png`… ＝固定の少数）。href・タイトル・
  キャプションが他のどこにも無いので、`content/showcase.ts` の `PAGE_COVERS` で手キュレーション。
  ページを 1 枚まるごと足すのは稀（ナビ・sitemap も同時に触る）なので、そのとき 1 行足す。
- **実験ログのカバー**（`log-*.png` ＝記事が増えるたびに増える）。これは
  `getAllLogs()` から**動的に生成**する。ログの frontmatter がそのままタイルになる
  （画像＝`logCover`、タイトル＝`title`、説明＝`summary`、リンク＝`slug`）。

```ts
// 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()` はビルド時（サーバー）にのみ走る。

```ts
// 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 検証](/logs/connecting-vmost-roadmap-logs)（未知の
pillar や必須項目の欠落をその場で slug つきで落とす）や、[カノニカル URL の一本化](/logs/going-live-vercel-and-domain)
と同じ、**「黙って欠落に化けさせない」** の一貫した適用だ。壊れるなら、本番で
静かにではなく、ビルドで大声で壊れてほしい。

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

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

```tsx
// 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>
```

`AssetImage` は `assetSrc(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.tsx` の `NAV` に `{ href: "/showcase", label: "Showcase" }` を追加。
- **sitemap**: `sitemap.ts` の静的ルートに `/showcase`（priority 0.6・monthly）を追加。
- **構造化データ**: ギャラリーを schema.org の `ImageGallery` として [JSON-LD](/topics/json-ld-structured-data) で出す。

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

```ts
// 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 の出所](/logs/going-live-vercel-and-domain)。
ギャラリーの 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 している**として正しく落ちた。

```text
# 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-remote` に **`remark-gfm` を挿していなかった**。GFM（GitHub
  Flavored Markdown）のテーブル記法は素の Markdown パーサでは解釈されず、`| … |` の
  行がただの段落に落ちる。
- 仮にパースできても、`components/mdx.tsx` に `table`/`th`/`td` の**スタイルマップが
  無かった**（`@tailwindcss/typography` を使わず要素ごとに手当てする方針のため）。

対応も 2 つ。まず `MDXRemote` に `remark-gfm` を渡して GFM（テーブル・打ち消し線・
タスクリスト）を有効化する。

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

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

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

ログ本文はどれも素の Markdown で、JSX（MDX 固有の記法）を一度も使っていなかった。
にもかかわらず拡張子が `.mdx` だと「MDX 前提のファイル」に見える。実態に名前を合わせて
全ログを **`.mdx` → `.md`** にリネームし、読み取り層（`lib/logs.ts` の glob と slug
生成）と `README`／`AGENTS.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 の
  `/showcase` に **11 タイル**（ページ 7 ＋ ログ 4）が出力されることを確認。
- この記事の Markdown 表が、生成 HTML で `<table>`（`thead`/`th`/`td`）として描画される
  ことを確認（GFM 対応後）。
- 全ログを `.md` にリネーム後も、4 記事が `/logs/[slug]` として静的生成されることを確認。
- Vercel の本番ビルドが成功し、[/showcase](/showcase) がカバーの格子で描画され、
  各タイル（画像・タイトル）から該当ページ・記事へ遷移することを確認。
- ページソースに `ImageGallery` の JSON-LD が出力され、各画像 `contentUrl` が
  `https://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` で**クリーンな木**を
  一度ビルドし、コミット漏れを手元で捕まえる。
- **自分の成果物を、自分で使うと抜けが出る（[ドッグフーディング](/topics/dogfooding)）。**
  この記事に表を書くまで、ログ基盤が GFM テーブルに対応していないことに気づかなかった。
  書いて出して初めて分かる欠けがある——「まず、やってみる」は、機能を試す検知器でもある。
  ここで踏んだ「開発者バイアス（作り手は自分で欠けに気づけない）」の突破は、
  [ドッグフーディングの解説](/topics/dogfooding)に一般論としてまとめた。

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

