レシピ · 6手 · 1プロンプト実装

パーソナル OG 共有カードを追加する

4S user slug から next/og(Satori)で 1200×630 の OG 画像を焼く /api/og/[slug] と、それを og:image に張る公開 /share/[slug] を配線する

前提: new-event解説 ch.24module: OG 共有カード

このレシピは 4S user slug からパーソナル OG 共有カードを自動生成する経路(第24章 / 経路B: Satori/OG)を配線する完全手順。 GET /api/og/<slug> が 4S 公開プロフィールをライブ取得して 1200×630 の PNG を焼き、 公開ページ /share/<slug> の generateMetadata がそれを og:image に張る。 対象 app を apps/<app>(例では apps/starter)とする。上から順に実行すれば完了する。

feature flag は不要(公開ページのみで admin nav も env も持たないため、features / FEATURES descriptor への追加なし)。

1. 依存

未導入なら apps/<app>/package.json の dependencies に:

"@event/visuals": "*"

apps/<app>/next.config.tstranspilePackages 配列に "@event/visuals" を足し、root で yarn install

2. ブランドトークン(src/lib/visuals.ts)

add-visuals 済みなら既存の visualsBrand をそのまま使う(スキップ)。無ければ最小版を作る:

apps/<app>/src/lib/visuals.ts:

import { resolveBrand, type EventBrand } from "@event/visuals";

/** イベントのブランドトークン(DEFAULT_BRAND への部分上書き)。 */
export const visualsBrand: EventBrand = resolveBrand({
  name: "starter",
  colors: {
    bg: "#0b0e13",
    bg2: "#141a24",
    accent: "#5ec8c0",
    accentDeep: "#2e6f6a",
    accentAlt: "#e0653c",
  },
});

resolveBrand は DEFAULT_BRAND(中立ダーク)に部分定義を浅くマージするので、カードが参照する 未指定トークン(fg / fgDim / fgFaint / hair / fonts.mono)も全て既定で埋まる。色を差し替えるだけで カードが reskin される(フォーマットレジストリ等の完全版は add-visuals / starter の同ファイル参照)。

3. OG 画像 route(/api/og/[slug]/route.tsx)

apps/<app>/src/app/api/og/[slug]/route.tsxJSX を含むので拡張子は .tsx):

import { ImageResponse } from "next/og";
import {
  createFoursConfig,
  extractSlug,
  fetchPublicProfile,
  type PublicProfile,
} from "@event/fours-sdk";
import { visualsBrand } from "@/lib/visuals";

// 4S へ per-request でライブ fetch する(build 時 prerender しない)。
export const dynamic = "force-dynamic";
// fours-sdk が process.env を広く読むため node runtime を明示する。
export const runtime = "nodejs";

const WIDTH = 1200;
const HEIGHT = 630;

/* ─────────── アバター inline 化(失敗は null) ─────────── */

async function loadAvatarDataUri(url: string | null): Promise<string | null> {
  if (!url) return null;
  try {
    const res = await fetch(url, { cache: "no-store" });
    if (!res.ok) return null;
    const ctype = res.headers.get("content-type") ?? "image/jpeg";
    if (!ctype.startsWith("image/")) return null;
    const buf = Buffer.from(await res.arrayBuffer());
    return `data:${ctype};base64,${buf.toString("base64")}`;
  } catch {
    return null;
  }
}

/* ─────────── Noto Sans JP を best-effort でランタイム取得 ───────────
   Satori は system-ui を解釈せず woff2 も読めない。Google Fonts の CSS API を
   旧 UA で叩いて truetype/woff の実体 URL を得てから fetch する。全て try/catch で
   包み、失敗時は空配列(= 既定フォントで描画継続)。text= で必要字だけ subset。 */

const fontCache = new Map<string, ArrayBuffer | null>();

async function loadNotoSansJp(subset: string): Promise<ArrayBuffer | null> {
  const key = subset || "_";
  const cached = fontCache.get(key);
  if (cached !== undefined) return cached;

  let data: ArrayBuffer | null = null;
  try {
    const cssUrl =
      "https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@700&display=swap&text=" +
      encodeURIComponent(subset);
    const cssRes = await fetch(cssUrl, {
      headers: {
        // 旧 UA を装って woff2 ではなく truetype/woff の URL を得る(Satori は woff2 非対応)。
        "User-Agent":
          "Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko)",
      },
      cache: "no-store",
    });
    if (cssRes.ok) {
      const css = await cssRes.text();
      const m = css.match(/src:\s*url\(([^)]+)\)\s*format\(['"]?(?:truetype|opentype|woff)['"]?\)/);
      const fontUrl = m?.[1];
      if (fontUrl) {
        const fontRes = await fetch(fontUrl, { cache: "no-store" });
        if (fontRes.ok) data = await fontRes.arrayBuffer();
      }
    }
  } catch {
    data = null;
  }
  fontCache.set(key, data);
  return data;
}

/* ─────────── カード JSX(Satori: flex サブセットのみ) ─────────── */

type CardData = {
  name: string;
  title: string | null;
  org: string | null;
  slug: string;
  avatar: string | null;
};

function primaryOrg(profile: PublicProfile): string | null {
  const current = profile.orgs.find((o) => o.current);
  return (current ?? profile.orgs[0])?.orgName ?? null;
}

function Card({ data }: { data: CardData }) {
  const b = visualsBrand;
  const c = b.colors;
  return (
    <div
      style={{
        width: WIDTH,
        height: HEIGHT,
        display: "flex",
        flexDirection: "column",
        justifyContent: "space-between",
        padding: 72,
        background: `linear-gradient(135deg, ${c.bg} 0%, ${c.bg2} 100%)`,
        color: c.fg,
        fontFamily: '"Noto Sans JP"',
        position: "relative",
      }}
    >
      {/* 左端アクセントバー */}
      <div
        style={{
          position: "absolute",
          left: 0,
          top: 0,
          bottom: 0,
          width: 12,
          background: c.accent,
          display: "flex",
        }}
      />

      {/* ヘッダ: mono ラベル + イベント名 */}
      <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
        <div
          style={{
            display: "flex",
            fontFamily: b.fonts.mono,
            fontSize: 22,
            letterSpacing: 6,
            textTransform: "uppercase",
            color: c.accent,
          }}
        >
          4S · SHARE CARD
        </div>
        <div style={{ display: "flex", fontSize: 22, letterSpacing: 3, color: c.fgFaint }}>
          {b.name.toUpperCase()}
        </div>
      </div>

      {/* 本体: アバター + 氏名 / 肩書 / 所属 */}
      <div style={{ display: "flex", alignItems: "center", gap: 48 }}>
        {data.avatar ? (
          <img
            src={data.avatar}
            width={220}
            height={220}
            style={{
              width: 220,
              height: 220,
              borderRadius: 9999,
              objectFit: "cover",
              border: `4px solid ${c.accent}`,
            }}
          />
        ) : (
          <div
            style={{
              width: 220,
              height: 220,
              borderRadius: 9999,
              display: "flex",
              alignItems: "center",
              justifyContent: "center",
              background: c.accentDeep,
              color: c.fg,
              fontSize: 96,
              fontWeight: 700,
              border: `4px solid ${c.accent}`,
            }}
          >
            {(data.name.trim()[0] ?? "?").toUpperCase()}
          </div>
        )}

        <div style={{ display: "flex", flexDirection: "column", maxWidth: 700 }}>
          <div style={{ display: "flex", fontSize: 68, fontWeight: 700, lineHeight: 1.1 }}>
            {data.name}
          </div>
          {data.title ? (
            <div style={{ display: "flex", fontSize: 32, marginTop: 18, color: c.fgDim }}>
              {data.title}
            </div>
          ) : null}
          {data.org ? (
            <div style={{ display: "flex", fontSize: 28, marginTop: 8, color: c.fgFaint }}>
              {data.org}
            </div>
          ) : null}
        </div>
      </div>

      {/* フッタ: slug ハンドル + hairline */}
      <div
        style={{
          display: "flex",
          alignItems: "center",
          justifyContent: "space-between",
          borderTop: `1px solid ${c.hair}`,
          paddingTop: 24,
        }}
      >
        <div style={{ display: "flex", fontFamily: b.fonts.mono, fontSize: 26, color: c.fgDim }}>
          @{data.slug}
        </div>
        <div style={{ display: "flex", fontFamily: b.fonts.mono, fontSize: 22, color: c.accentAlt }}>
          4s.link
        </div>
      </div>
    </div>
  );
}

/* ─────────── GET ハンドラ ─────────── */

export async function GET(_req: Request, ctx: { params: Promise<{ slug: string }> }) {
  const raw = (await ctx.params).slug;
  const slug = extractSlug(decodeURIComponent(raw)) || raw;

  // 取得は全て best-effort。失敗しても汎用カードにフォールバックし 500 にしない。
  let profile: PublicProfile | null = null;
  try {
    profile = await fetchPublicProfile(createFoursConfig(), slug);
  } catch {
    profile = null;
  }

  const data: CardData = {
    name: profile?.name ?? slug,
    title: profile?.title ?? null,
    org: profile ? primaryOrg(profile) : null,
    slug: profile?.slug ?? slug,
    avatar: await loadAvatarDataUri(profile?.avatarUrl ?? null),
  };

  const subset = `${data.name}${data.title ?? ""}${data.org ?? ""}${data.slug}`;
  const fontData = await loadNotoSansJp(subset);
  const fonts = fontData
    ? [{ name: "Noto Sans JP", data: fontData, weight: 700 as const, style: "normal" as const }]
    : undefined;

  return new ImageResponse(<Card data={data} />, {
    width: WIDTH,
    height: HEIGHT,
    ...(fonts ? { fonts } : {}),
  });
}

Satori は CSS の flex サブセットしか解釈しないので、複数子を持つ div には必ず display: "flex" を書く (無いと ImageResponse が throw する)。extractSlug は URL 形式(https://4s.link/ja/asao)も raw slug(asao)も受けて slug に正規化する。

4. 公開シェアページ(/share/[slug]/page.tsx)

generateMetadata が og:image を同 slug の /api/og/<slug> に向ける。OGP クローラは相対 URL を 解決しないので、NEXT_PUBLIC_SITE_URL(明示)→ VERCEL_URL(Vercel が自動注入)→ 相対パス (dev フォールバック)の順で絶対 URL に解決する。

apps/<app>/src/app/share/[slug]/page.tsx:

import type { Metadata } from "next";
import {
  createFoursConfig,
  extractSlug,
  fetchPublicProfile,
  type PublicProfile,
} from "@event/fours-sdk";

// 4S へライブ fetch するため build 時 prerender を避ける。
export const dynamic = "force-dynamic";

type Params = { params: Promise<{ slug: string }> };

/** 絶対 OG URL 用の origin(未設定なら相対パスで返す)。 */
function siteOrigin(): string | null {
  const explicit = process.env.NEXT_PUBLIC_SITE_URL?.trim();
  if (explicit) return explicit.replace(/\/+$/, "");
  const vercel = process.env.VERCEL_URL?.trim();
  if (vercel) return `https://${vercel.replace(/\/+$/, "")}`;
  return null;
}

async function loadProfile(rawSlug: string): Promise<{ slug: string; profile: PublicProfile | null }> {
  const slug = extractSlug(decodeURIComponent(rawSlug)) || rawSlug;
  try {
    return { slug, profile: await fetchPublicProfile(createFoursConfig(), slug) };
  } catch {
    return { slug, profile: null };
  }
}

export async function generateMetadata({ params }: Params): Promise<Metadata> {
  const { slug, profile } = await loadProfile((await params).slug);
  const displayName = profile?.name ?? slug;
  const title = `${displayName} — 4S share card`;
  const description =
    profile?.title?.trim() ||
    profile?.bio?.trim() ||
    `${displayName} の 4S プロフィール共有カード`;

  const origin = siteOrigin();
  const ogPath = `/api/og/${encodeURIComponent(slug)}`;
  const imageUrl = origin ? `${origin}${ogPath}` : ogPath;
  const pageUrl = origin ? `${origin}/share/${encodeURIComponent(slug)}` : undefined;

  return {
    ...(origin ? { metadataBase: new URL(origin) } : {}),
    title,
    description,
    openGraph: {
      type: "profile",
      title,
      description,
      ...(pageUrl ? { url: pageUrl } : {}),
      images: [{ url: imageUrl, width: 1200, height: 630, alt: title }],
    },
    twitter: {
      card: "summary_large_image",
      title,
      description,
      images: [imageUrl],
    },
  };
}

const label: React.CSSProperties = {
  fontFamily: "var(--mono)",
  fontSize: 11,
  letterSpacing: "0.15em",
  textTransform: "uppercase",
  color: "var(--accent)",
  margin: 0,
};

export default async function SharePage({ params }: Params) {
  const { slug, profile } = await loadProfile((await params).slug);
  const displayName = profile?.name ?? slug;
  const config = createFoursConfig();
  const fourSUrl = `${config.webBase.replace(/\/+$/, "")}/${encodeURIComponent(slug)}`;
  const ogSrc = `/api/og/${encodeURIComponent(slug)}`;
  const currentOrgs = (profile?.orgs ?? []).filter((o) => o.current);

  return (
    <main style={{ maxWidth: 720, margin: "0 auto", padding: "48px 24px" }}>
      <p style={label}>4S · share card</p>
      <h1 style={{ fontSize: 28, margin: "6px 0 4px" }}>{displayName}</h1>
      {profile?.title ? (
        <p style={{ fontSize: 14, color: "var(--ink-2)", margin: "0 0 4px" }}>{profile.title}</p>
      ) : null}
      {!profile ? (
        <p style={{ fontSize: 13, color: "var(--ink-3)", margin: "0 0 4px", maxWidth: "70ch" }}>
          このハンドルの 4S 公開プロフィールを取得できませんでした(未接続 / 非公開 / 不明な slug)。
          下のカードは汎用フォールバックで生成しています。
        </p>
      ) : null}

      {/* OG カードのプレビュー(/api/og/<slug> をそのまま画像として読む) */}
      <div
        style={{
          border: "1px solid var(--line)",
          background: "var(--surface)",
          borderRadius: 8,
          overflow: "hidden",
          margin: "20px 0 24px",
        }}
      >
        {/* eslint-disable-next-line @next/next/no-img-element */}
        <img
          src={ogSrc}
          alt={`${displayName} の OG カード`}
          width={1200}
          height={630}
          style={{ display: "block", width: "100%", height: "auto" }}
        />
      </div>

      {profile?.bio ? (
        <section
          style={{
            border: "1px solid var(--line)",
            background: "var(--surface)",
            padding: "16px 20px",
            marginBottom: 20,
          }}
        >
          <p style={{ fontSize: 14, margin: 0, whiteSpace: "pre-wrap", lineHeight: 1.9 }}>
            {profile.bio}
          </p>
        </section>
      ) : null}

      {currentOrgs.length > 0 ? (
        <section
          style={{
            border: "1px solid var(--line)",
            background: "var(--surface)",
            padding: "16px 20px",
            marginBottom: 20,
          }}
        >
          <h2 style={{ fontSize: 13, margin: "0 0 8px" }}>現所属</h2>
          <ul style={{ margin: 0, paddingLeft: 20, fontSize: 14 }}>
            {currentOrgs.map((o) => (
              <li key={o.orgId} style={{ marginBottom: 4 }}>
                <b>{o.orgName}</b>
                {o.title ? (
                  <span style={{ color: "var(--ink-3)", fontSize: 12, marginLeft: 8 }}>{o.title}</span>
                ) : null}
              </li>
            ))}
          </ul>
        </section>
      ) : null}

      <p style={{ fontSize: 13, margin: 0 }}>
        <a href={fourSUrl} rel="noopener noreferrer" target="_blank" style={{ color: "var(--accent)" }}>
          4S でプロフィール全体を見る →
        </a>
      </p>
    </main>
  );
}

本番デプロイでは NEXT_PUBLIC_SITE_URL=https://<本番ドメイン> を設定する (カスタムドメイン時、VERCEL_URL*.vercel.app のデプロイ URL を指すため明示が確実)。

5. 三段フォールバック(絶対に 500 を返さない設計)

失敗するものフォールバック
プロフィール4S 未接続 / 不明 slug / 非公開(fetchPublicProfile は失敗時 null を返す)slug を名前にした汎用カード。null 分岐 = フォールバックで 500 にしない
アバターavatarUrl の fetch 失敗 / content-type が非画像イニシャル 1 文字の丸(accentDeep 地)。URL を Satori に渡さず data URI に inline 化してから描くのが要点 — Satori 内のリモート fetch 失敗は ImageResponse 全体を throw させる
フォントGoogle Fonts CSS API / フォント実体の取得失敗既定フォントで描画継続(日本語は tofu になりうるが 200 は返す)。取得は旧 UA で truetype URL を得る方式(Satori は woff2 非対応)+ text= subset + Map キャッシュ

6. 型チェックと検証

yarn workspace <app> typecheck   # 対象 app にスコープ

dev で確認(4S 未接続でも動く):

  1. yarn workspace <app> dev
  2. ブラウザで /api/og/<4S slug> → 1200×630 のカード画像が描画される。

curl -sI http://localhost:<port>/api/og/<slug> の content-type が image/png であること

  1. /share/<slug> → カードプレビュー + プロフィールが出る。ページソースに og:image /

twitter:image メタが入っていること

  1. 存在しない slug(例 /api/og/no-such-user)でも 500 にならず、slug 名 + イニシャルの

汎用カードが返ること

完了条件: typecheck が通り、/api/og/<slug> が PNG を返し、/share/<slug> の og:image が それを指し、4S 未接続 / 不正 slug でも汎用カードにフォールバックする。