diff --git a/website-next/app/(content)/opengraph-image.tsx b/website-next/app/(content)/opengraph-image.tsx
new file mode 100644
index 00000000000..fdd0b4cc1b6
--- /dev/null
+++ b/website-next/app/(content)/opengraph-image.tsx
@@ -0,0 +1,33 @@
+import { ImageResponse } from "next/og";
+import { loadInterFonts } from "@/src/og/fonts";
+import { ShareCard } from "@/src/og/ShareCard";
+
+// TODO: Proper styling and layout of share cards
+
+// Required under `output: export` for this paramless metadata route, which has
+// no `generateStaticParams` to imply prerendering (unlike the per-doc card).
+export const dynamic = "force-static";
+
+// Mirrors the brand strings in app/layout.tsx (TITLE is the headline).
+const TITLE = "ChilliCream GraphQL Platform";
+
+export const alt = TITLE;
+
+export const size = {
+ width: 1200,
+ height: 630,
+};
+
+export const contentType = "image/png";
+
+export default async function Image() {
+ const fonts = await loadInterFonts();
+
+ return new ImageResponse(
+ ,
+ {
+ ...size,
+ fonts,
+ },
+ );
+}
diff --git a/website-next/app/blog/[...slug]/page.tsx b/website-next/app/blog/[...slug]/page.tsx
index 2f002ed885e..5ed74d94137 100644
--- a/website-next/app/blog/[...slug]/page.tsx
+++ b/website-next/app/blog/[...slug]/page.tsx
@@ -18,6 +18,7 @@ import { findSimilarPosts, listBlogPostSummaries } from "@/src/helpers/blogPosts
import { compileDoc } from "@/src/helpers/compileDoc";
import { readFrontmatter } from "@/src/helpers/readFrontmatter";
import { estimateReadingTime } from "@/src/helpers/readingTime";
+import { toAbsoluteUrl } from "@/src/helpers/siteUrl";
type BlogFrontmatter = {
title?: string;
@@ -67,9 +68,29 @@ export async function generateMetadata({
return {};
}
const { title, description } = readFrontmatter(path.join(BLOG_ROOT, rel));
+
+ const stem = stemForSlug(slug);
+ const summary = listBlogPostSummaries().find((s) => s.stem === stem);
+ const featuredImageAbs = summary?.featuredImage
+ ? toAbsoluteUrl(summary.featuredImage)
+ : undefined;
+ const images = featuredImageAbs ? [featuredImageAbs] : undefined;
+
return {
title,
description,
+ openGraph: {
+ type: "article",
+ title,
+ description,
+ images,
+ },
+ twitter: {
+ card: "summary_large_image",
+ title,
+ description,
+ images,
+ },
};
}
@@ -93,7 +114,7 @@ export default async function BlogSlugPage({ params }: PageProps) {
const readingTime = estimateReadingTime(raw).text;
const summaries = listBlogPostSummaries();
- const stem = `${slug[0]}-${slug[1]}-${slug[2]}-${slug.slice(3).join("/")}`;
+ const stem = stemForSlug(slug);
const current = summaries.find((s) => s.stem === stem);
const similar = current ? findSimilarPosts(current, summaries) : [];
const featuredImage = current?.featuredImage ?? null;
@@ -133,6 +154,10 @@ function isPaginationSlug(slug: string[]): boolean {
return slug.length === 1 && /^\d+$/.test(slug[0]);
}
+function stemForSlug(slug: string[]): string {
+ return `${slug[0]}-${slug[1]}-${slug[2]}-${slug.slice(3).join("/")}`;
+}
+
function renderPagination(pageNum: number) {
if (!Number.isInteger(pageNum) || pageNum < 2) {
notFound();
diff --git a/website-next/app/docs-og/[id]/opengraph-image.tsx b/website-next/app/docs-og/[id]/opengraph-image.tsx
new file mode 100644
index 00000000000..e3c4bb42537
--- /dev/null
+++ b/website-next/app/docs-og/[id]/opengraph-image.tsx
@@ -0,0 +1,58 @@
+import path from "node:path";
+import { ImageResponse } from "next/og";
+import { PRODUCTS } from "@/src/data/products";
+import {
+ CONTENT_ROOT,
+ decodeDocId,
+ encodeDocId,
+ listDocSlugs,
+ resolveFile,
+} from "@/src/helpers/docsParams";
+import { readFrontmatter } from "@/src/helpers/readFrontmatter";
+import { loadInterFonts } from "@/src/og/fonts";
+import { ShareCard } from "@/src/og/ShareCard";
+
+// TODO: Proper styling and layout of share cards
+
+export const dynamicParams = false;
+
+export const alt = "ChilliCream documentation";
+
+export const size = {
+ width: 1200,
+ height: 630,
+};
+
+export const contentType = "image/png";
+
+type Params = {
+ id: string;
+};
+
+export function generateStaticParams(): Params[] {
+ return listDocSlugs().map((slug) => ({ id: encodeDocId(slug) }));
+}
+
+export default async function Image({ params }: { params: Promise }) {
+ const { id } = await params;
+ const slug = decodeDocId(id);
+ const rel = resolveFile(slug);
+ const frontmatter = rel
+ ? readFrontmatter(path.join(CONTENT_ROOT, rel))
+ : null;
+
+ const productSlug = slug[0];
+ const product = PRODUCTS.find((p) => p.slug === productSlug);
+ const eyebrow = product?.title ?? "ChilliCream";
+ const title = frontmatter?.title ?? product?.title ?? "ChilliCream";
+
+ const fonts = await loadInterFonts();
+
+ return new ImageResponse(
+ ,
+ {
+ ...size,
+ fonts,
+ },
+ );
+}
diff --git a/website-next/app/docs/[...slug]/page.tsx b/website-next/app/docs/[...slug]/page.tsx
index 13b63315463..f72a4b3c4c6 100644
--- a/website-next/app/docs/[...slug]/page.tsx
+++ b/website-next/app/docs/[...slug]/page.tsx
@@ -1,4 +1,3 @@
-import fs from "node:fs";
import path from "node:path";
import type { Metadata } from "next";
import { notFound } from "next/navigation";
@@ -7,11 +6,16 @@ import { EditOnGitHub } from "@/src/design-system/EditOnGitHub";
import { TableOfContents } from "@/src/design-system/TableOfContents";
import { Typography } from "@/src/design-system/Typography";
import { compileDoc } from "@/src/helpers/compileDoc";
+import {
+ CONTENT_ROOT,
+ encodeDocId,
+ listDocSlugs,
+ resolveFile,
+} from "@/src/helpers/docsParams";
import { getGitMetadata } from "@/src/helpers/gitMetadata";
import { githubEditUrl } from "@/src/helpers/githubEditUrl";
import { readFrontmatter } from "@/src/helpers/readFrontmatter";
-
-const CONTENT_ROOT = path.join(process.cwd(), "content/docs");
+import { toAbsoluteUrl } from "@/src/helpers/siteUrl";
type Params = {
slug: string[];
@@ -24,19 +28,7 @@ type PageProps = {
export const dynamicParams = false;
export function generateStaticParams(): Params[] {
- const params = walk(CONTENT_ROOT)
- .filter((f) => /\.mdx?$/.test(f))
- .map((f) => path.relative(CONTENT_ROOT, f).replace(/\.mdx?$/, ""))
- .map((rel) => rel.split(path.sep))
- .map((parts) =>
- parts[parts.length - 1] === "index" ? parts.slice(0, -1) : parts,
- )
- .filter((slug) => slug.length > 0)
- .map((slug) => ({ slug }));
-
- // output: export requires at least one prerendered path; placeholder
- // renders 404 via notFound() when no content is present.
- return params.length > 0 ? params : [{ slug: ["__empty__"] }];
+ return listDocSlugs().map((slug) => ({ slug }));
}
export async function generateMetadata({
@@ -48,9 +40,31 @@ export async function generateMetadata({
return {};
}
const { title, description } = readFrontmatter(path.join(CONTENT_ROOT, rel));
+
+ const id = encodeDocId(slug);
+ const ogImage = {
+ url: toAbsoluteUrl(`/docs-og/${id}/opengraph-image`),
+ width: 1200,
+ height: 630,
+ type: "image/png",
+ alt: title ? `${title} documentation` : "ChilliCream documentation",
+ };
+
return {
title,
description,
+ openGraph: {
+ type: "article",
+ title,
+ description,
+ images: [ogImage],
+ },
+ twitter: {
+ card: "summary_large_image",
+ title,
+ description,
+ images: [ogImage],
+ },
};
}
@@ -89,28 +103,3 @@ export default async function DocPage({ params }: PageProps) {
);
}
-
-function resolveFile(slug: string[]): string | null {
- const joined = slug.join("/");
- const candidates = [
- `${joined}.md`,
- `${joined}.mdx`,
- `${joined}/index.md`,
- `${joined}/index.mdx`,
- ];
-
- for (const c of candidates) {
- if (fs.existsSync(path.join(CONTENT_ROOT, c))) {
- return c;
- }
- }
- return null;
-}
-
-function walk(dir: string): string[] {
- const entries = fs.readdirSync(dir, { withFileTypes: true });
- return entries.flatMap((e) => {
- const full = path.join(dir, e.name);
- return e.isDirectory() ? walk(full) : [full];
- });
-}
diff --git a/website-next/app/globals.css b/website-next/app/globals.css
index 4c17bed0f3a..aebdaeca457 100644
--- a/website-next/app/globals.css
+++ b/website-next/app/globals.css
@@ -9,6 +9,7 @@
/* Disable default Tailwind colors */
--color-*: initial;
+ /* The cc-* colors below are mirrored in src/theme/colors.ts for the OG share cards. */
--color-cc-ink: #f5f1ea;
/* Long-form body prose: lighter than `cc-ink-dim` so paragraphs stay readable. */
--color-cc-prose: rgba(245, 241, 234, 0.8);
diff --git a/website-next/nginx/conf.d/default.conf b/website-next/nginx/conf.d/default.conf
index 2ae1930e93e..d8713021e1b 100644
--- a/website-next/nginx/conf.d/default.conf
+++ b/website-next/nginx/conf.d/default.conf
@@ -9,38 +9,38 @@ server {
error_page 404 /404.html;
- # security headers
- add_header X-Content-Type-Options "nosniff" always;
- add_header X-Frame-Options "SAMEORIGIN" always;
- add_header Referrer-Policy "strict-origin-when-cross-origin" always;
- add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
-
# redirects (managed in redirects.conf)
include /etc/nginx/conf.d/redirects.conf;
- # Next.js hashed build assets — immutable forever
+ # Next.js hashed build assets - immutable, cached forever
location /_next/static/ {
add_header Cache-Control "public, max-age=31536000, immutable" always;
- add_header X-Content-Type-Options "nosniff" always;
access_log off;
try_files $uri =404;
}
- # static asset extensions — long cache
+ # Static asset files (CSS, JS, fonts, images, media) - immutable, cached forever
location ~* \.(?:css|js|mjs|woff2?|ttf|otf|eot|ico|svg|png|jpe?g|gif|webp|avif|bmp|mp4|webm|ogg|mp3|wav|flac|aac|pdf)$ {
add_header Cache-Control "public, max-age=31536000, immutable" always;
- add_header X-Content-Type-Options "nosniff" always;
access_log off;
try_files $uri =404;
}
- # HTML — always revalidate
+ # Generated OG share-card images - immutable, cached forever
+ location ~ ^/(.*/)?opengraph-image(-[^/]+)?$ {
+ default_type image/png;
+ add_header Cache-Control "public, max-age=31536000, immutable" always;
+ access_log off;
+ try_files $uri =404;
+ }
+
+ # HTML files - always revalidate
location ~* \.html$ {
add_header Cache-Control "public, max-age=0, must-revalidate" always;
- add_header X-Content-Type-Options "nosniff" always;
try_files $uri =404;
}
+ # Catch-all for pages/routes not matched above - always revalidate
location / {
add_header Cache-Control "public, max-age=0, must-revalidate" always;
try_files $uri $uri.html $uri/index.html =404;
diff --git a/website-next/src/helpers/docsParams.ts b/website-next/src/helpers/docsParams.ts
new file mode 100644
index 00000000000..eb0874257f3
--- /dev/null
+++ b/website-next/src/helpers/docsParams.ts
@@ -0,0 +1,77 @@
+import fs from "node:fs";
+import path from "node:path";
+
+/**
+ * Root directory that holds all docs markdown content. Shared by the docs page
+ * and its Open Graph image route so they enumerate the exact same slugs.
+ */
+export const CONTENT_ROOT = path.join(process.cwd(), "content/docs");
+
+/**
+ * Enumerates the doc slugs (one `string[]` per route) from the markdown files
+ * under `CONTENT_ROOT`. `index.md(x)` files map to their parent directory.
+ *
+ * `output: export` requires at least one prerendered path, so a `__empty__`
+ * placeholder is returned when no content is present; the page renders a 404
+ * for it via `notFound()`.
+ */
+export function listDocSlugs(): string[][] {
+ const slugs = walk(CONTENT_ROOT)
+ .filter((f) => /\.mdx?$/.test(f))
+ .map((f) => path.relative(CONTENT_ROOT, f).replace(/\.mdx?$/, ""))
+ .map((rel) => rel.split(path.sep))
+ .map((parts) =>
+ parts[parts.length - 1] === "index" ? parts.slice(0, -1) : parts,
+ )
+ .filter((slug) => slug.length > 0);
+
+ return slugs.length > 0 ? slugs : [["__empty__"]];
+}
+
+/**
+ * Separator used to flatten a doc slug (`["foo", "bar"]`) into the single
+ * opaque `[id]` segment of the Open Graph image route. Catch-all segments
+ * cannot be followed by the `opengraph-image` file convention, so the docs
+ * share-card route lives under `app/docs-og/[id]` and the page metadata points
+ * at it. No doc slug contains this separator.
+ */
+const SLUG_ID_SEPARATOR = "__";
+
+/** Flattens a doc slug into the opaque `[id]` segment. */
+export function encodeDocId(slug: string[]): string {
+ return slug.join(SLUG_ID_SEPARATOR);
+}
+
+/** Expands an opaque `[id]` segment back into a doc slug. */
+export function decodeDocId(id: string): string[] {
+ return id.split(SLUG_ID_SEPARATOR);
+}
+
+/**
+ * Resolves a doc slug to its markdown file path relative to `CONTENT_ROOT`,
+ * or `null` when no matching file exists.
+ */
+export function resolveFile(slug: string[]): string | null {
+ const joined = slug.join("/");
+ const candidates = [
+ `${joined}.md`,
+ `${joined}.mdx`,
+ `${joined}/index.md`,
+ `${joined}/index.mdx`,
+ ];
+
+ for (const c of candidates) {
+ if (fs.existsSync(path.join(CONTENT_ROOT, c))) {
+ return c;
+ }
+ }
+ return null;
+}
+
+function walk(dir: string): string[] {
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
+ return entries.flatMap((e) => {
+ const full = path.join(dir, e.name);
+ return e.isDirectory() ? walk(full) : [full];
+ });
+}
diff --git a/website-next/src/helpers/siteUrl.ts b/website-next/src/helpers/siteUrl.ts
index e1d98ba989c..cf40a19e0c7 100644
--- a/website-next/src/helpers/siteUrl.ts
+++ b/website-next/src/helpers/siteUrl.ts
@@ -5,3 +5,15 @@
export const SITE_URL = (
process.env.NEXT_PUBLIC_SITE_URL ?? "https://chillicream.com"
).replace(/\/+$/, "");
+
+/**
+ * Turns a path into an absolute URL against {@link SITE_URL}. Root-relative
+ * paths (`/foo`) are prefixed with the site origin; values that are already
+ * absolute (`https://…`) or protocol-relative (`//…`) are returned unchanged.
+ */
+export function toAbsoluteUrl(pathOrUrl: string): string {
+ if (/^(https?:)?\/\//.test(pathOrUrl)) {
+ return pathOrUrl;
+ }
+ return `${SITE_URL}${pathOrUrl.startsWith("/") ? "" : "/"}${pathOrUrl}`;
+}
diff --git a/website-next/src/og/ShareCard.tsx b/website-next/src/og/ShareCard.tsx
new file mode 100644
index 00000000000..87dd076e3ff
--- /dev/null
+++ b/website-next/src/og/ShareCard.tsx
@@ -0,0 +1,97 @@
+import { ccAccent, ccBg, ccInk, ccSurface } from "@/src/theme/colors";
+
+/** `#rrggbb` -> `rgba(r, g, b, a)`, so gradients derive from the same tokens. */
+function rgba(hex: string, alpha: number): string {
+ const r = parseInt(hex.slice(1, 3), 16);
+ const g = parseInt(hex.slice(3, 5), 16);
+ const b = parseInt(hex.slice(5, 7), 16);
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
+}
+
+type ShareCardProps = {
+ /** Brand badge text in the top-right box. */
+ badge: string;
+ /** Small uppercase accent line above the title. */
+ eyebrow: string;
+ /** Headline, large text lower-left. */
+ title: string;
+};
+
+/**
+ * The shared 1200x630 share-card layout used by both the per-doc OG image and
+ * the default marketing OG image. Satori (next/og) supports only flexbox and a
+ * subset of CSS, so this stays within those constraints.
+ */
+export function ShareCard({ badge, eyebrow, title }: ShareCardProps) {
+ return (
+
+
+ {badge}
+
+
+
+
+ {eyebrow}
+
+
+ {title}
+
+
+
+ );
+}
diff --git a/website-next/src/og/fonts.ts b/website-next/src/og/fonts.ts
new file mode 100644
index 00000000000..0d0b33bd85d
--- /dev/null
+++ b/website-next/src/og/fonts.ts
@@ -0,0 +1,31 @@
+import { readFile } from "node:fs/promises";
+import path from "node:path";
+
+const FONT_DIR = path.join(process.cwd(), "src/og/fonts");
+
+/**
+ * Shape `ImageResponse` (from `next/og`) expects for the `fonts` option.
+ */
+export type OgFont = {
+ name: string;
+ data: Buffer;
+ weight: 400 | 700;
+ style: "normal";
+};
+
+/**
+ * Loads the vendored Inter TTFs (regular + bold) as raw font bytes for
+ * `ImageResponse`. Reading from `process.cwd()` works at build time, which is
+ * required for the static export (`output: "export"`).
+ */
+export async function loadInterFonts(): Promise {
+ const [regular, bold] = await Promise.all([
+ readFile(path.join(FONT_DIR, "Inter-Regular.ttf")),
+ readFile(path.join(FONT_DIR, "Inter-Bold.ttf")),
+ ]);
+
+ return [
+ { name: "Inter", data: regular, weight: 400, style: "normal" },
+ { name: "Inter", data: bold, weight: 700, style: "normal" },
+ ];
+}
diff --git a/website-next/src/og/fonts/Inter-Bold.ttf b/website-next/src/og/fonts/Inter-Bold.ttf
new file mode 100644
index 00000000000..47e27502649
Binary files /dev/null and b/website-next/src/og/fonts/Inter-Bold.ttf differ
diff --git a/website-next/src/og/fonts/Inter-Regular.ttf b/website-next/src/og/fonts/Inter-Regular.ttf
new file mode 100644
index 00000000000..1e228ab9ab1
Binary files /dev/null and b/website-next/src/og/fonts/Inter-Regular.ttf differ
diff --git a/website-next/src/og/fonts/LICENSE.md b/website-next/src/og/fonts/LICENSE.md
new file mode 100644
index 00000000000..b9e570295da
--- /dev/null
+++ b/website-next/src/og/fonts/LICENSE.md
@@ -0,0 +1,11 @@
+# Inter font files
+
+`Inter-Regular.ttf` (weight 400) and `Inter-Bold.ttf` (weight 700) are static
+subsets of the [Inter](https://github.com/rsms/inter) typeface, sourced from the
+[Fontsource](https://fontsource.org/fonts/inter) jsDelivr CDN
+(`fonts/inter@latest/latin-{400,700}-normal.ttf`).
+
+Inter is licensed under the SIL Open Font License, Version 1.1
+(). These files are vendored so that
+`next/og` (`ImageResponse`) can render Open Graph share cards at build time
+without any network access.
diff --git a/website-next/src/theme/colors.ts b/website-next/src/theme/colors.ts
new file mode 100644
index 00000000000..82af6d6425c
--- /dev/null
+++ b/website-next/src/theme/colors.ts
@@ -0,0 +1,14 @@
+// Mirror of the cc-* color tokens in app/globals.css @theme. Keep in sync —
+// satori (next/og) cannot read the CSS @theme tokens at build time.
+
+export const ccBg = "#0b0f1a";
+export const ccInk = "#f5f1ea";
+export const ccAccent = "#5eead4";
+export const ccSurface = "#0c1322";
+
+export const ccColors = {
+ bg: ccBg,
+ ink: ccInk,
+ accent: ccAccent,
+ surface: ccSurface,
+} as const;