Skip to content

Commit f4824a9

Browse files
Add share cards to website-next (#9804)
1 parent a33389e commit f4824a9

14 files changed

Lines changed: 402 additions & 54 deletions

File tree

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { ImageResponse } from "next/og";
2+
import { loadInterFonts } from "@/src/og/fonts";
3+
import { ShareCard } from "@/src/og/ShareCard";
4+
5+
// TODO: Proper styling and layout of share cards
6+
7+
// Required under `output: export` for this paramless metadata route, which has
8+
// no `generateStaticParams` to imply prerendering (unlike the per-doc card).
9+
export const dynamic = "force-static";
10+
11+
// Mirrors the brand strings in app/layout.tsx (TITLE is the headline).
12+
const TITLE = "ChilliCream GraphQL Platform";
13+
14+
export const alt = TITLE;
15+
16+
export const size = {
17+
width: 1200,
18+
height: 630,
19+
};
20+
21+
export const contentType = "image/png";
22+
23+
export default async function Image() {
24+
const fonts = await loadInterFonts();
25+
26+
return new ImageResponse(
27+
<ShareCard badge="ChilliCream" eyebrow="chillicream.com" title={TITLE} />,
28+
{
29+
...size,
30+
fonts,
31+
},
32+
);
33+
}

website-next/app/blog/[...slug]/page.tsx

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { findSimilarPosts, listBlogPostSummaries } from "@/src/helpers/blogPosts
1818
import { compileDoc } from "@/src/helpers/compileDoc";
1919
import { readFrontmatter } from "@/src/helpers/readFrontmatter";
2020
import { estimateReadingTime } from "@/src/helpers/readingTime";
21+
import { toAbsoluteUrl } from "@/src/helpers/siteUrl";
2122

2223
type BlogFrontmatter = {
2324
title?: string;
@@ -67,9 +68,29 @@ export async function generateMetadata({
6768
return {};
6869
}
6970
const { title, description } = readFrontmatter(path.join(BLOG_ROOT, rel));
71+
72+
const stem = stemForSlug(slug);
73+
const summary = listBlogPostSummaries().find((s) => s.stem === stem);
74+
const featuredImageAbs = summary?.featuredImage
75+
? toAbsoluteUrl(summary.featuredImage)
76+
: undefined;
77+
const images = featuredImageAbs ? [featuredImageAbs] : undefined;
78+
7079
return {
7180
title,
7281
description,
82+
openGraph: {
83+
type: "article",
84+
title,
85+
description,
86+
images,
87+
},
88+
twitter: {
89+
card: "summary_large_image",
90+
title,
91+
description,
92+
images,
93+
},
7394
};
7495
}
7596

@@ -93,7 +114,7 @@ export default async function BlogSlugPage({ params }: PageProps) {
93114
const readingTime = estimateReadingTime(raw).text;
94115

95116
const summaries = listBlogPostSummaries();
96-
const stem = `${slug[0]}-${slug[1]}-${slug[2]}-${slug.slice(3).join("/")}`;
117+
const stem = stemForSlug(slug);
97118
const current = summaries.find((s) => s.stem === stem);
98119
const similar = current ? findSimilarPosts(current, summaries) : [];
99120
const featuredImage = current?.featuredImage ?? null;
@@ -133,6 +154,10 @@ function isPaginationSlug(slug: string[]): boolean {
133154
return slug.length === 1 && /^\d+$/.test(slug[0]);
134155
}
135156

157+
function stemForSlug(slug: string[]): string {
158+
return `${slug[0]}-${slug[1]}-${slug[2]}-${slug.slice(3).join("/")}`;
159+
}
160+
136161
function renderPagination(pageNum: number) {
137162
if (!Number.isInteger(pageNum) || pageNum < 2) {
138163
notFound();
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import path from "node:path";
2+
import { ImageResponse } from "next/og";
3+
import { PRODUCTS } from "@/src/data/products";
4+
import {
5+
CONTENT_ROOT,
6+
decodeDocId,
7+
encodeDocId,
8+
listDocSlugs,
9+
resolveFile,
10+
} from "@/src/helpers/docsParams";
11+
import { readFrontmatter } from "@/src/helpers/readFrontmatter";
12+
import { loadInterFonts } from "@/src/og/fonts";
13+
import { ShareCard } from "@/src/og/ShareCard";
14+
15+
// TODO: Proper styling and layout of share cards
16+
17+
export const dynamicParams = false;
18+
19+
export const alt = "ChilliCream documentation";
20+
21+
export const size = {
22+
width: 1200,
23+
height: 630,
24+
};
25+
26+
export const contentType = "image/png";
27+
28+
type Params = {
29+
id: string;
30+
};
31+
32+
export function generateStaticParams(): Params[] {
33+
return listDocSlugs().map((slug) => ({ id: encodeDocId(slug) }));
34+
}
35+
36+
export default async function Image({ params }: { params: Promise<Params> }) {
37+
const { id } = await params;
38+
const slug = decodeDocId(id);
39+
const rel = resolveFile(slug);
40+
const frontmatter = rel
41+
? readFrontmatter(path.join(CONTENT_ROOT, rel))
42+
: null;
43+
44+
const productSlug = slug[0];
45+
const product = PRODUCTS.find((p) => p.slug === productSlug);
46+
const eyebrow = product?.title ?? "ChilliCream";
47+
const title = frontmatter?.title ?? product?.title ?? "ChilliCream";
48+
49+
const fonts = await loadInterFonts();
50+
51+
return new ImageResponse(
52+
<ShareCard badge={eyebrow} eyebrow={eyebrow} title={title} />,
53+
{
54+
...size,
55+
fonts,
56+
},
57+
);
58+
}
Lines changed: 30 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import fs from "node:fs";
21
import path from "node:path";
32
import type { Metadata } from "next";
43
import { notFound } from "next/navigation";
@@ -7,11 +6,16 @@ import { EditOnGitHub } from "@/src/design-system/EditOnGitHub";
76
import { TableOfContents } from "@/src/design-system/TableOfContents";
87
import { Typography } from "@/src/design-system/Typography";
98
import { compileDoc } from "@/src/helpers/compileDoc";
9+
import {
10+
CONTENT_ROOT,
11+
encodeDocId,
12+
listDocSlugs,
13+
resolveFile,
14+
} from "@/src/helpers/docsParams";
1015
import { getGitMetadata } from "@/src/helpers/gitMetadata";
1116
import { githubEditUrl } from "@/src/helpers/githubEditUrl";
1217
import { readFrontmatter } from "@/src/helpers/readFrontmatter";
13-
14-
const CONTENT_ROOT = path.join(process.cwd(), "content/docs");
18+
import { toAbsoluteUrl } from "@/src/helpers/siteUrl";
1519

1620
type Params = {
1721
slug: string[];
@@ -24,19 +28,7 @@ type PageProps = {
2428
export const dynamicParams = false;
2529

2630
export function generateStaticParams(): Params[] {
27-
const params = walk(CONTENT_ROOT)
28-
.filter((f) => /\.mdx?$/.test(f))
29-
.map((f) => path.relative(CONTENT_ROOT, f).replace(/\.mdx?$/, ""))
30-
.map((rel) => rel.split(path.sep))
31-
.map((parts) =>
32-
parts[parts.length - 1] === "index" ? parts.slice(0, -1) : parts,
33-
)
34-
.filter((slug) => slug.length > 0)
35-
.map((slug) => ({ slug }));
36-
37-
// output: export requires at least one prerendered path; placeholder
38-
// renders 404 via notFound() when no content is present.
39-
return params.length > 0 ? params : [{ slug: ["__empty__"] }];
31+
return listDocSlugs().map((slug) => ({ slug }));
4032
}
4133

4234
export async function generateMetadata({
@@ -48,9 +40,31 @@ export async function generateMetadata({
4840
return {};
4941
}
5042
const { title, description } = readFrontmatter(path.join(CONTENT_ROOT, rel));
43+
44+
const id = encodeDocId(slug);
45+
const ogImage = {
46+
url: toAbsoluteUrl(`/docs-og/${id}/opengraph-image`),
47+
width: 1200,
48+
height: 630,
49+
type: "image/png",
50+
alt: title ? `${title} documentation` : "ChilliCream documentation",
51+
};
52+
5153
return {
5254
title,
5355
description,
56+
openGraph: {
57+
type: "article",
58+
title,
59+
description,
60+
images: [ogImage],
61+
},
62+
twitter: {
63+
card: "summary_large_image",
64+
title,
65+
description,
66+
images: [ogImage],
67+
},
5468
};
5569
}
5670

@@ -89,28 +103,3 @@ export default async function DocPage({ params }: PageProps) {
89103
</div>
90104
);
91105
}
92-
93-
function resolveFile(slug: string[]): string | null {
94-
const joined = slug.join("/");
95-
const candidates = [
96-
`${joined}.md`,
97-
`${joined}.mdx`,
98-
`${joined}/index.md`,
99-
`${joined}/index.mdx`,
100-
];
101-
102-
for (const c of candidates) {
103-
if (fs.existsSync(path.join(CONTENT_ROOT, c))) {
104-
return c;
105-
}
106-
}
107-
return null;
108-
}
109-
110-
function walk(dir: string): string[] {
111-
const entries = fs.readdirSync(dir, { withFileTypes: true });
112-
return entries.flatMap((e) => {
113-
const full = path.join(dir, e.name);
114-
return e.isDirectory() ? walk(full) : [full];
115-
});
116-
}

website-next/app/globals.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
/* Disable default Tailwind colors */
1010
--color-*: initial;
1111

12+
/* The cc-* colors below are mirrored in src/theme/colors.ts for the OG share cards. */
1213
--color-cc-ink: #f5f1ea;
1314
/* Long-form body prose: lighter than `cc-ink-dim` so paragraphs stay readable. */
1415
--color-cc-prose: rgba(245, 241, 234, 0.8);

website-next/nginx/conf.d/default.conf

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,38 +9,38 @@ server {
99

1010
error_page 404 /404.html;
1111

12-
# security headers
13-
add_header X-Content-Type-Options "nosniff" always;
14-
add_header X-Frame-Options "SAMEORIGIN" always;
15-
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
16-
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
17-
1812
# redirects (managed in redirects.conf)
1913
include /etc/nginx/conf.d/redirects.conf;
2014

21-
# Next.js hashed build assets immutable forever
15+
# Next.js hashed build assets - immutable, cached forever
2216
location /_next/static/ {
2317
add_header Cache-Control "public, max-age=31536000, immutable" always;
24-
add_header X-Content-Type-Options "nosniff" always;
2518
access_log off;
2619
try_files $uri =404;
2720
}
2821

29-
# static asset extensions — long cache
22+
# Static asset files (CSS, JS, fonts, images, media) - immutable, cached forever
3023
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)$ {
3124
add_header Cache-Control "public, max-age=31536000, immutable" always;
32-
add_header X-Content-Type-Options "nosniff" always;
3325
access_log off;
3426
try_files $uri =404;
3527
}
3628

37-
# HTML — always revalidate
29+
# Generated OG share-card images - immutable, cached forever
30+
location ~ ^/(.*/)?opengraph-image(-[^/]+)?$ {
31+
default_type image/png;
32+
add_header Cache-Control "public, max-age=31536000, immutable" always;
33+
access_log off;
34+
try_files $uri =404;
35+
}
36+
37+
# HTML files - always revalidate
3838
location ~* \.html$ {
3939
add_header Cache-Control "public, max-age=0, must-revalidate" always;
40-
add_header X-Content-Type-Options "nosniff" always;
4140
try_files $uri =404;
4241
}
4342

43+
# Catch-all for pages/routes not matched above - always revalidate
4444
location / {
4545
add_header Cache-Control "public, max-age=0, must-revalidate" always;
4646
try_files $uri $uri.html $uri/index.html =404;
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import fs from "node:fs";
2+
import path from "node:path";
3+
4+
/**
5+
* Root directory that holds all docs markdown content. Shared by the docs page
6+
* and its Open Graph image route so they enumerate the exact same slugs.
7+
*/
8+
export const CONTENT_ROOT = path.join(process.cwd(), "content/docs");
9+
10+
/**
11+
* Enumerates the doc slugs (one `string[]` per route) from the markdown files
12+
* under `CONTENT_ROOT`. `index.md(x)` files map to their parent directory.
13+
*
14+
* `output: export` requires at least one prerendered path, so a `__empty__`
15+
* placeholder is returned when no content is present; the page renders a 404
16+
* for it via `notFound()`.
17+
*/
18+
export function listDocSlugs(): string[][] {
19+
const slugs = walk(CONTENT_ROOT)
20+
.filter((f) => /\.mdx?$/.test(f))
21+
.map((f) => path.relative(CONTENT_ROOT, f).replace(/\.mdx?$/, ""))
22+
.map((rel) => rel.split(path.sep))
23+
.map((parts) =>
24+
parts[parts.length - 1] === "index" ? parts.slice(0, -1) : parts,
25+
)
26+
.filter((slug) => slug.length > 0);
27+
28+
return slugs.length > 0 ? slugs : [["__empty__"]];
29+
}
30+
31+
/**
32+
* Separator used to flatten a doc slug (`["foo", "bar"]`) into the single
33+
* opaque `[id]` segment of the Open Graph image route. Catch-all segments
34+
* cannot be followed by the `opengraph-image` file convention, so the docs
35+
* share-card route lives under `app/docs-og/[id]` and the page metadata points
36+
* at it. No doc slug contains this separator.
37+
*/
38+
const SLUG_ID_SEPARATOR = "__";
39+
40+
/** Flattens a doc slug into the opaque `[id]` segment. */
41+
export function encodeDocId(slug: string[]): string {
42+
return slug.join(SLUG_ID_SEPARATOR);
43+
}
44+
45+
/** Expands an opaque `[id]` segment back into a doc slug. */
46+
export function decodeDocId(id: string): string[] {
47+
return id.split(SLUG_ID_SEPARATOR);
48+
}
49+
50+
/**
51+
* Resolves a doc slug to its markdown file path relative to `CONTENT_ROOT`,
52+
* or `null` when no matching file exists.
53+
*/
54+
export function resolveFile(slug: string[]): string | null {
55+
const joined = slug.join("/");
56+
const candidates = [
57+
`${joined}.md`,
58+
`${joined}.mdx`,
59+
`${joined}/index.md`,
60+
`${joined}/index.mdx`,
61+
];
62+
63+
for (const c of candidates) {
64+
if (fs.existsSync(path.join(CONTENT_ROOT, c))) {
65+
return c;
66+
}
67+
}
68+
return null;
69+
}
70+
71+
function walk(dir: string): string[] {
72+
const entries = fs.readdirSync(dir, { withFileTypes: true });
73+
return entries.flatMap((e) => {
74+
const full = path.join(dir, e.name);
75+
return e.isDirectory() ? walk(full) : [full];
76+
});
77+
}

0 commit comments

Comments
 (0)