Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions website-next/app/(content)/opengraph-image.tsx
Original file line number Diff line number Diff line change
@@ -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(
<ShareCard badge="ChilliCream" eyebrow="chillicream.com" title={TITLE} />,
{
...size,
fonts,
},
);
}
27 changes: 26 additions & 1 deletion website-next/app/blog/[...slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
},
};
}

Expand All @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
58 changes: 58 additions & 0 deletions website-next/app/docs-og/[id]/opengraph-image.tsx
Original file line number Diff line number Diff line change
@@ -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<Params> }) {
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(
<ShareCard badge={eyebrow} eyebrow={eyebrow} title={title} />,
{
...size,
fonts,
},
);
}
71 changes: 30 additions & 41 deletions website-next/app/docs/[...slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import fs from "node:fs";
import path from "node:path";
import type { Metadata } from "next";
import { notFound } from "next/navigation";
Expand All @@ -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[];
Expand All @@ -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({
Expand All @@ -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],
},
};
}

Expand Down Expand Up @@ -89,28 +103,3 @@ export default async function DocPage({ params }: PageProps) {
</div>
);
}

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];
});
}
1 change: 1 addition & 0 deletions website-next/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
24 changes: 12 additions & 12 deletions website-next/nginx/conf.d/default.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
tobias-tengler marked this conversation as resolved.

# 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;
Comment thread
tobias-tengler marked this conversation as resolved.
}

# 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;
Comment thread
tobias-tengler marked this conversation as resolved.
}

# 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;
Comment thread
tobias-tengler marked this conversation as resolved.
}

# 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;
Comment thread
tobias-tengler marked this conversation as resolved.
}

# 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;
Expand Down
77 changes: 77 additions & 0 deletions website-next/src/helpers/docsParams.ts
Original file line number Diff line number Diff line change
@@ -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];
});
}
Loading
Loading