Files
stanza/apps/web/server/routes/sitemap.xml.ts
T
jake 5ca679cf32 feat: add module detail pages, ⌘K search, dynamic OG images, sitemap, and SEO groundwork for apps/web
- Add `/m/$slot/$id` route (`apps/web/src/routes/m.$slot.$id.tsx`) with `module-detail.ts` server function — full adapter switcher (peer chip rows), deps/devDeps/scripts table, env-var table, templates list with click-to-expand Shiki preview, and a "Try it" copy-command bar; new detail components land under `apps/web/src/components/detail/`
- Add ⌘K search popover (`apps/web/src/components/search/site-search.tsx`) backed by `apps/web/src/lib/module-search.ts` — fuzzy-matches id/label/description/slot exactly as the CLI does; wired into the header
- Add dynamic OG image generation via `@vercel/og` + Satori: `server/routes/og/[slot]/[id].ts` renders a per-module card, `server/routes/og.ts` renders the default fallback; both served as Nitro route handlers with appropriate CDN cache headers; OG React components in `apps/web/src/server/og-card.tsx`
- Add `server/routes/sitemap.xml.ts` (enumerates `/` + all `/m/$slot/$id` URLs from registry index) and `public/robots.txt` pointing at it
- Add `apps/web/src/lib/seo.ts` — `buildHead()` helper that emits `title`, `og:*`, `twitter:*`, and `canonical` tags; consumed by `__root.tsx`, `index.tsx`, and `m.$slot.$id.tsx`
- Extract `ModuleLogo` into `apps/web/src/components/module-logo.tsx` — shared between slot-cards and the new detail page; remove the duplicate from `slot-cards.tsx`
- Add new shadcn-style UI primitives: `command.tsx` (cmdk wrapper), `dialog.tsx`, `input-group.tsx`, `kbd.tsx`, `popover.tsx`, `textarea.tsx`
- Add `apps/web/src/server/registry-base.ts` (filesystem reads to avoid SSR loopback-fetch deadlock), `registry-index.ts`, and `module-detail.ts` as the server-side data layer
- Add `@vercel/og` and `cmdk` to `apps/web` dependencies; add `vitest run` as `pnpm test` in web package
- Mark all corresponding TODO items done
2026-05-20 19:36:34 -04:00

55 lines
1.6 KiB
TypeScript

import type { RegistryIndex } from "@stanza/registry";
import type { H3Event } from "nitro/h3";
import { defineHandler, getRequestHost, getRequestProtocol } from "nitro/h3";
import { loadRegistryFile } from "@/server/registry-base";
/**
* `/sitemap.xml` — enumerates the home plus every module detail page. Search
* params (peer overrides) are intentionally excluded: the canonical URL for a
* module is the bare `/m/$slot/$id`, and the page renders an auto-default
* adapter from there.
*/
export default defineHandler(async (event) => {
const origin = originFromEvent(event);
let index: RegistryIndex;
try {
index = await loadRegistryFile<RegistryIndex>("index.json");
} catch {
return new Response("Registry unavailable", { status: 502 });
}
const urls = [
{ loc: `${origin}/`, changefreq: "weekly", priority: "1.0" },
...index.modules.map((m) => ({
loc: `${origin}/m/${m.slot}/${m.id}`,
changefreq: "monthly",
priority: "0.7",
})),
];
const body = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls
.map(
(u) =>
` <url><loc>${u.loc}</loc><changefreq>${u.changefreq}</changefreq><priority>${u.priority}</priority></url>`,
)
.join("\n")}
</urlset>
`;
return new Response(body, {
headers: {
"content-type": "application/xml; charset=utf-8",
"cache-control": "public, max-age=3600, s-maxage=86400",
},
});
});
function originFromEvent(event: H3Event): string {
const host = getRequestHost(event);
const proto = getRequestProtocol(event);
return `${proto}://${host}`;
}