mirror of
https://github.com/jakejarvis/stanza.git
synced 2026-08-30 23:05:33 -04:00
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:
@@ -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
@@ -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(/\/$/, "");
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 that’s yours the moment it lands.",
|
||||
path: "/",
|
||||
jsonLd: [getWebSiteJsonLd()],
|
||||
}),
|
||||
component: Page,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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",
|
||||
},
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -46,4 +46,7 @@ export default defineConfig({
|
||||
tslib: "tslib/tslib.es6.mjs",
|
||||
},
|
||||
},
|
||||
ssr: {
|
||||
external: ["@takumi-rs/image-response"],
|
||||
},
|
||||
});
|
||||
|
||||
Generated
+243
-482
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user