@agentvibes/site 0.0.0-stage → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +4 -2
  3. package/bin/agentvibes-site.mjs +7 -0
  4. package/package.json +87 -3
  5. package/src/app-mode.tsx +110 -0
  6. package/src/catalog.ts +80 -0
  7. package/src/cli.ts +51 -0
  8. package/src/client.ts +5 -0
  9. package/src/components/changelog-entry-view.tsx +11 -0
  10. package/src/components/doctor-throw/doctor-throw.tsx +15 -0
  11. package/src/components/errors-init.tsx +14 -0
  12. package/src/components/hero/hero-action-link.tsx +15 -0
  13. package/src/components/hero/hero.tsx +31 -0
  14. package/src/components/home-shell.tsx +19 -0
  15. package/src/components/jsdoc-view/group-view.tsx +14 -0
  16. package/src/components/jsdoc-view/item-view.tsx +24 -0
  17. package/src/components/jsdoc-view/jsdoc-view.tsx +16 -0
  18. package/src/components/jsdoc-view/member-view.tsx +15 -0
  19. package/src/components/module-cards/module-card.tsx +13 -0
  20. package/src/components/module-cards/module-cards.tsx +15 -0
  21. package/src/components/page-actions/copy-button.tsx +40 -0
  22. package/src/components/page-actions/page-actions.tsx +53 -0
  23. package/src/components/page-footer.tsx +20 -0
  24. package/src/components/page-link/page-link.tsx +19 -0
  25. package/src/components/site-error-boundary/error-panel.tsx +16 -0
  26. package/src/components/site-error-boundary/site-error-boundary.tsx +12 -0
  27. package/src/components/site-error-boundary/site-global-error-boundary.tsx +19 -0
  28. package/src/components/site-footer/footer-column-view.tsx +15 -0
  29. package/src/components/site-footer/footer-link.tsx +20 -0
  30. package/src/components/site-footer/site-footer.tsx +32 -0
  31. package/src/components/site-video/site-video.tsx +25 -0
  32. package/src/components/telemetry-scripts.tsx +18 -0
  33. package/src/config.ts +9 -0
  34. package/src/context.ts +68 -0
  35. package/src/data.ts +64 -0
  36. package/src/docs-title.ts +8 -0
  37. package/src/doctor-routes.tsx +44 -0
  38. package/src/index.ts +39 -0
  39. package/src/instrumentation.ts +53 -0
  40. package/src/jsdoc.ts +38 -0
  41. package/src/layout-options.ts +18 -0
  42. package/src/layouts.tsx +63 -0
  43. package/src/mdx-config.d.ts +2 -0
  44. package/src/mdx-config.js +7 -0
  45. package/src/mdx.tsx +18 -0
  46. package/src/nav.ts +97 -0
  47. package/src/og-route.tsx +29 -0
  48. package/src/output.ts +90 -0
  49. package/src/pages.tsx +186 -0
  50. package/src/prepare/assets.ts +98 -0
  51. package/src/prepare/author-next-config.ts +46 -0
  52. package/src/prepare/changelog.ts +17 -0
  53. package/src/prepare/files.ts +95 -0
  54. package/src/prepare/finish-static.ts +122 -0
  55. package/src/prepare/index.ts +291 -0
  56. package/src/prepare/jsdoc-extract.ts +182 -0
  57. package/src/prepare/release.ts +12 -0
  58. package/src/prepare/templates.ts +415 -0
  59. package/src/prepare/unimplemented.ts +32 -0
  60. package/src/proxy.ts +57 -0
  61. package/src/routes.ts +158 -0
  62. package/src/runtime.tsx +165 -0
  63. package/src/theme.ts +59 -0
package/src/proxy.ts ADDED
@@ -0,0 +1,57 @@
1
+ /**
2
+ * The Next 16 proxy (formerly middleware) of a Site Factory site:
3
+ * - `X-Robots-Tag: noindex, nofollow` on every response that may not be indexed;
4
+ * - `<path>.md` and `Accept: text/markdown` → the page's Markdown, `Vary: Accept` on pages;
5
+ * - the site's own `site.proxy.ts` guard, if it has one, runs first (a login gate).
6
+ */
7
+ import type { SiteConfig } from "@agentvibes/site-config";
8
+ import { pagePathOfMarkdown, prefersMarkdown } from "@agentvibes/site-llms";
9
+ import { decideIndexing, indexingHeaders, readDeploymentEnv } from "@agentvibes/site-seo";
10
+ import { NextResponse, type NextRequest } from "next/server";
11
+
12
+ const NON_PAGE_PREFIXES = ["/_next", "/api/", "/_a/", "/_e", "/og", "/llms.mdx"];
13
+
14
+ /** A path that renders an HTML page (no file extension, not an internal route). */
15
+ export function isPagePath(path: string): boolean {
16
+ if (NON_PAGE_PREFIXES.some((prefix) => path === prefix || path.startsWith(prefix))) return false;
17
+ const last = path.split("/").pop() ?? "";
18
+ return !last.includes(".");
19
+ }
20
+
21
+ /** Where a request for Markdown is rewritten to, or undefined when it is not one. */
22
+ export function markdownRewrite(path: string, wantsMarkdown: boolean): string | undefined {
23
+ const fromExtension = pagePathOfMarkdown(path);
24
+ const page = fromExtension ?? (wantsMarkdown && isPagePath(path) ? path : undefined);
25
+ if (page === undefined) return undefined;
26
+ const normalized = page.replace(/\/+$/, "");
27
+ return normalized === "" ? "/llms.mdx/index" : `/llms.mdx${normalized}`;
28
+ }
29
+
30
+ /**
31
+ * The default export of the optional author file `site.proxy.ts`. It sees every
32
+ * request the proxy sees, including `/llms.txt`, `<path>.md`, `/api/search` and
33
+ * `/og`. Return a response to answer the request (a redirect to a login page) or
34
+ * undefined to let it through.
35
+ */
36
+ export type SiteGuard = (request: NextRequest) => NextResponse | undefined;
37
+
38
+ export function createProxy(config: Pick<SiteConfig, "index" | "siteUrl">, guard?: SiteGuard) {
39
+ return function proxy(request: NextRequest): NextResponse {
40
+ const host = request.headers.get("x-forwarded-host") ?? request.headers.get("host") ?? undefined;
41
+ const decision = decideIndexing({ index: config.index, siteUrl: config.siteUrl, env: readDeploymentEnv(process.env), host });
42
+ const headers = indexingHeaders(decision);
43
+ const answered = guard?.(request);
44
+ if (answered !== undefined) {
45
+ for (const [name, value] of Object.entries(headers)) answered.headers.set(name, value);
46
+ return answered;
47
+ }
48
+ const path = request.nextUrl.pathname;
49
+ const target = markdownRewrite(path, prefersMarkdown(request));
50
+ const response = target === undefined ? NextResponse.next() : NextResponse.rewrite(new URL(target, request.url));
51
+ for (const [name, value] of Object.entries(headers)) response.headers.set(name, value);
52
+ if (target !== undefined || isPagePath(path)) response.headers.append("Vary", "Accept");
53
+ return response;
54
+ };
55
+ }
56
+
57
+ export { loadConfig } from "./config";
package/src/routes.ts ADDED
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Route handlers. Each takes the site runtime and returns the Next handler;
3
+ * the generated `app/**\/route.ts` files only bind them.
4
+ */
5
+ import { llmsTxt, markdownResponse, pageMarkdown, plainTextResponse, VARY_ACCEPT } from "@agentvibes/site-llms";
6
+ import { indexingHeaders, robotsTxt, sitemapXml } from "@agentvibes/site-seo";
7
+ import { createSearchAPI, type AdvancedIndex } from "fumadocs-core/search/server";
8
+ import { match } from "ts-pattern";
9
+
10
+ import { catalog, catalogSections, findPage } from "./catalog";
11
+ import { requestContextOf, type RequestContext } from "./context";
12
+ import { pathFromStaticSlug, staticSlugOf } from "./output";
13
+ import type { SiteRuntime, SiteSource } from "./runtime";
14
+
15
+ type SlugContext = { params: Promise<{ slug?: string[] }> };
16
+ type PathContext = { params: Promise<{ path: string[] }> };
17
+
18
+ function notFoundResponse(headers: Record<string, string>): Response {
19
+ return new Response("Not found", { status: 404, headers: { "content-type": "text/plain; charset=utf-8", ...headers } });
20
+ }
21
+
22
+ /**
23
+ * Whether the sitemap and llms.txt exist. On a server they follow the request's
24
+ * host. A static build only generates these routes when `index` is not false:
25
+ * a copy on another host keeps the files of its source, listing canonical
26
+ * URLs, while its pages and robots.txt stay noindex.
27
+ */
28
+ function listsPages(site: SiteRuntime, ctx: RequestContext): boolean {
29
+ return ctx.decision.kind === "index" || site.generated.output.kind === "static";
30
+ }
31
+
32
+ /** `/robots.txt`: `Disallow: /` unless the request is indexable. */
33
+ export function createRobotsRoute(site: SiteRuntime) {
34
+ return function GET(request: Request): Response {
35
+ const ctx = requestContextOf(site, request);
36
+ return plainTextResponse(robotsTxt(ctx.decision, site.config.siteUrl), indexingHeaders(ctx.decision));
37
+ };
38
+ }
39
+
40
+ /** `/sitemap.xml`: canonical URLs of every page; 404 when not indexable. */
41
+ export function createSitemapRoute(site: SiteRuntime) {
42
+ return function GET(request: Request): Response {
43
+ const ctx = requestContextOf(site, request);
44
+ if (!listsPages(site, ctx)) return notFoundResponse(indexingHeaders(ctx.decision));
45
+ return new Response(sitemapXml(site.config.siteUrl, catalog(site).map((page) => ({ path: page.path }))), {
46
+ headers: { "content-type": "application/xml; charset=utf-8" }
47
+ });
48
+ };
49
+ }
50
+
51
+ async function llmsBody(site: SiteRuntime): Promise<string> {
52
+ const generated = llmsTxt(site.seo, catalogSections(site));
53
+ const extra = (await site.entries?.llms?.())?.trim() ?? "";
54
+ return extra === "" ? generated : `${generated.trimEnd()}\n\n${extra}\n`;
55
+ }
56
+
57
+ /**
58
+ * `/llms.txt`: one section per site section, then the site's own text
59
+ * (`site.entries.ts`); 404 when not indexable (spec §4: no llms.txt).
60
+ */
61
+ export function createLlmsRoute(site: SiteRuntime) {
62
+ return async function GET(request: Request): Promise<Response> {
63
+ const ctx = requestContextOf(site, request);
64
+ if (!listsPages(site, ctx)) return notFoundResponse(indexingHeaders(ctx.decision));
65
+ return plainTextResponse(await llmsBody(site));
66
+ };
67
+ }
68
+
69
+ /** `/llms.mdx/<path>`: the Markdown twin of a page (the proxy maps `<path>.md` and `Accept: text/markdown` here). */
70
+ export function createMarkdownRoute(site: SiteRuntime) {
71
+ return async function GET(request: Request, context: SlugContext): Promise<Response> {
72
+ const ctx = requestContextOf(site, request);
73
+ const { slug = [] } = await context.params;
74
+ const path = site.generated.output.kind === "static" ? pathFromStaticSlug(slug, ".md") : slug.length === 1 && slug[0] === "index" ? "/" : `/${slug.join("/")}`;
75
+ const page = findPage(site, path);
76
+ const headers = { ...indexingHeaders(ctx.decision), ...VARY_ACCEPT };
77
+ if (page === undefined) return notFoundResponse(headers);
78
+ const text = pageMarkdown({ title: page.title, description: page.description, url: new URL(page.path, site.config.siteUrl).href, body: await page.markdown() });
79
+ return markdownResponse(text, headers);
80
+ };
81
+ }
82
+
83
+ /** Static output: one `.md` file per page; `finish-static` moves them next to their pages. */
84
+ export function createMarkdownParams(site: SiteRuntime) {
85
+ return () => catalog(site).map((page) => ({ slug: staticSlugOf(page.path, ".md") }));
86
+ }
87
+
88
+ function contentIndexes(source: SiteSource): AdvancedIndex[] {
89
+ return source.getPages().map((page) => {
90
+ const index: AdvancedIndex = { id: page.url, title: page.data.title ?? page.url, url: page.url, structuredData: page.data.structuredData };
91
+ return page.data.description === undefined ? index : { ...index, description: page.data.description };
92
+ });
93
+ }
94
+
95
+ function jsdocIndexes(site: SiteRuntime): AdvancedIndex[] {
96
+ return (site.config.sections.npm?.modules ?? []).flatMap((mod) => {
97
+ const items = site.generated.jsdoc.modules[mod.id]?.items ?? [];
98
+ if (items.length === 0) return [];
99
+ return [
100
+ {
101
+ id: `/docs/${mod.id}/jsdoc`,
102
+ title: `${mod.title} API reference`,
103
+ url: `/docs/${mod.id}/jsdoc`,
104
+ structuredData: {
105
+ headings: items.map((item) => ({ id: item.name, content: item.name })),
106
+ contents: items.map((item) => ({
107
+ heading: item.name,
108
+ content: [item.description, item.signature, ...(item.members ?? []).map((member) => member.signature)].filter((part) => part !== undefined).join(" ")
109
+ }))
110
+ }
111
+ }
112
+ ];
113
+ });
114
+ }
115
+
116
+ /** `/api/search`: Fumadocs' advanced search over docs, pages, the API references and the site's own entries (`site.entries.ts`). */
117
+ export function createSearchRoute(site: SiteRuntime) {
118
+ return createSearchAPI("advanced", {
119
+ indexes: async () => [
120
+ ...contentIndexes(site.pages),
121
+ ...(site.config.kind === "landing" ? [] : contentIndexes(site.docs)),
122
+ ...jsdocIndexes(site),
123
+ ...((await site.entries?.search?.()) ?? [])
124
+ ]
125
+ });
126
+ }
127
+
128
+ /**
129
+ * `/favicon.ico`, rendered by prepare from the site's icon and baked into
130
+ * the route file (Vercel functions do not carry `public/`).
131
+ */
132
+ export function createFaviconRoute(base64: string) {
133
+ const bytes = Buffer.from(base64, "base64");
134
+ return function GET(): Response {
135
+ return new Response(bytes, { headers: { "content-type": "image/x-icon", "cache-control": "public, max-age=86400" } });
136
+ };
137
+ }
138
+
139
+ /** `/_a/<path>`: first-party analytics tunnel; 404 unless analytics is on for this request. */
140
+ export function createAnalyticsTunnel(site: SiteRuntime) {
141
+ return async function handler(request: Request, context: PathContext): Promise<Response> {
142
+ const ctx = requestContextOf(site, request);
143
+ const provider = site.analytics;
144
+ if (provider === undefined || ctx.telemetry.analytics.kind === "off") return notFoundResponse({});
145
+ const { path } = await context.params;
146
+ return provider.tunnel(request, path.join("/"));
147
+ };
148
+ }
149
+
150
+ /** `/_e`: first-party error tunnel; 404 unless error capture is on for this request. */
151
+ export function createErrorsTunnel(site: SiteRuntime) {
152
+ return async function POST(request: Request): Promise<Response> {
153
+ const ctx = requestContextOf(site, request);
154
+ const reporter = site.errors;
155
+ if (reporter === undefined || ctx.telemetry.errors.kind === "off") return notFoundResponse({});
156
+ return reporter.tunnel(request);
157
+ };
158
+ }
@@ -0,0 +1,165 @@
1
+ /**
2
+ * The site runtime: everything a route needs, built once per server from
3
+ * site.config, the preset, the Fumadocs collections and the data baked by
4
+ * `prepare`. The generated `app/_site/runtime.ts` calls `createSiteRuntime`.
5
+ */
6
+ import { resolveTelemetryKeys, type SiteConfig, type SitePreset, type TelemetryKeys } from "@agentvibes/site-config";
7
+ import type { SeoSite } from "@agentvibes/site-seo";
8
+ import { sentryErrorReporter, umamiAnalytics, type AnalyticsProvider, type ErrorReporter, type TelemetryTransport } from "@agentvibes/site-telemetry";
9
+ import type { Root } from "fumadocs-core/page-tree";
10
+ import type { LoaderOutput, MetaData, PageData } from "fumadocs-core/source";
11
+ import { createRelativeLink } from "fumadocs-ui/mdx";
12
+ import type { StructuredData } from "fumadocs-core/mdx-plugins";
13
+ import type { AdvancedIndex } from "fumadocs-core/search/server";
14
+ import type { TOCItemType } from "fumadocs-core/toc";
15
+ import type { MDXComponents } from "mdx/types";
16
+ import type { ComponentProps, ComponentType, FC, ReactNode } from "react";
17
+ import { match } from "ts-pattern";
18
+
19
+ import type { GeneratedSiteData } from "./data";
20
+
21
+ /** What a page of a Fumadocs-MDX collection carries (with `includeProcessedMarkdown`). */
22
+ export type SitePageData = PageData & {
23
+ readonly body: ComponentType<{ components?: MDXComponents }>;
24
+ readonly toc: TOCItemType[];
25
+ readonly structuredData: StructuredData;
26
+ readonly full?: boolean | undefined;
27
+ getText(type: "raw" | "processed"): Promise<string>;
28
+ };
29
+
30
+ export type SitePage = {
31
+ readonly url: string;
32
+ readonly slugs: readonly string[];
33
+ readonly data: SitePageData;
34
+ };
35
+
36
+ /**
37
+ * The part of a Fumadocs loader the renderer uses. Fumadocs' own
38
+ * `LoaderOutput` is invariant in its page type, so the runtime adapts each
39
+ * loader to this shape once instead of carrying the generated types around.
40
+ */
41
+ export type SiteSource = {
42
+ readonly pageTree: Root;
43
+ getPage(slugs: readonly string[]): SitePage | undefined;
44
+ getPages(): SitePage[];
45
+ /** The MDX `a` of `page`: resolves relative file links (`./other.mdx`) to page URLs. */
46
+ relativeLink(page: SitePage): FC<ComponentProps<"a">>;
47
+ };
48
+
49
+ function PlainLink(props: ComponentProps<"a">) {
50
+ return <a {...props} />;
51
+ }
52
+
53
+ /** Adapt a Fumadocs loader (any page type that carries the fields the renderer reads). */
54
+ export function siteSource<P extends SitePageData, M extends MetaData>(loader: LoaderOutput<{ source: { pageData: P; metaData: M }; i18n: undefined }>): SiteSource {
55
+ return {
56
+ pageTree: loader.getPageTree(),
57
+ getPage: (slugs) => loader.getPage([...slugs]),
58
+ getPages: () => loader.getPages(),
59
+ relativeLink: (page) => {
60
+ const original = loader.getPage([...page.slugs]);
61
+ return original === undefined ? PlainLink : createRelativeLink(loader, original);
62
+ }
63
+ };
64
+ }
65
+
66
+ export type SiteRuntimeInput = {
67
+ readonly config: SiteConfig;
68
+ readonly preset: SitePreset | undefined;
69
+ readonly generated: GeneratedSiteData;
70
+ /** `content/docs`, served under `/docs`. */
71
+ readonly docs: SiteSource;
72
+ /** `content/pages`, served from `/` (the landing page is `content/pages/index.mdx`). */
73
+ readonly pages: SiteSource;
74
+ /** The site's own search documents and llms.txt text, from its `site.entries.ts`. */
75
+ readonly entries?: SiteEntries | undefined;
76
+ /** The site's own header title, footer and theme storage key, from its `site.chrome.tsx`. */
77
+ readonly chrome?: SiteChrome | undefined;
78
+ };
79
+
80
+ /**
81
+ * What a site replaces in the generated chrome, on every page including the
82
+ * docs. Default export of the site's optional `site.chrome.tsx`; each field left
83
+ * out keeps the generated default.
84
+ */
85
+ export type SiteChrome = {
86
+ /** Shown in the header instead of `config.name`. */
87
+ readonly navTitle?: ReactNode;
88
+ /** Shown instead of the generated footer. */
89
+ readonly footer?: ReactNode;
90
+ /** localStorage key of the visitor's theme choice, to keep a choice saved before the move to the generator. */
91
+ readonly themeStorageKey?: string;
92
+ };
93
+
94
+ /** Typed identity for `site.chrome.tsx`: `export default defineSiteChrome({ navTitle, footer })`. */
95
+ export function defineSiteChrome(chrome: SiteChrome): SiteChrome {
96
+ return chrome;
97
+ }
98
+
99
+ /** A search document: what Fumadocs' advanced search indexes (title, URL, headings and their text). */
100
+ export type SiteSearchEntry = AdvancedIndex;
101
+
102
+ /**
103
+ * What a site adds to the generated `/api/search` and `/llms.txt`, for
104
+ * content that is not an MDX page (a gallery, a catalogue, app screens).
105
+ * Default export of the site's optional `site.entries.ts`; the generated
106
+ * routes still decide indexing, so a noindex request gets no llms.txt.
107
+ */
108
+ export type SiteEntries = {
109
+ /** Documents searched besides the pages, the docs and the API references. */
110
+ readonly search?: () => readonly SiteSearchEntry[] | Promise<readonly SiteSearchEntry[]>;
111
+ /** Markdown appended to llms.txt after the generated sections (start it with a `## ` heading). */
112
+ readonly llms?: () => string | Promise<string>;
113
+ };
114
+
115
+ /** Typed identity for `site.entries.ts`: `export default defineSiteEntries({ search, llms })`. */
116
+ export function defineSiteEntries(entries: SiteEntries): SiteEntries {
117
+ return entries;
118
+ }
119
+
120
+ export type SiteRuntime = SiteRuntimeInput & {
121
+ readonly seo: SeoSite;
122
+ readonly telemetryKeys: TelemetryKeys;
123
+ readonly analytics: AnalyticsProvider | undefined;
124
+ readonly errors: ErrorReporter | undefined;
125
+ };
126
+
127
+ /** A static host has no tunnels to post to, so the browser talks to the collector itself. */
128
+ export function telemetryTransport(site: SiteRuntime): TelemetryTransport {
129
+ return site.generated.output.kind === "static" ? "direct" : "tunnel";
130
+ }
131
+
132
+ export class SiteTelemetryConfigError extends Error {
133
+ override readonly name = "SiteTelemetryConfigError";
134
+ }
135
+
136
+ function analyticsProvider(keys: TelemetryKeys): AnalyticsProvider | undefined {
137
+ return match(keys.umami)
138
+ .with({ kind: "configured" }, (umami) => umamiAnalytics({ websiteId: umami.websiteId, host: umami.host }))
139
+ .with({ kind: "absent" }, () => undefined)
140
+ .with({ kind: "missing-host" }, (umami) => {
141
+ throw new SiteTelemetryConfigError(
142
+ `The Umami website id (from ${umami.source}) has no collector host: set telemetry.umamiHost in site.config, umamiHost in [telemetry] of .agentvibes/project.toml, or use a preset that names one.`
143
+ );
144
+ })
145
+ .exhaustive();
146
+ }
147
+
148
+ function errorReporter(keys: TelemetryKeys): ErrorReporter | undefined {
149
+ return match(keys.glitchtip)
150
+ .with({ kind: "configured" }, (glitchtip) => sentryErrorReporter({ dsn: glitchtip.dsn }))
151
+ .with({ kind: "absent" }, () => undefined)
152
+ .exhaustive();
153
+ }
154
+
155
+ export function createSiteRuntime(input: SiteRuntimeInput): SiteRuntime {
156
+ // process.env: AGENTVIBES_GLITCHTIP_DSN overrides the configured DSN (site doctor points it at its capture sink).
157
+ const telemetryKeys = resolveTelemetryKeys(input.config, input.generated.projectToml ?? undefined, input.preset, process.env);
158
+ return {
159
+ ...input,
160
+ seo: { name: input.config.name, description: input.config.description, siteUrl: input.config.siteUrl },
161
+ telemetryKeys,
162
+ analytics: analyticsProvider(telemetryKeys),
163
+ errors: errorReporter(telemetryKeys)
164
+ };
165
+ }
package/src/theme.ts ADDED
@@ -0,0 +1,59 @@
1
+ import type { SitePreset, SiteTheme, ThemeColors } from "@agentvibes/site-config";
2
+ import { publicTheme } from "@agentvibes/theme-public";
3
+
4
+ const VARIABLES: ReadonlyArray<readonly [keyof ThemeColors, string]> = [
5
+ ["background", "--color-fd-background"],
6
+ ["foreground", "--color-fd-foreground"],
7
+ ["muted", "--color-fd-muted"],
8
+ ["mutedForeground", "--color-fd-muted-foreground"],
9
+ ["popover", "--color-fd-popover"],
10
+ ["popoverForeground", "--color-fd-popover-foreground"],
11
+ ["card", "--color-fd-card"],
12
+ ["cardForeground", "--color-fd-card-foreground"],
13
+ ["border", "--color-fd-border"],
14
+ ["primary", "--color-fd-primary"],
15
+ ["primaryForeground", "--color-fd-primary-foreground"],
16
+ ["secondary", "--color-fd-secondary"],
17
+ ["secondaryForeground", "--color-fd-secondary-foreground"],
18
+ ["accent", "--color-fd-accent"],
19
+ ["accentForeground", "--color-fd-accent-foreground"],
20
+ ["ring", "--color-fd-ring"],
21
+ ["info", "--color-fd-info"],
22
+ ["warning", "--color-fd-warning"],
23
+ ["error", "--color-fd-error"],
24
+ ["success", "--color-fd-success"]
25
+ ];
26
+
27
+ function declarations(colors: ThemeColors): string {
28
+ return VARIABLES.map(([key, variable]) => ` ${variable}: ${colors[key]};`).join("\n");
29
+ }
30
+
31
+ /**
32
+ * CSS that maps the site theme ({@link siteTheme}) onto Fumadocs'
33
+ * `--color-fd-*` variables. The light palette applies to `:root:not(.dark)`;
34
+ * a theme without a dark palette leaves Fumadocs' neutral
35
+ * `.dark` one in place for dark mode.
36
+ */
37
+ export function themeCss(theme: SiteTheme): string {
38
+ const blocks = [`/* theme: ${theme.name} */`, `:root:not(.dark) {\n${declarations(theme.light)}\n}`];
39
+ if (theme.dark !== undefined) blocks.push(`.dark {\n${declarations(theme.dark)}\n}`);
40
+ const extras: string[] = [];
41
+ if (theme.fonts !== undefined) extras.push(` --font-sans: ${theme.fonts.sans};`, ` --font-mono: ${theme.fonts.mono};`);
42
+ if (theme.radius !== undefined) extras.push(` --radius: ${theme.radius};`);
43
+ if (extras.length > 0) blocks.push(`:root {\n${extras.join("\n")}\n}`);
44
+ return `${blocks.join("\n\n")}\n`;
45
+ }
46
+
47
+ /** Colours for generated images (OG): the light palette of the site theme. */
48
+ export function ogColors(theme: SiteTheme): { background: string; foreground: string; muted: string; accent: string } {
49
+ return { background: theme.light.background, foreground: theme.light.foreground, muted: theme.light.mutedForeground, accent: theme.light.primary };
50
+ }
51
+
52
+ /**
53
+ * The theme a site is drawn with: its preset's, else the public theme
54
+ * (`@agentvibes/theme-public`, light and dark), so a site without a preset gets
55
+ * the public defaults (spec §3) rather than Fumadocs' bare neutral.
56
+ */
57
+ export function siteTheme(preset: SitePreset | undefined): SiteTheme {
58
+ return preset?.theme ?? publicTheme;
59
+ }