作ったものそのもので見せる — 制作物ショーケースに AI 生成カバーのギャラリーを追加
ラボの制作物ショーケース(/showcase)の最初のセクションとして AI 生成カバーのギャラリーを実装し、そこから数手かけて磨き込んだ記録。画像の出所を content/assets.ts 一本に畳んでビルド時に実在を保証しつつ、ログのカバーは getAllLogs() から動的にタイル化してギャラリーが自動で増える作りにし、cover-first 規約に例外を作らずショーケース自身のカバーも用意した。途中で踏んだ 2 つのインフラ的なつまずき(Vercel だけビルドが落ちる/この記事の表が表にならない)と、ログの拡張子を .mdx → .md に整理した片づけまで、実際のコードと一次情報で残します。
何をやったか
制作物ショーケース(/showcase)の最初のセクションとして、 各ページ・各ログのカバー(Gemini で生成したグラレコ)を 1 か所に集めた AI 生成カバーのギャラリーを実装した。そこから数手、次のように磨き込んだ。
- ギャラリーを、ログを足すたびに自動で増える動的な作りにした。
- ショーケース自身も例外なく cover-first にした(専用カバー
showcase.pngを生成)。 - 途中で 2 つのインフラ的なつまずき(Vercel だけビルドが落ちる / この記事の表が 表にならない)を踏み、どちらも仕組みで直した。
- ついでにログの拡張子を
.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 生成カバーのギャラリー を done、 その器である ラボの制作物ショーケース を doing にした。
ショーケースは「構想 → 実働プロトタイプ → 公開サービス」の橋渡しを、 作ったものそのもので見せる場だ。その最初のコンテンツとして、いちばん手元に ある制作物=この web サイト自身のビジュアル(各ページのグラレコ)を並べた。
なぜ、ギャラリーから始めたか
ショーケースで見せたい制作物(この web・apps/video など)は これから増える。だが「増えてから作る」では器がいつまでも空のままだ。いま確実に 在るもの=各ページのカバー群を最初のセクションにすれば、ショーケースの器を すぐ世に出せる。空の器を磨き続けるより、1 セクションでも中身を入れて公開したほうが、 以降を実データ(解析・検索・回遊)の上で回せる——まず器を出す という前回の判断と同じ発想だ。
そのうえでカバーのギャラリーは、単なる画像置き場ではなく「AI を使い倒す」 制作プロセスの見せ場になる。Gemini(Nano Banana Pro)で生成したグラレコが、 ブランドのビジュアル言語を一望できる形で並ぶ。行動原則をページの文章で説明するのでは なく、成果物そのもので証明する狙いだ。
設計 — 画像は 1 か所、タイルは「固定+動的」で組む
ギャラリーは「画像」「タイル(表示コピー+リンク)」「描画」を別々のファイルに分け、
間をファイル名(src)で突き合わせる構成にした。責務が 1 か所ずつに閉じ、
どれか 1 つを直しても他に波及しない。
- 画像の出所 =
content/assets.ts。生成プロンプトつきで全アセットを宣言する 唯一の出所。画像生成スクリプト(scripts/generate-images.ts)もここを読む。 - タイルの組み立て =
lib/gallery.tsのgetGalleryItems()。後述の 2 種類の カバー(固定のページカバー+動的なログのカバー)を連結して返す。 - 描画 =
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)。
// 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>
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 で出す。
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-remoteにremark-gfmを挿していなかった。GFM(GitHub Flavored Markdown)のテーブル記法は素の Markdown パーサでは解釈されず、| … |の 行がただの段落に落ちる。 - 仮にパースできても、
components/mdx.tsxにtable/th/tdのスタイルマップが 無かった(@tailwindcss/typographyを使わず要素ごとに手当てする方針のため)。
対応も 2 つ。まず MDXRemote に remark-gfm を渡して GFM(テーブル・打ち消し線・
タスクリスト)を有効化する。
// 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 がカバーの格子で描画され、 各タイル(画像・タイトル)から該当ページ・記事へ遷移することを確認。
- ページソースに
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でクリーンな木を 一度ビルドし、コミット漏れを手元で捕まえる。 - 自分の成果物を、自分で使うと抜けが出る(ドッグフーディング)。 この記事に表を書くまで、ログ基盤が GFM テーブルに対応していないことに気づかなかった。 書いて出して初めて分かる欠けがある——「まず、やってみる」は、機能を試す検知器でもある。 ここで踏んだ「開発者バイアス(作り手は自分で欠けに気づけない)」の突破は、 ドッグフーディングの解説に一般論としてまとめた。
これで AI 生成カバーのギャラリー は完了。次は器である ショーケースに、YouTube ショートと web の相互導線 など他の制作物のセクションを足していく。




