@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.
- package/LICENSE +21 -0
- package/README.md +4 -2
- package/bin/agentvibes-site.mjs +7 -0
- package/package.json +87 -3
- package/src/app-mode.tsx +110 -0
- package/src/catalog.ts +80 -0
- package/src/cli.ts +51 -0
- package/src/client.ts +5 -0
- package/src/components/changelog-entry-view.tsx +11 -0
- package/src/components/doctor-throw/doctor-throw.tsx +15 -0
- package/src/components/errors-init.tsx +14 -0
- package/src/components/hero/hero-action-link.tsx +15 -0
- package/src/components/hero/hero.tsx +31 -0
- package/src/components/home-shell.tsx +19 -0
- package/src/components/jsdoc-view/group-view.tsx +14 -0
- package/src/components/jsdoc-view/item-view.tsx +24 -0
- package/src/components/jsdoc-view/jsdoc-view.tsx +16 -0
- package/src/components/jsdoc-view/member-view.tsx +15 -0
- package/src/components/module-cards/module-card.tsx +13 -0
- package/src/components/module-cards/module-cards.tsx +15 -0
- package/src/components/page-actions/copy-button.tsx +40 -0
- package/src/components/page-actions/page-actions.tsx +53 -0
- package/src/components/page-footer.tsx +20 -0
- package/src/components/page-link/page-link.tsx +19 -0
- package/src/components/site-error-boundary/error-panel.tsx +16 -0
- package/src/components/site-error-boundary/site-error-boundary.tsx +12 -0
- package/src/components/site-error-boundary/site-global-error-boundary.tsx +19 -0
- package/src/components/site-footer/footer-column-view.tsx +15 -0
- package/src/components/site-footer/footer-link.tsx +20 -0
- package/src/components/site-footer/site-footer.tsx +32 -0
- package/src/components/site-video/site-video.tsx +25 -0
- package/src/components/telemetry-scripts.tsx +18 -0
- package/src/config.ts +9 -0
- package/src/context.ts +68 -0
- package/src/data.ts +64 -0
- package/src/docs-title.ts +8 -0
- package/src/doctor-routes.tsx +44 -0
- package/src/index.ts +39 -0
- package/src/instrumentation.ts +53 -0
- package/src/jsdoc.ts +38 -0
- package/src/layout-options.ts +18 -0
- package/src/layouts.tsx +63 -0
- package/src/mdx-config.d.ts +2 -0
- package/src/mdx-config.js +7 -0
- package/src/mdx.tsx +18 -0
- package/src/nav.ts +97 -0
- package/src/og-route.tsx +29 -0
- package/src/output.ts +90 -0
- package/src/pages.tsx +186 -0
- package/src/prepare/assets.ts +98 -0
- package/src/prepare/author-next-config.ts +46 -0
- package/src/prepare/changelog.ts +17 -0
- package/src/prepare/files.ts +95 -0
- package/src/prepare/finish-static.ts +122 -0
- package/src/prepare/index.ts +291 -0
- package/src/prepare/jsdoc-extract.ts +182 -0
- package/src/prepare/release.ts +12 -0
- package/src/prepare/templates.ts +415 -0
- package/src/prepare/unimplemented.ts +32 -0
- package/src/proxy.ts +57 -0
- package/src/routes.ts +158 -0
- package/src/runtime.tsx +165 -0
- package/src/theme.ts +59 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The page action menu: copy the page as Markdown, view the Markdown, open
|
|
5
|
+
* it in Claude. Copying is async (fetch + clipboard), so it renders all four
|
|
6
|
+
* states of a Resource.
|
|
7
|
+
*/
|
|
8
|
+
import { Resource } from "@agentvibes/kit/resource";
|
|
9
|
+
import { useCallback, useState } from "react";
|
|
10
|
+
|
|
11
|
+
import { CopyButton } from "./copy-button";
|
|
12
|
+
|
|
13
|
+
export type PageActionsProps = {
|
|
14
|
+
/** Same-origin path of the page's Markdown, e.g. `/docs/init.md`. */
|
|
15
|
+
readonly markdownPath: string;
|
|
16
|
+
/** Absolute URL of the Markdown on the host that served this page. */
|
|
17
|
+
readonly markdownUrl: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
async function copyMarkdown(path: string): Promise<void> {
|
|
21
|
+
const response = await fetch(path, { headers: { Accept: "text/markdown" } });
|
|
22
|
+
if (!response.ok) throw new Error(`GET ${path} answered ${response.status}`);
|
|
23
|
+
await navigator.clipboard.writeText(await response.text());
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function claudeUrl(markdownUrl: string): string {
|
|
27
|
+
return `https://claude.ai/new?q=${encodeURIComponent(`Read ${markdownUrl} and answer my questions about it.`)}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const LINK = "inline-flex items-center rounded-md px-2.5 py-1 text-xs font-medium text-fd-muted-foreground hover:text-fd-foreground";
|
|
31
|
+
|
|
32
|
+
export function PageActions(props: PageActionsProps) {
|
|
33
|
+
const [state, setState] = useState<Resource<true>>(Resource.idle());
|
|
34
|
+
const onCopy = useCallback(() => {
|
|
35
|
+
setState(Resource.loading());
|
|
36
|
+
copyMarkdown(props.markdownPath).then(
|
|
37
|
+
() => setState(Resource.ready(true)),
|
|
38
|
+
(error: unknown) => setState(Resource.error(error instanceof Error ? error : new Error(String(error))))
|
|
39
|
+
);
|
|
40
|
+
}, [props.markdownPath]);
|
|
41
|
+
const onDismiss = useCallback(() => setState(Resource.idle()), []);
|
|
42
|
+
return (
|
|
43
|
+
<div className="not-prose mb-6 flex flex-wrap items-center gap-2" data-page-actions="">
|
|
44
|
+
<CopyButton state={state} onCopy={onCopy} onDismiss={onDismiss} />
|
|
45
|
+
<a className={LINK} href={props.markdownPath}>
|
|
46
|
+
View as Markdown
|
|
47
|
+
</a>
|
|
48
|
+
<a className={LINK} href={claudeUrl(props.markdownUrl)} target="_blank" rel="noreferrer">
|
|
49
|
+
Open in Claude
|
|
50
|
+
</a>
|
|
51
|
+
</div>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { requestContext } from "../context";
|
|
2
|
+
import { footerColumns, telemetryDashboards } from "../nav";
|
|
3
|
+
import type { SiteRuntime } from "../runtime";
|
|
4
|
+
import { SiteFooter } from "./site-footer/site-footer";
|
|
5
|
+
|
|
6
|
+
/** The footer for this request: the Internal column (preset links, dashboards) only off prod, llms.txt only when indexable. */
|
|
7
|
+
export async function PageFooter(props: { site: SiteRuntime }) {
|
|
8
|
+
const { site } = props;
|
|
9
|
+
if (site.chrome?.footer !== undefined) return <>{site.chrome.footer}</>;
|
|
10
|
+
const ctx = await requestContext(site);
|
|
11
|
+
const internal = ctx.environment === "prod" ? [] : [...(site.preset?.links?.internal ?? []), ...telemetryDashboards(site.telemetryKeys)];
|
|
12
|
+
return (
|
|
13
|
+
<SiteFooter
|
|
14
|
+
name={site.config.name}
|
|
15
|
+
description={site.config.description}
|
|
16
|
+
release={site.generated.release}
|
|
17
|
+
columns={footerColumns({ config: site.config, llms: ctx.decision.kind === "index", internal })}
|
|
18
|
+
/>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import NextLink from "next/link";
|
|
4
|
+
import type { ComponentProps } from "react";
|
|
5
|
+
|
|
6
|
+
/** The props Fumadocs renders its framework link with. */
|
|
7
|
+
type PageLinkProps = ComponentProps<"a"> & { prefetch?: boolean };
|
|
8
|
+
|
|
9
|
+
const STATIC_BUILD = process.env.NEXT_PUBLIC_SITE_STATIC === "1";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `next/link`, without prefetch in a static build. A static host answers each prefetch as one more
|
|
13
|
+
* request, and on a host that counts requests a page view should not fetch pages nobody opened.
|
|
14
|
+
*/
|
|
15
|
+
export function PageLink({ href = "#", ...rest }: PageLinkProps) {
|
|
16
|
+
// Same anchor props; next/link only spells its optional handlers without `undefined`, which it accepts at run time.
|
|
17
|
+
const props = { ...rest, href } as ComponentProps<typeof NextLink>;
|
|
18
|
+
return STATIC_BUILD ? <NextLink {...props} prefetch={false} /> : <NextLink {...props} />;
|
|
19
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export type BoundaryProps = { error: Error & { digest?: string }; reset: () => void };
|
|
2
|
+
|
|
3
|
+
/** The fallback both error boundaries render: what happened, and a retry. */
|
|
4
|
+
export function ErrorPanel(props: BoundaryProps) {
|
|
5
|
+
return (
|
|
6
|
+
<div role="alert" className="mx-auto my-16 max-w-xl rounded-lg border border-fd-error/40 bg-fd-error/10 p-6 text-fd-foreground">
|
|
7
|
+
<p className="mb-2 font-semibold">This page failed to render.</p>
|
|
8
|
+
<p className="mb-4 text-sm text-fd-muted-foreground">
|
|
9
|
+
The error was reported{props.error.digest === undefined ? "" : ` (digest ${props.error.digest})`}.
|
|
10
|
+
</p>
|
|
11
|
+
<button type="button" className="rounded-md border border-fd-border bg-fd-secondary px-3 py-1.5 text-sm" onClick={props.reset}>
|
|
12
|
+
Try again
|
|
13
|
+
</button>
|
|
14
|
+
</div>
|
|
15
|
+
);
|
|
16
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { reportError } from "@agentvibes/site-telemetry/client";
|
|
4
|
+
import { useEffect } from "react";
|
|
5
|
+
|
|
6
|
+
import { ErrorPanel, type BoundaryProps } from "./error-panel";
|
|
7
|
+
|
|
8
|
+
/** `app/error.tsx`: reports the error to the error reporter, offers a retry. */
|
|
9
|
+
export function SiteErrorBoundary(props: BoundaryProps) {
|
|
10
|
+
useEffect(() => reportError(props.error), [props.error]);
|
|
11
|
+
return <ErrorPanel {...props} />;
|
|
12
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { reportError } from "@agentvibes/site-telemetry/client";
|
|
4
|
+
import { useEffect } from "react";
|
|
5
|
+
|
|
6
|
+
import { ErrorPanel, type BoundaryProps } from "./error-panel";
|
|
7
|
+
|
|
8
|
+
/** `app/global-error.tsx`: same as SiteErrorBoundary, for errors in the root layout. It renders its own `<html>`, so it takes the site's `lang`. */
|
|
9
|
+
export function SiteGlobalErrorBoundary(props: BoundaryProps & { readonly lang?: string }) {
|
|
10
|
+
const { lang = "en", ...boundary } = props;
|
|
11
|
+
useEffect(() => reportError(boundary.error), [boundary.error]);
|
|
12
|
+
return (
|
|
13
|
+
<html lang={lang}>
|
|
14
|
+
<body>
|
|
15
|
+
<ErrorPanel {...boundary} />
|
|
16
|
+
</body>
|
|
17
|
+
</html>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { FooterColumn } from "../../nav";
|
|
2
|
+
import { FooterLink } from "./footer-link";
|
|
3
|
+
|
|
4
|
+
export function FooterColumnView(props: { column: FooterColumn }) {
|
|
5
|
+
return (
|
|
6
|
+
<div>
|
|
7
|
+
<p className="mb-3 text-xs font-semibold uppercase tracking-wide text-fd-muted-foreground">{props.column.title}</p>
|
|
8
|
+
<ul className="flex flex-col gap-2">
|
|
9
|
+
{props.column.links.map((link) => (
|
|
10
|
+
<FooterLink key={link.href} link={link} />
|
|
11
|
+
))}
|
|
12
|
+
</ul>
|
|
13
|
+
</div>
|
|
14
|
+
);
|
|
15
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { PageLink as Link } from "../page-link/page-link";
|
|
2
|
+
|
|
3
|
+
import type { SiteLink } from "../../nav";
|
|
4
|
+
|
|
5
|
+
export function FooterLink(props: { link: SiteLink }) {
|
|
6
|
+
const { link } = props;
|
|
7
|
+
return (
|
|
8
|
+
<li>
|
|
9
|
+
{link.external ? (
|
|
10
|
+
<a href={link.href} className="text-sm text-fd-muted-foreground hover:text-fd-foreground" rel="noreferrer">
|
|
11
|
+
{link.label}
|
|
12
|
+
</a>
|
|
13
|
+
) : (
|
|
14
|
+
<Link href={link.href} className="text-sm text-fd-muted-foreground hover:text-fd-foreground">
|
|
15
|
+
{link.label}
|
|
16
|
+
</Link>
|
|
17
|
+
)}
|
|
18
|
+
</li>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { FooterColumn } from "../../nav";
|
|
2
|
+
import { FooterColumnView } from "./footer-column-view";
|
|
3
|
+
|
|
4
|
+
export type SiteFooterProps = {
|
|
5
|
+
readonly name: string;
|
|
6
|
+
readonly description: string | undefined;
|
|
7
|
+
readonly columns: readonly FooterColumn[];
|
|
8
|
+
readonly release: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/** The site footer: name, description, link columns from site.config, build release. */
|
|
12
|
+
export function SiteFooter(props: SiteFooterProps) {
|
|
13
|
+
return (
|
|
14
|
+
<footer className="border-t border-fd-border bg-fd-card text-fd-card-foreground" data-site-footer="">
|
|
15
|
+
<div className="mx-auto grid max-w-fd-container gap-8 px-6 py-10 md:grid-cols-[1.4fr_repeat(4,1fr)]">
|
|
16
|
+
<div>
|
|
17
|
+
<p className="mb-2 font-semibold">{props.name}</p>
|
|
18
|
+
{props.description === undefined ? null : <p className="max-w-[32ch] text-sm text-fd-muted-foreground">{props.description}</p>}
|
|
19
|
+
</div>
|
|
20
|
+
{props.columns.map((column) => (
|
|
21
|
+
<FooterColumnView key={column.title} column={column} />
|
|
22
|
+
))}
|
|
23
|
+
</div>
|
|
24
|
+
<div className="mx-auto flex max-w-fd-container justify-between px-6 pb-6 text-xs text-fd-muted-foreground">
|
|
25
|
+
<span>
|
|
26
|
+
© {new Date().getFullYear()} {props.name}
|
|
27
|
+
</span>
|
|
28
|
+
<span className="font-mono">release {props.release.slice(0, 12)}</span>
|
|
29
|
+
</div>
|
|
30
|
+
</footer>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Video, type VideoSources } from "@agentvibes/site-mdx";
|
|
2
|
+
|
|
3
|
+
import type { MediaData } from "../../data";
|
|
4
|
+
|
|
5
|
+
export class SiteMediaError extends Error {
|
|
6
|
+
override readonly name = "SiteMediaError";
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type SiteVideoProps = Partial<VideoSources> & {
|
|
10
|
+
/** A video of `sections.media.videos`: `deploy` for `deploy.mp4`, `deploy.webm`, `deploy.png`, `deploy.vtt`. */
|
|
11
|
+
readonly name?: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/** `<Video name="deploy" label="…" />` in MDX: the files come from the media prepare copied; props override them. */
|
|
15
|
+
export function SiteVideo(props: SiteVideoProps & { videos: MediaData["videos"] }) {
|
|
16
|
+
const { videos, name, ...rest } = props;
|
|
17
|
+
const entry = name === undefined ? undefined : videos[name];
|
|
18
|
+
if (name !== undefined && entry === undefined) {
|
|
19
|
+
throw new SiteMediaError(`<Video name="${name}">: no such video in sections.media.videos (found: ${Object.keys(videos).join(", ") || "none"})`);
|
|
20
|
+
}
|
|
21
|
+
const label = rest.label ?? name;
|
|
22
|
+
const mp4 = rest.mp4 ?? entry?.mp4;
|
|
23
|
+
if (label === undefined || mp4 === undefined) throw new SiteMediaError("<Video>: pass name (a video of sections.media.videos), or label and mp4");
|
|
24
|
+
return <Video {...entry} {...rest} label={label} mp4={mp4} />;
|
|
25
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { match } from "ts-pattern";
|
|
2
|
+
|
|
3
|
+
import type { RequestContext } from "../context";
|
|
4
|
+
import { telemetryTransport, type SiteRuntime } from "../runtime";
|
|
5
|
+
|
|
6
|
+
/** The analytics script in `<head>`, when the request's telemetry plan turns analytics on. */
|
|
7
|
+
export function TelemetryScripts(props: { site: SiteRuntime; ctx: RequestContext }) {
|
|
8
|
+
const { site, ctx } = props;
|
|
9
|
+
const analytics = site.analytics;
|
|
10
|
+
return (
|
|
11
|
+
<>
|
|
12
|
+
{match(ctx.telemetry.analytics)
|
|
13
|
+
.with({ kind: "on" }, (plan) => (analytics === undefined ? null : <analytics.Script environment={plan.environment} release={plan.release} transport={telemetryTransport(site)} />))
|
|
14
|
+
.with({ kind: "off" }, () => null)
|
|
15
|
+
.exhaustive()}
|
|
16
|
+
</>
|
|
17
|
+
);
|
|
18
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { loadSiteConfigDocument, type SiteConfig } from "@agentvibes/site-config";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The default export of `site.config.ts`, migrated and validated (a
|
|
5
|
+
* `defineSite()` result passes through unchanged).
|
|
6
|
+
*/
|
|
7
|
+
export function loadConfig(raw: unknown): SiteConfig {
|
|
8
|
+
return loadSiteConfigDocument(raw).config;
|
|
9
|
+
}
|
package/src/context.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The per-request view of the site: which host asked, whether it may be
|
|
3
|
+
* indexed (spec §4), which environment errors are tagged with, and whether
|
|
4
|
+
* telemetry runs. Pages read it through `next/headers`; route handlers from
|
|
5
|
+
* their `Request`.
|
|
6
|
+
*/
|
|
7
|
+
import { decideIndexing, readDeploymentEnv, siteEnvironment, type IndexingDecision, type SiteEnvironment } from "@agentvibes/site-seo";
|
|
8
|
+
import { planTelemetry, readAnalyticsOnPreview, type TelemetryPlan } from "@agentvibes/site-telemetry";
|
|
9
|
+
import { headers } from "next/headers";
|
|
10
|
+
|
|
11
|
+
import type { SiteRuntime } from "./runtime";
|
|
12
|
+
|
|
13
|
+
export type RequestContext = {
|
|
14
|
+
readonly host: string | undefined;
|
|
15
|
+
/** `https://<host>` of this request (links that must work on a preview). */
|
|
16
|
+
readonly origin: string;
|
|
17
|
+
readonly decision: IndexingDecision;
|
|
18
|
+
readonly environment: SiteEnvironment;
|
|
19
|
+
readonly telemetry: TelemetryPlan;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function hostOf(get: (name: string) => string | null): string | undefined {
|
|
23
|
+
const value = get("x-forwarded-host") ?? get("host");
|
|
24
|
+
return value === null || value === "" ? undefined : (value.split(",")[0]?.trim() ?? undefined);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function originOf(get: (name: string) => string | null, host: string | undefined, siteUrl: string): string {
|
|
28
|
+
if (host === undefined) return new URL(siteUrl).origin;
|
|
29
|
+
const proto = get("x-forwarded-proto")?.split(",")[0]?.trim() ?? "https";
|
|
30
|
+
return `${proto}://${host}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function contextFor(site: SiteRuntime, get: (name: string) => string | null): RequestContext {
|
|
34
|
+
const host = hostOf(get);
|
|
35
|
+
const env = readDeploymentEnv(process.env);
|
|
36
|
+
const decision = decideIndexing({ index: site.config.index, siteUrl: site.config.siteUrl, env, host });
|
|
37
|
+
const environment = siteEnvironment({ siteUrl: site.config.siteUrl, env, host });
|
|
38
|
+
const telemetry = planTelemetry({
|
|
39
|
+
decision,
|
|
40
|
+
environment,
|
|
41
|
+
release: site.generated.release,
|
|
42
|
+
analyticsConfigured: site.analytics !== undefined,
|
|
43
|
+
errorsConfigured: site.errors !== undefined,
|
|
44
|
+
analyticsOnPreview: readAnalyticsOnPreview(process.env)
|
|
45
|
+
});
|
|
46
|
+
return { host, origin: originOf(get, host, site.config.siteUrl), decision, environment, telemetry };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A static site has no request: its context is that of a request to the origin the files are served from. */
|
|
50
|
+
function staticHeaders(origin: string): (name: string) => string | null {
|
|
51
|
+
const url = new URL(origin);
|
|
52
|
+
return (name) => (name === "host" ? url.host : name === "x-forwarded-proto" ? url.protocol.replace(":", "") : null);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** For server components and `generateMetadata`. */
|
|
56
|
+
export async function requestContext(site: SiteRuntime): Promise<RequestContext> {
|
|
57
|
+
const output = site.generated.output;
|
|
58
|
+
if (output.kind === "static") return contextFor(site, staticHeaders(output.origin));
|
|
59
|
+
const all = await headers();
|
|
60
|
+
return contextFor(site, (name) => all.get(name));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** For route handlers. */
|
|
64
|
+
export function requestContextOf(site: SiteRuntime, request: Request): RequestContext {
|
|
65
|
+
const output = site.generated.output;
|
|
66
|
+
if (output.kind === "static") return contextFor(site, staticHeaders(output.origin));
|
|
67
|
+
return contextFor(site, (name) => request.headers.get(name));
|
|
68
|
+
}
|
package/src/data.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Data the `prepare` step bakes at build time into `app/_site/generated.json`
|
|
3
|
+
* (no file-system reads at request time).
|
|
4
|
+
*/
|
|
5
|
+
import type { ProjectToml } from "@agentvibes/site-config";
|
|
6
|
+
|
|
7
|
+
import type { SiteOutput } from "./output";
|
|
8
|
+
|
|
9
|
+
export type ApiMember = {
|
|
10
|
+
readonly kind: "constructor" | "method" | "property";
|
|
11
|
+
readonly name: string;
|
|
12
|
+
readonly signature: string;
|
|
13
|
+
readonly description?: string | undefined;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type ApiItemKind = "class" | "function" | "type" | "interface" | "const" | "enum";
|
|
17
|
+
|
|
18
|
+
export type ApiItem = {
|
|
19
|
+
readonly kind: ApiItemKind;
|
|
20
|
+
readonly name: string;
|
|
21
|
+
readonly signature?: string | undefined;
|
|
22
|
+
readonly description?: string | undefined;
|
|
23
|
+
readonly members?: readonly ApiMember[] | undefined;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type JsdocData = {
|
|
27
|
+
readonly modules: Readonly<Record<string, { readonly entry: string | null; readonly items: readonly ApiItem[] }>>;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type ChangelogEntry = {
|
|
31
|
+
/** npm package name, from the package's package.json. */
|
|
32
|
+
readonly packageName: string;
|
|
33
|
+
/** Markdown of the package's CHANGELOG.md without its top-level `#` title. */
|
|
34
|
+
readonly markdown: string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** One video of `sections.media.videos`, as URLs under `/_av/media/`. */
|
|
38
|
+
export type VideoEntry = {
|
|
39
|
+
readonly mp4: string;
|
|
40
|
+
readonly webm?: string;
|
|
41
|
+
readonly compactMp4?: string;
|
|
42
|
+
readonly compactWebm?: string;
|
|
43
|
+
readonly poster?: string;
|
|
44
|
+
readonly captions?: string;
|
|
45
|
+
readonly width?: number;
|
|
46
|
+
readonly height?: number;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export type MediaData = {
|
|
50
|
+
/** By name: `<Video name="deploy" label="…" />`. */
|
|
51
|
+
readonly videos: Readonly<Record<string, VideoEntry>>;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export type GeneratedSiteData = {
|
|
55
|
+
/** Server or static files (`prepare --static`). */
|
|
56
|
+
readonly output: SiteOutput;
|
|
57
|
+
/** Git SHA of the build (`release` tag), or "unknown" outside a git checkout. */
|
|
58
|
+
readonly release: string;
|
|
59
|
+
/** `.agentvibes/project.toml`, when the repository has one. */
|
|
60
|
+
readonly projectToml: ProjectToml | null;
|
|
61
|
+
readonly jsdoc: JsdocData;
|
|
62
|
+
readonly changelog: readonly ChangelogEntry[];
|
|
63
|
+
readonly media: MediaData;
|
|
64
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* True when the MDX body has its own `# ` heading. Pages without one get the
|
|
3
|
+
* frontmatter `title` and `description` as the page heading, as a hand-written
|
|
4
|
+
* Fumadocs page does; pages with one keep theirs and get no second title.
|
|
5
|
+
*/
|
|
6
|
+
export function hasOwnTitle(toc: ReadonlyArray<{ readonly depth: number }>): boolean {
|
|
7
|
+
return toc.some((item) => item.depth === 1);
|
|
8
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test routes for site doctor's `errors-captured` rule: `/_doctor/throw`
|
|
3
|
+
* (a server error) and `/_doctor/throw-client` (a browser error). Every build
|
|
4
|
+
* has them; they answer 404 unless the server runs with
|
|
5
|
+
* `AGENTVIBES_DOCTOR_ROUTES=1`, so the artefact the doctor checks is the one
|
|
6
|
+
* that is deployed.
|
|
7
|
+
*/
|
|
8
|
+
import { notFound } from "next/navigation";
|
|
9
|
+
|
|
10
|
+
import { DoctorThrow } from "./components/doctor-throw/doctor-throw";
|
|
11
|
+
|
|
12
|
+
export const DOCTOR_ROUTES_ENV = "AGENTVIBES_DOCTOR_ROUTES";
|
|
13
|
+
|
|
14
|
+
function doctorRoutesOn(): boolean {
|
|
15
|
+
return process.env[DOCTOR_ROUTES_ENV] === "1";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function idOf(search: URLSearchParams): string {
|
|
19
|
+
return (search.get("id") ?? "none").replace(/[^\w-]/g, "").slice(0, 64);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class DoctorSyntheticError extends Error {
|
|
23
|
+
override readonly name = "DoctorSyntheticError";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** `/_doctor/throw?id=…`: throws, so Next's `onRequestError` reports it. */
|
|
27
|
+
export function createDoctorThrowRoute() {
|
|
28
|
+
return function GET(request: Request): Response {
|
|
29
|
+
if (!doctorRoutesOn()) return new Response("Not found", { status: 404 });
|
|
30
|
+
throw new DoctorSyntheticError(`site-doctor synthetic server error ${idOf(new URL(request.url).searchParams)}`);
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type SearchProps = { searchParams: Promise<Record<string, string | string[] | undefined>> };
|
|
35
|
+
|
|
36
|
+
/** `/_doctor/throw-client?id=…`: a page whose client component throws after mount. */
|
|
37
|
+
export function createDoctorThrowPage() {
|
|
38
|
+
return async function Page(props: SearchProps) {
|
|
39
|
+
if (!doctorRoutesOn()) notFound();
|
|
40
|
+
const raw = (await props.searchParams)["id"];
|
|
41
|
+
const search = new URLSearchParams(typeof raw === "string" ? { id: raw } : {});
|
|
42
|
+
return <DoctorThrow id={idOf(search)} />;
|
|
43
|
+
};
|
|
44
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export { loadConfig } from "./config";
|
|
2
|
+
export {
|
|
3
|
+
createSiteRuntime,
|
|
4
|
+
defineSiteChrome,
|
|
5
|
+
defineSiteEntries,
|
|
6
|
+
siteSource,
|
|
7
|
+
SiteTelemetryConfigError,
|
|
8
|
+
type SiteChrome,
|
|
9
|
+
type SiteEntries,
|
|
10
|
+
type SitePage,
|
|
11
|
+
type SitePageData,
|
|
12
|
+
type SiteRuntime,
|
|
13
|
+
type SiteRuntimeInput,
|
|
14
|
+
type SiteSearchEntry,
|
|
15
|
+
type SiteSource
|
|
16
|
+
} from "./runtime";
|
|
17
|
+
export type { ApiItem, ApiMember, ChangelogEntry, GeneratedSiteData, JsdocData, MediaData, VideoEntry } from "./data";
|
|
18
|
+
export { HomeShell } from "./components/home-shell";
|
|
19
|
+
export { createDocsLayout, createRootLayout } from "./layouts";
|
|
20
|
+
export { createChangelogPage, createDocsPage, createJsdocPage, createLandingPage, createNotFoundPage } from "./pages";
|
|
21
|
+
export {
|
|
22
|
+
createAnalyticsTunnel,
|
|
23
|
+
createErrorsTunnel,
|
|
24
|
+
createFaviconRoute,
|
|
25
|
+
createLlmsRoute,
|
|
26
|
+
createMarkdownParams,
|
|
27
|
+
createMarkdownRoute,
|
|
28
|
+
createRobotsRoute,
|
|
29
|
+
createSearchRoute,
|
|
30
|
+
createSitemapRoute
|
|
31
|
+
} from "./routes";
|
|
32
|
+
export { createOgParams, createOgRoute } from "./og-route";
|
|
33
|
+
export { type SiteOutput } from "./output";
|
|
34
|
+
export { catalog, catalogSections, findPage, type CatalogPage } from "./catalog";
|
|
35
|
+
export { footerColumns, headerLinks, repositoryLinks, type FooterColumn, type SiteLink } from "./nav";
|
|
36
|
+
export { themeCss } from "./theme";
|
|
37
|
+
export { mdxComponentsFor } from "./mdx";
|
|
38
|
+
export { createDoctorThrowPage, createDoctorThrowRoute, DOCTOR_ROUTES_ENV, DoctorSyntheticError } from "./doctor-routes";
|
|
39
|
+
export { AppChrome, appMetadata, AppModeConfigError, createManifest, createOfflinePage, createServiceWorkerRoute, documentViewport, siteViewport } from "./app-mode";
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side error capture: Next calls `onRequestError` for every error
|
|
3
|
+
* thrown while rendering a page, in a route handler, a server action or the
|
|
4
|
+
* proxy. The generated `instrumentation.ts` binds this to the site runtime;
|
|
5
|
+
* the error goes to the configured reporter with the same environment and
|
|
6
|
+
* release gating as the browser (`planTelemetry`).
|
|
7
|
+
*/
|
|
8
|
+
import { warnDegraded } from "@agentvibes/kit/diagnostics";
|
|
9
|
+
import type { Instrumentation } from "next";
|
|
10
|
+
import { match } from "ts-pattern";
|
|
11
|
+
|
|
12
|
+
import { contextFor } from "./context";
|
|
13
|
+
import type { SiteRuntime } from "./runtime";
|
|
14
|
+
|
|
15
|
+
type RequestHeaders = Parameters<Instrumentation.onRequestError>[1]["headers"];
|
|
16
|
+
|
|
17
|
+
function headerReader(headers: RequestHeaders): (name: string) => string | null {
|
|
18
|
+
return (name) => {
|
|
19
|
+
const value = headers[name];
|
|
20
|
+
return Array.isArray(value) ? (value[0] ?? null) : (value ?? null);
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* `load` imports the runtime lazily, so instrumentation does not pull the
|
|
26
|
+
* site into every server entry; it returns undefined where the runtime is not
|
|
27
|
+
* available (the edge runtime), and the error is then left to Next's log.
|
|
28
|
+
*/
|
|
29
|
+
export function createRequestErrorHandler(load: () => Promise<SiteRuntime | undefined>): Instrumentation.onRequestError {
|
|
30
|
+
return async function onRequestError(error, request, context) {
|
|
31
|
+
const site = await load();
|
|
32
|
+
const reporter = site?.errors;
|
|
33
|
+
if (site === undefined || reporter === undefined) return;
|
|
34
|
+
const ctx = contextFor(site, headerReader(request.headers));
|
|
35
|
+
await match(ctx.telemetry.errors)
|
|
36
|
+
.with({ kind: "on" }, (plan) =>
|
|
37
|
+
reporter
|
|
38
|
+
.captureServer(error, {
|
|
39
|
+
environment: plan.environment,
|
|
40
|
+
release: plan.release,
|
|
41
|
+
path: request.path,
|
|
42
|
+
method: request.method,
|
|
43
|
+
routePath: context.routePath,
|
|
44
|
+
routeType: context.routeType
|
|
45
|
+
})
|
|
46
|
+
.catch((failure: unknown) => {
|
|
47
|
+
warnDegraded("site.errors.server", `a server error was not reported: ${failure instanceof Error ? failure.message : String(failure)}`);
|
|
48
|
+
})
|
|
49
|
+
)
|
|
50
|
+
.with({ kind: "off" }, async () => undefined)
|
|
51
|
+
.exhaustive();
|
|
52
|
+
};
|
|
53
|
+
}
|
package/src/jsdoc.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { match } from "ts-pattern";
|
|
2
|
+
|
|
3
|
+
import type { ApiItem, ApiItemKind } from "./data";
|
|
4
|
+
|
|
5
|
+
const KIND_ORDER: readonly ApiItemKind[] = ["class", "function", "type", "interface", "const", "enum"];
|
|
6
|
+
|
|
7
|
+
export function sectionTitle(kind: ApiItemKind): string {
|
|
8
|
+
return match(kind)
|
|
9
|
+
.with("class", () => "Classes")
|
|
10
|
+
.with("function", () => "Functions")
|
|
11
|
+
.with("type", () => "Types")
|
|
12
|
+
.with("interface", () => "Interfaces")
|
|
13
|
+
.with("const", () => "Constants")
|
|
14
|
+
.with("enum", () => "Enums")
|
|
15
|
+
.exhaustive();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function groupByKind(items: readonly ApiItem[]): Array<{ kind: ApiItemKind; items: ApiItem[] }> {
|
|
19
|
+
return KIND_ORDER.map((kind) => ({ kind, items: items.filter((item) => item.kind === kind) })).filter((group) => group.items.length > 0);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The API reference as Markdown, for `.md` and llms. */
|
|
23
|
+
export function jsdocMarkdown(items: readonly ApiItem[]): string {
|
|
24
|
+
return groupByKind(items)
|
|
25
|
+
.map((group) => {
|
|
26
|
+
const entries = group.items.map((item) => {
|
|
27
|
+
const lines = [`### ${item.name}`, ""];
|
|
28
|
+
if (item.description !== undefined) lines.push(item.description, "");
|
|
29
|
+
if (item.signature !== undefined) lines.push("```ts", item.signature, "```", "");
|
|
30
|
+
for (const member of item.members ?? []) {
|
|
31
|
+
lines.push(`- \`${member.signature}\`${member.description === undefined ? "" : ` — ${member.description}`}`);
|
|
32
|
+
}
|
|
33
|
+
return lines.join("\n").trimEnd();
|
|
34
|
+
});
|
|
35
|
+
return [`## ${sectionTitle(group.kind)}`, "", entries.join("\n\n")].join("\n");
|
|
36
|
+
})
|
|
37
|
+
.join("\n\n");
|
|
38
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { LinkItemType } from "fumadocs-ui/layouts/shared";
|
|
2
|
+
|
|
3
|
+
import { githubUrl, headerLinks } from "./nav";
|
|
4
|
+
import type { SiteRuntime } from "./runtime";
|
|
5
|
+
|
|
6
|
+
function navLinks(site: SiteRuntime): LinkItemType[] {
|
|
7
|
+
return headerLinks(site.config).map((link) => ({ text: link.label, url: link.href, external: link.external, active: "nested-url" as const }));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Header options shared by the docs and home layouts: title, links from config, GitHub. */
|
|
11
|
+
export function layoutOptions(site: SiteRuntime) {
|
|
12
|
+
const github = githubUrl(site.config);
|
|
13
|
+
return {
|
|
14
|
+
nav: { title: site.chrome?.navTitle ?? site.config.name, url: "/" },
|
|
15
|
+
links: navLinks(site),
|
|
16
|
+
...(github === undefined ? {} : { githubUrl: github })
|
|
17
|
+
};
|
|
18
|
+
}
|