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;