feat: add JSON-LD structured data, per-page OG images for docs, and swap OG image renderer

- Extend `buildHead` / `HeadOutput` with a `scripts` field for `application/ld+json` injection and a `markdownPath` field for `<link rel="alternate" type="text/markdown">`; add `getWebSiteJsonLd`, `getTechArticleJsonLd`, and `getSoftwareSourceCodeJsonLd` helpers; wire them into the home, docs, and module-detail routes
- Add an `og.docs.$` route that generates per-page OG images for docs pages using the page title/description; rename `og.$slot.$id` → `og.m.$slot.$id` to match the `/m/` route structure; regenerate the route tree
- Replace `@vercel/og` with `@takumi-rs/image-response` in `og-card.server.tsx`; remove `@vercel/analytics` and its `<Analytics />` usage from the root layout
This commit is contained in:
2026-05-23 16:33:23 -04:00
parent 25fcbd6b12
commit 9d1b79f6ae
13 changed files with 529 additions and 541 deletions
+1 -2
View File
@@ -19,6 +19,7 @@
"@stanza/registry": "workspace:*",
"@tabler/icons-react": "^3.44.0",
"@tailwindcss/vite": "^4.3.0",
"@takumi-rs/image-response": "^1.4.1",
"@tanstack/react-devtools": "^0.10.5",
"@tanstack/react-hotkeys": "^0.10.0",
"@tanstack/react-router": "^1.170.7",
@@ -26,9 +27,7 @@
"@tanstack/react-router-ssr-query": "^1.167.0",
"@tanstack/react-start": "^1.168.10",
"@tanstack/router-plugin": "^1.168.10",
"@vercel/analytics": "^2.0.1",
"@vercel/functions": "^3.6.0",
"@vercel/og": "^0.11.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"fumadocs-core": "^16.9.0",
+111 -1
View File
@@ -2,6 +2,13 @@ const SITE_URL = process.env.SITE_URL ?? "https://stanza.tools";
const DEFAULT_TITLE = "stanza";
const DEFAULT_DESCRIPTION = "Modular monorepo template builder.";
const REPO_URL = "https://github.com/jakejarvis/stanza";
type JsonLdValue = string | number | boolean | null | JsonLdObject | readonly JsonLdValue[];
export type JsonLdObject = {
[key: string]: JsonLdValue | undefined;
};
export type HeadInput = {
/** Page-specific title. Concatenated to the site name: `${title} · stanza`. */
@@ -13,11 +20,16 @@ export type HeadInput = {
ogImage?: string;
/** Defaults to `"website"`; module detail pages use `"article"`. */
type?: "website" | "article";
/** Path to a Markdown alternate of this page (e.g. `/docs/registry.md`). */
markdownPath?: string;
/** schema.org JSON-LD objects to emit as inline `<script type="application/ld+json">`. */
jsonLd?: readonly JsonLdObject[];
};
export type HeadOutput = {
meta: Array<Record<string, string>>;
links: Array<Record<string, string>>;
scripts: Array<{ type: "application/ld+json"; children: string }>;
};
export function buildHead(input: HeadInput): HeadOutput {
@@ -27,6 +39,17 @@ export function buildHead(input: HeadInput): HeadOutput {
const ogImage = abs(input.ogImage ?? "/og");
const type = input.type ?? "website";
const links: Array<Record<string, string>> = [{ rel: "canonical", href: url }];
if (input.markdownPath) {
links.push({
rel: "alternate",
type: "text/markdown",
href: abs(input.markdownPath),
title: `${title} as Markdown`,
});
}
return {
meta: [
{ charSet: "utf-8" },
@@ -44,10 +67,97 @@ export function buildHead(input: HeadInput): HeadOutput {
{ name: "twitter:description", content: description },
{ name: "twitter:image", content: ogImage },
],
links: [{ rel: "canonical", href: url }],
links,
scripts: (input.jsonLd ?? []).map((entry) => ({
type: "application/ld+json",
children: serializeJsonLd(entry),
})),
};
}
export function getWebSiteJsonLd(): JsonLdObject {
return {
"@context": "https://schema.org",
"@type": "WebSite",
name: DEFAULT_TITLE,
url: abs("/"),
description: DEFAULT_DESCRIPTION,
publisher: {
"@type": "Organization",
name: DEFAULT_TITLE,
url: abs("/"),
sameAs: [REPO_URL],
},
};
}
export function getTechArticleJsonLd({
title,
description,
path,
section,
}: {
title: string;
description: string;
path: string;
section?: string;
}): JsonLdObject {
const url = abs(path);
return {
"@context": "https://schema.org",
"@type": "TechArticle",
headline: title,
name: title,
description: description.trim() || DEFAULT_DESCRIPTION,
url,
mainEntityOfPage: url,
...(section ? { articleSection: section } : {}),
isPartOf: {
"@type": "WebSite",
name: DEFAULT_TITLE,
url: abs("/"),
},
};
}
export function getSoftwareSourceCodeJsonLd({
name,
description,
path,
version,
author,
homepage,
}: {
name: string;
description: string;
path: string;
version?: string;
author?: string;
homepage?: string;
}): JsonLdObject {
const url = abs(path);
return {
"@context": "https://schema.org",
"@type": "SoftwareSourceCode",
name,
description: description.trim() || DEFAULT_DESCRIPTION,
url,
programmingLanguage: "TypeScript",
...(version ? { softwareVersion: version } : {}),
...(author ? { author: { "@type": "Person", name: author } } : {}),
...(homepage ? { sameAs: [homepage] } : {}),
isPartOf: {
"@type": "WebSite",
name: DEFAULT_TITLE,
url: abs("/"),
},
};
}
function serializeJsonLd(jsonLd: JsonLdObject): string {
return JSON.stringify(jsonLd).replace(/</gu, "\\u003c");
}
function abs(pathOrUrl: string): string {
if (/^https?:\/\//.test(pathOrUrl)) return pathOrUrl;
const base = SITE_URL.replace(/\/$/, "");
+38 -17
View File
@@ -16,8 +16,9 @@ import { Route as DocsLlmsDottxtRouteImport } from './routes/docs.llms[.]txt'
import { Route as DocsLlmsFullDottxtRouteImport } from './routes/docs.llms-full[.]txt'
import { Route as DocsSplatRouteImport } from './routes/docs.$'
import { Route as ApiEventsRouteImport } from './routes/api.events'
import { Route as OgSlotIdRouteImport } from './routes/og.$slot.$id'
import { Route as OgDocsSplatRouteImport } from './routes/og.docs.$'
import { Route as MSlotIdRouteImport } from './routes/m.$slot.$id'
import { Route as OgMSlotIdRouteImport } from './routes/og.m.$slot.$id'
const SitemapDotxmlRoute = SitemapDotxmlRouteImport.update({
id: '/sitemap.xml',
@@ -54,9 +55,9 @@ const ApiEventsRoute = ApiEventsRouteImport.update({
path: '/api/events',
getParentRoute: () => rootRouteImport,
} as any)
const OgSlotIdRoute = OgSlotIdRouteImport.update({
id: '/og/$slot/$id',
path: '/og/$slot/$id',
const OgDocsSplatRoute = OgDocsSplatRouteImport.update({
id: '/og/docs/$',
path: '/og/docs/$',
getParentRoute: () => rootRouteImport,
} as any)
const MSlotIdRoute = MSlotIdRouteImport.update({
@@ -64,6 +65,11 @@ const MSlotIdRoute = MSlotIdRouteImport.update({
path: '/m/$slot/$id',
getParentRoute: () => rootRouteImport,
} as any)
const OgMSlotIdRoute = OgMSlotIdRouteImport.update({
id: '/og/m/$slot/$id',
path: '/og/m/$slot/$id',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
@@ -74,7 +80,8 @@ export interface FileRoutesByFullPath {
'/docs/llms.txt': typeof DocsLlmsDottxtRoute
'/og/': typeof OgIndexRoute
'/m/$slot/$id': typeof MSlotIdRoute
'/og/$slot/$id': typeof OgSlotIdRoute
'/og/docs/$': typeof OgDocsSplatRoute
'/og/m/$slot/$id': typeof OgMSlotIdRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
@@ -85,7 +92,8 @@ export interface FileRoutesByTo {
'/docs/llms.txt': typeof DocsLlmsDottxtRoute
'/og': typeof OgIndexRoute
'/m/$slot/$id': typeof MSlotIdRoute
'/og/$slot/$id': typeof OgSlotIdRoute
'/og/docs/$': typeof OgDocsSplatRoute
'/og/m/$slot/$id': typeof OgMSlotIdRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
@@ -97,7 +105,8 @@ export interface FileRoutesById {
'/docs/llms.txt': typeof DocsLlmsDottxtRoute
'/og/': typeof OgIndexRoute
'/m/$slot/$id': typeof MSlotIdRoute
'/og/$slot/$id': typeof OgSlotIdRoute
'/og/docs/$': typeof OgDocsSplatRoute
'/og/m/$slot/$id': typeof OgMSlotIdRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
@@ -110,7 +119,8 @@ export interface FileRouteTypes {
| '/docs/llms.txt'
| '/og/'
| '/m/$slot/$id'
| '/og/$slot/$id'
| '/og/docs/$'
| '/og/m/$slot/$id'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
@@ -121,7 +131,8 @@ export interface FileRouteTypes {
| '/docs/llms.txt'
| '/og'
| '/m/$slot/$id'
| '/og/$slot/$id'
| '/og/docs/$'
| '/og/m/$slot/$id'
id:
| '__root__'
| '/'
@@ -132,7 +143,8 @@ export interface FileRouteTypes {
| '/docs/llms.txt'
| '/og/'
| '/m/$slot/$id'
| '/og/$slot/$id'
| '/og/docs/$'
| '/og/m/$slot/$id'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
@@ -144,7 +156,8 @@ export interface RootRouteChildren {
DocsLlmsDottxtRoute: typeof DocsLlmsDottxtRoute
OgIndexRoute: typeof OgIndexRoute
MSlotIdRoute: typeof MSlotIdRoute
OgSlotIdRoute: typeof OgSlotIdRoute
OgDocsSplatRoute: typeof OgDocsSplatRoute
OgMSlotIdRoute: typeof OgMSlotIdRoute
}
declare module '@tanstack/react-router' {
@@ -198,11 +211,11 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiEventsRouteImport
parentRoute: typeof rootRouteImport
}
'/og/$slot/$id': {
id: '/og/$slot/$id'
path: '/og/$slot/$id'
fullPath: '/og/$slot/$id'
preLoaderRoute: typeof OgSlotIdRouteImport
'/og/docs/$': {
id: '/og/docs/$'
path: '/og/docs/$'
fullPath: '/og/docs/$'
preLoaderRoute: typeof OgDocsSplatRouteImport
parentRoute: typeof rootRouteImport
}
'/m/$slot/$id': {
@@ -212,6 +225,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof MSlotIdRouteImport
parentRoute: typeof rootRouteImport
}
'/og/m/$slot/$id': {
id: '/og/m/$slot/$id'
path: '/og/m/$slot/$id'
fullPath: '/og/m/$slot/$id'
preLoaderRoute: typeof OgMSlotIdRouteImport
parentRoute: typeof rootRouteImport
}
}
}
@@ -224,7 +244,8 @@ const rootRouteChildren: RootRouteChildren = {
DocsLlmsDottxtRoute: DocsLlmsDottxtRoute,
OgIndexRoute: OgIndexRoute,
MSlotIdRoute: MSlotIdRoute,
OgSlotIdRoute: OgSlotIdRoute,
OgDocsSplatRoute: OgDocsSplatRoute,
OgMSlotIdRoute: OgMSlotIdRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
-2
View File
@@ -1,7 +1,6 @@
import { TanStackDevtools } from "@tanstack/react-devtools";
import { HeadContent, Link, Outlet, Scripts, createRootRoute } from "@tanstack/react-router";
import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools";
import { Analytics } from "@vercel/analytics/react";
import { Footer } from "@/components/footer";
import { Header } from "@/components/header";
@@ -105,7 +104,6 @@ function RootComponent() {
config={{ position: "bottom-right" }}
plugins={[{ name: "TanStack Router", render: <TanStackRouterDevtoolsPanel /> }]}
/>
<Analytics />
<Scripts />
</body>
</html>
+17 -7
View File
@@ -9,7 +9,7 @@ import { RootProvider } from "fumadocs-ui/provider/tanstack";
import { DocsSidebar } from "@/components/docs/docs-sidebar";
import { DocsToc } from "@/components/docs/docs-toc";
import { useMDXComponents } from "@/components/mdx";
import { buildHead } from "@/lib/seo";
import { buildHead, getTechArticleJsonLd } from "@/lib/seo";
import { source } from "@/lib/source";
const serverLoader = createServerFn({ method: "GET" })
@@ -59,12 +59,22 @@ export const Route = createFileRoute("/docs/$")({
await clientLoader.preload(data.path);
return data;
},
head: ({ loaderData }) =>
buildHead({
title: loaderData?.title,
description: loaderData?.description,
path: loaderData?.url ?? "/docs",
}),
head: ({ loaderData }) => {
const path = loaderData?.url ?? "/docs";
const title = loaderData?.title;
const description = loaderData?.description;
return buildHead({
title,
description,
path,
ogImage: loaderData ? `/og${loaderData.url}` : undefined,
markdownPath: loaderData ? `${path}.md` : undefined,
jsonLd:
loaderData && title
? [getTechArticleJsonLd({ title, description: description ?? "", path })]
: undefined,
});
},
});
function Page() {
+2 -1
View File
@@ -5,7 +5,7 @@ import { load } from "@tanstack/react-start/hydration";
import { Builder } from "@/components/builder";
import type { BuilderSearch } from "@/lib/selection";
import { buildHead } from "@/lib/seo";
import { buildHead, getWebSiteJsonLd } from "@/lib/seo";
import { getBuilderState } from "@/server/builder-state.functions";
// Keys are derived from the canonical slot + add-on tuples so this never
@@ -32,6 +32,7 @@ export const Route = createFileRoute("/")({
description:
"Pick your modules and walk away with a clean TypeScript monorepo. Idiomatic, vendored code thats yours the moment it lands.",
path: "/",
jsonLd: [getWebSiteJsonLd()],
}),
component: Page,
});
+22 -13
View File
@@ -13,7 +13,7 @@ import { ModuleLogo } from "@/components/module-logo";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import type { Selections } from "@/lib/selection";
import { buildHead } from "@/lib/seo";
import { buildHead, getSoftwareSourceCodeJsonLd } from "@/lib/seo";
import { getModuleDetail } from "@/server/module-detail.functions";
type DetailSearch = Partial<Record<CategoryId, string>>;
@@ -38,19 +38,28 @@ export const Route = createFileRoute("/m/$slot/$id")({
if (!detail) throw notFound();
return detail;
},
head: ({ loaderData, params }) =>
loaderData
? buildHead({
title: loaderData.module.label,
description: loaderData.module.description,
path: `/m/${params.slot}/${params.id}`,
ogImage: `/og/${params.slot}/${params.id}`,
type: "article",
})
: buildHead({
title: "Not found",
path: `/m/${params.slot}/${params.id}`,
head: ({ loaderData, params }) => {
const path = `/m/${params.slot}/${params.id}`;
if (!loaderData) return buildHead({ title: "Not found", path });
const { module } = loaderData;
return buildHead({
title: module.label,
description: module.description,
path,
ogImage: `/og/m/${params.slot}/${params.id}`,
type: "article",
jsonLd: [
getSoftwareSourceCodeJsonLd({
name: module.label,
description: module.description,
path,
version: module.version,
author: module.author,
homepage: module.homepage,
}),
],
});
},
component: ModuleDetailPage,
});
+42
View File
@@ -0,0 +1,42 @@
import { ImageResponse } from "@takumi-rs/image-response";
import { createFileRoute } from "@tanstack/react-router";
import { source } from "@/lib/source";
import { OgDocs } from "@/server/og-card.server";
/**
* `/og/docs/$splat` — per-docs-page OG card, mirroring the public
* `/docs/$splat` URL. The splat resolves to fumadocs page slugs the same way
* `routes/docs.$.tsx` does. Bails 404 when the page is unknown.
*/
export const Route = createFileRoute("/og/docs/$")({
server: {
handlers: {
GET: ({ params }) => {
// `_splat` is TanStack Router's catch-all param; bracket access dodges
// the no-underscore-dangle lint rule on a name we don't control.
const slugs = params["_splat"]?.split("/").filter(Boolean) ?? [];
const page = source.getPage(slugs);
if (!page) {
return new Response("Not found", { status: 404 });
}
return new ImageResponse(
OgDocs({
title: page.data.title,
description: page.data.description ?? "",
slug: slugs.join("/"),
}),
{
width: 1200,
height: 630,
format: "webp",
headers: {
"cache-control": "public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400",
},
},
);
},
},
},
});
+3 -2
View File
@@ -1,5 +1,5 @@
import { ImageResponse } from "@takumi-rs/image-response";
import { createFileRoute } from "@tanstack/react-router";
import { ImageResponse } from "@vercel/og";
import { OgDefault } from "@/server/og-card.server";
@@ -7,7 +7,7 @@ import { OgDefault } from "@/server/og-card.server";
* `/og` — default OG used by `/`, `/search`, and anywhere without a more
* specific image. Static-ish content; the SWR header lets the CDN serve it
* stale for a long time. A TanStack Start server route (no page component):
* the `GET` handler streams a PNG straight back.
* the `GET` handler streams a WebP straight back.
*/
export const Route = createFileRoute("/og/")({
server: {
@@ -16,6 +16,7 @@ export const Route = createFileRoute("/og/")({
new ImageResponse(OgDefault(), {
width: 1200,
height: 630,
format: "webp",
headers: {
"cache-control":
"public, max-age=86400, s-maxage=604800, stale-while-revalidate=604800",
@@ -1,18 +1,16 @@
import type { RegistryIndex } from "@stanza/registry";
import { ImageResponse } from "@takumi-rs/image-response";
import { createFileRoute } from "@tanstack/react-router";
import { ImageResponse } from "@vercel/og";
import { OgCard } from "@/server/og-card.server";
import { loadRegistryFile } from "@/server/registry-base.server";
/**
* `/og/$slot/$id` per-module OG card (e.g. `/og/auth/clerk`). Dynamically
* rendered at request time via Satori (bundled inside `@vercel/og`). The URL is
* extensionless on purpose: a `.png` segment is swallowed by Vite/Nitro static
* asset handling before routing crawlers read the `image/png` content-type
* instead. Bails 404 when the module is unknown.
* `/og/m/$slot/$id` per-module OG card (e.g. `/og/m/auth/clerk`), mirroring
* the public `/m/$slot/$id` URL. Dynamically rendered at request time via
* Takumi. Bails 404 when the module is unknown.
*/
export const Route = createFileRoute("/og/$slot/$id")({
export const Route = createFileRoute("/og/m/$slot/$id")({
server: {
handlers: {
GET: async ({ params }) => {
@@ -37,6 +35,7 @@ export const Route = createFileRoute("/og/$slot/$id")({
return new ImageResponse(OgCard({ summary }), {
width: 1200,
height: 630,
format: "webp",
headers: {
"cache-control": "public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400",
},
+41 -7
View File
@@ -6,8 +6,9 @@ import type { CSSProperties, ReactElement } from "react";
* The visual layout shared by all OG images. Lives in `src/server/` so the
* tsx/JSX parsing applies — Nitro server routes are plain .ts files.
*
* Satori (used inside @vercel/og) supports a subset of CSS — flex layout,
* absolute positioning, basic typography. No grid, no shadows on text, etc.
* Rendered by Takumi (@takumi-rs/image-response). Geist + Geist Mono are
* pre-bundled by the renderer, so naming `Geist` here resolves to the same
* typeface the live site loads via `@fontsource-variable/geist`.
*
* Styles are hoisted to module constants: these functions render once per
* image (not React components that re-render), and static style objects must
@@ -21,7 +22,7 @@ const PAGE: CSSProperties = {
background: "#0a0a0a",
color: "#fafafa",
padding: "80px",
fontFamily: "Inter, system-ui",
fontFamily: "Geist, sans-serif",
};
const HEADER_ROW: CSSProperties = { display: "flex", alignItems: "center", gap: "12px" };
@@ -82,7 +83,7 @@ const DESCRIPTION: CSSProperties = {
const FOOTER: CSSProperties = {
display: "flex",
justifyContent: "space-between",
color: "#52525b",
color: "#6c6c6c",
fontSize: "20px",
};
@@ -133,9 +134,42 @@ export function OgCard({ summary }: { summary: ModuleSummary }): ReactElement {
<div style={FOOTER}>
<span>
{summary.category}/{summary.id}
stanza.tools/m/{summary.category}/{summary.id}
</span>
<span>v{summary.version}</span>
</div>
</div>
);
}
/**
* Per-docs-page OG. Mirrors `OgCard`'s chrome (brand + section label header,
* footer slug) but swaps the body for a plain title + description block —
* docs pages don't have a logo or version to display.
*/
export function OgDocs({
title,
description,
slug,
}: {
title: string;
description: string;
slug: string;
}): ReactElement {
return (
<div style={PAGE}>
<div style={HEADER_ROW}>
<img src={svgToDataUri(BRAND_LOGO_SVG)} width={32} height={32} alt="stanza" />
<span style={DOT}>·</span>
<span style={SLOT}>Docs</span>
</div>
<div style={BODY}>
<div style={TITLE}>{title}</div>
{description ? <div style={DESCRIPTION}>{description}</div> : null}
</div>
<div style={FOOTER}>
<span>stanza.tools/docs/{slug}</span>
</div>
</div>
);
@@ -151,7 +185,7 @@ export function OgDefault(): ReactElement {
<img src={svgToDataUri(BRAND_LOGO_SVG)} width={160} height={160} alt="stanza" />
<div style={TAGLINE}>Modular monorepo template builder.</div>
</div>
<div style={DEFAULT_FOOTER}>pnpm create stanza my-app</div>
<div style={DEFAULT_FOOTER}>npm init stanza my-app</div>
</div>
);
}
+3
View File
@@ -46,4 +46,7 @@ export default defineConfig({
tslib: "tslib/tslib.es6.mjs",
},
},
ssr: {
external: ["@takumi-rs/image-response"],
},
});
+243 -482
View File
File diff suppressed because it is too large Load Diff