@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
package/src/layouts.tsx
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { JsonLdScript } from "@agentvibes/site-seo/json-ld";
|
|
2
|
+
import { siteMetadata } from "@agentvibes/site-seo";
|
|
3
|
+
import { ScrollPage } from "@agentvibes/site-app";
|
|
4
|
+
import { DocsLayout } from "fumadocs-ui/layouts/docs";
|
|
5
|
+
import { RootProvider } from "fumadocs-ui/provider/next";
|
|
6
|
+
import type { Metadata } from "next";
|
|
7
|
+
import type { ReactNode } from "react";
|
|
8
|
+
|
|
9
|
+
import { AppChrome, appMetadata, documentViewport, siteViewport } from "./app-mode";
|
|
10
|
+
import { ErrorsInit } from "./components/errors-init";
|
|
11
|
+
import { PageFooter } from "./components/page-footer";
|
|
12
|
+
import { PageLink } from "./components/page-link/page-link";
|
|
13
|
+
import { TelemetryScripts } from "./components/telemetry-scripts";
|
|
14
|
+
import { requestContext } from "./context";
|
|
15
|
+
import { layoutOptions } from "./layout-options";
|
|
16
|
+
import { ogImageUrl, searchProviderProps } from "./output";
|
|
17
|
+
import type { SiteRuntime } from "./runtime";
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
export function createRootLayout(site: SiteRuntime) {
|
|
21
|
+
async function generateMetadata(): Promise<Metadata> {
|
|
22
|
+
const ctx = await requestContext(site);
|
|
23
|
+
return { ...siteMetadata(site.seo, ctx.decision, ogImageUrl(site.generated.output, "/")), ...appMetadata(site) };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function RootLayout(props: { children: ReactNode }) {
|
|
27
|
+
const ctx = await requestContext(site);
|
|
28
|
+
return (
|
|
29
|
+
<html lang={site.config.lang} suppressHydrationWarning>
|
|
30
|
+
<head>
|
|
31
|
+
<JsonLdScript site={site.seo} />
|
|
32
|
+
<TelemetryScripts site={site} ctx={ctx} />
|
|
33
|
+
</head>
|
|
34
|
+
<body className="flex min-h-screen flex-col">
|
|
35
|
+
<RootProvider components={{ Link: PageLink }} {...(site.chrome?.themeStorageKey === undefined ? {} : { theme: { storageKey: site.chrome.themeStorageKey } })} {...searchProviderProps(site.generated.output)}>
|
|
36
|
+
<AppChrome site={site}>{props.children}</AppChrome>
|
|
37
|
+
</RootProvider>
|
|
38
|
+
<ErrorsInit site={site} ctx={ctx} />
|
|
39
|
+
</body>
|
|
40
|
+
</html>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return { generateMetadata, RootLayout, viewport: siteViewport(site) };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* `/docs/**`: Fumadocs' docs layout (sidebar from the page tree, header links from config), then the footer.
|
|
49
|
+
* `data-av-scroll-page`: inside an app-mode shell, docs still scroll as a document.
|
|
50
|
+
*/
|
|
51
|
+
export function createDocsLayout(site: SiteRuntime) {
|
|
52
|
+
function SiteDocsLayout(props: { children: ReactNode }) {
|
|
53
|
+
return (
|
|
54
|
+
<ScrollPage>
|
|
55
|
+
<DocsLayout tree={site.docs.pageTree} {...layoutOptions(site)}>
|
|
56
|
+
{props.children}
|
|
57
|
+
</DocsLayout>
|
|
58
|
+
<PageFooter site={site} />
|
|
59
|
+
</ScrollPage>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
return { Layout: SiteDocsLayout, viewport: documentViewport(site) };
|
|
63
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the generated `source.config.ts` imports. Node loads that file with
|
|
3
|
+
* packages external (and Node 20 cannot load `.ts`), so this entry is plain
|
|
4
|
+
* JavaScript; types are in mdx-config.d.ts.
|
|
5
|
+
*/
|
|
6
|
+
export { remarkInlineMedia } from "@agentvibes/site-mdx/remark-inline-media";
|
|
7
|
+
export { remarkMarkdownCopy } from "@agentvibes/site-mdx/remark-markdown-copy";
|
package/src/mdx.tsx
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { siteMdxComponents } from "@agentvibes/site-mdx";
|
|
2
|
+
import type { MDXComponents } from "mdx/types";
|
|
3
|
+
|
|
4
|
+
import { Hero } from "./components/hero/hero";
|
|
5
|
+
import { ModuleCards } from "./components/module-cards/module-cards";
|
|
6
|
+
import { SiteVideo, type SiteVideoProps } from "./components/site-video/site-video";
|
|
7
|
+
import type { SiteRuntime } from "./runtime";
|
|
8
|
+
|
|
9
|
+
/** MDX components for this site: `@agentvibes/site-mdx` plus the config-aware landing pieces and `Video` bound to the site's media. */
|
|
10
|
+
export function mdxComponentsFor(site: SiteRuntime, overrides: MDXComponents = {}): MDXComponents {
|
|
11
|
+
const modules = site.config.sections.npm?.modules ?? [];
|
|
12
|
+
return siteMdxComponents({
|
|
13
|
+
Hero,
|
|
14
|
+
ModuleCards: () => <ModuleCards modules={modules} />,
|
|
15
|
+
Video: (props: SiteVideoProps) => <SiteVideo {...props} videos={site.generated.media.videos} />,
|
|
16
|
+
...overrides
|
|
17
|
+
});
|
|
18
|
+
}
|
package/src/nav.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Header and footer links, derived from site.config (nothing hand-kept).
|
|
3
|
+
*/
|
|
4
|
+
import type { NavItem, PresetLink, SiteConfig, TelemetryKeys } from "@agentvibes/site-config";
|
|
5
|
+
|
|
6
|
+
export type SiteLink = { readonly label: string; readonly href: string; readonly external: boolean };
|
|
7
|
+
export type FooterColumn = { readonly title: string; readonly links: readonly SiteLink[] };
|
|
8
|
+
|
|
9
|
+
function isExternal(href: string): boolean {
|
|
10
|
+
return /^[a-z][a-z0-9+.-]*:/i.test(href);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function link(label: string, href: string): SiteLink {
|
|
14
|
+
return { label, href, external: isExternal(href) };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function githubUrl(config: SiteConfig): string | undefined {
|
|
18
|
+
return config.links.github === undefined ? undefined : `https://github.com/${config.links.github}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function hasDocs(config: SiteConfig): boolean {
|
|
22
|
+
return config.kind !== "landing" && config.sections.docs !== false;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function hasChangelog(config: SiteConfig): boolean {
|
|
26
|
+
return config.sections.changelog !== undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function flattenNav(items: readonly NavItem[]): SiteLink[] {
|
|
30
|
+
return items.flatMap((item) => ("href" in item ? [link(item.title, item.href)] : flattenNav(item.children)));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The header's main links: `nav: "auto"` → Docs, each npm module, Changelog; an explicit tree → its leaves. */
|
|
34
|
+
export function headerLinks(config: SiteConfig): SiteLink[] {
|
|
35
|
+
const extra = config.links.extra.filter((item) => item.placement === "header").map((item) => link(item.label, item.href));
|
|
36
|
+
if (config.nav !== "auto") return [...flattenNav(config.nav), ...extra];
|
|
37
|
+
const auto: SiteLink[] = [];
|
|
38
|
+
if (hasDocs(config)) auto.push(link("Docs", "/docs"));
|
|
39
|
+
for (const mod of config.sections.npm?.modules ?? []) auto.push(link(mod.title, `/docs/${mod.id}`));
|
|
40
|
+
if (hasChangelog(config)) auto.push(link("Changelog", "/changelog"));
|
|
41
|
+
return [...auto, ...extra];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Repository links: GitHub, Issues, Releases (each switchable in `links`). */
|
|
45
|
+
export function repositoryLinks(config: SiteConfig): SiteLink[] {
|
|
46
|
+
const base = githubUrl(config);
|
|
47
|
+
if (base === undefined) return [];
|
|
48
|
+
const links = [link("GitHub", base)];
|
|
49
|
+
if (config.links.issues) links.push(link("Issues", `${base}/issues`));
|
|
50
|
+
if (config.links.releases) links.push(link("Releases", `${base}/releases`));
|
|
51
|
+
return links;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type FooterInput = {
|
|
55
|
+
readonly config: SiteConfig;
|
|
56
|
+
/** True when llms.txt is served (indexable request). */
|
|
57
|
+
readonly llms: boolean;
|
|
58
|
+
/** Preset internal links; passed only off the canonical production host. */
|
|
59
|
+
readonly internal: readonly PresetLink[];
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export function footerColumns(input: FooterInput): FooterColumn[] {
|
|
63
|
+
const { config } = input;
|
|
64
|
+
const site: SiteLink[] = [];
|
|
65
|
+
if (hasDocs(config)) site.push(link("Docs", "/docs"));
|
|
66
|
+
for (const mod of config.sections.npm?.modules ?? []) site.push(link(mod.title, `/docs/${mod.id}`));
|
|
67
|
+
if (hasChangelog(config)) site.push(link("Changelog", "/changelog"));
|
|
68
|
+
|
|
69
|
+
const resources: SiteLink[] = [];
|
|
70
|
+
if (config.sections.npm !== undefined) {
|
|
71
|
+
for (const mod of config.sections.npm.modules) resources.push(link(`${mod.title} API`, `/docs/${mod.id}/jsdoc`));
|
|
72
|
+
}
|
|
73
|
+
if (input.llms) resources.push(link("llms.txt", "/llms.txt"));
|
|
74
|
+
resources.push(...config.links.extra.filter((item) => item.placement === "footer").map((item) => link(item.label, item.href)));
|
|
75
|
+
|
|
76
|
+
const columns: FooterColumn[] = [
|
|
77
|
+
{ title: "Site", links: site },
|
|
78
|
+
{ title: "Resources", links: resources },
|
|
79
|
+
{ title: "Repository", links: repositoryLinks(config) },
|
|
80
|
+
{ title: "Internal", links: input.internal.map((item) => link(item.label, item.href)) }
|
|
81
|
+
];
|
|
82
|
+
return columns.filter((column) => column.links.length > 0);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Dashboards of the configured collectors, for the Internal footer column
|
|
87
|
+
* (rendered off canonical production only). Origins only: the collector hosts
|
|
88
|
+
* are known, deep links into their UIs are not part of any contract here.
|
|
89
|
+
*/
|
|
90
|
+
export function telemetryDashboards(keys: TelemetryKeys): PresetLink[] {
|
|
91
|
+
const links: PresetLink[] = [];
|
|
92
|
+
if (keys.umami.kind === "configured") links.push({ label: "Analytics (Umami)", href: new URL(keys.umami.host).origin });
|
|
93
|
+
if (keys.glitchtip.kind === "configured" && URL.canParse(keys.glitchtip.dsn)) {
|
|
94
|
+
links.push({ label: "Errors (GlitchTip)", href: new URL(keys.glitchtip.dsn).origin });
|
|
95
|
+
}
|
|
96
|
+
return links;
|
|
97
|
+
}
|
package/src/og-route.tsx
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { ogImage } from "@agentvibes/site-seo/og";
|
|
2
|
+
|
|
3
|
+
import { catalog, findPage } from "./catalog";
|
|
4
|
+
import { pathFromStaticSlug, staticSlugOf } from "./output";
|
|
5
|
+
import type { SiteRuntime } from "./runtime";
|
|
6
|
+
import { ogColors, siteTheme } from "./theme";
|
|
7
|
+
|
|
8
|
+
type SlugContext = { params: Promise<{ slug?: string[] }> };
|
|
9
|
+
|
|
10
|
+
/** Static output: one `.png` file per page, and `index.png` for the site. */
|
|
11
|
+
export function createOgParams(site: SiteRuntime) {
|
|
12
|
+
return () => [{ slug: staticSlugOf("/", ".png") }, ...catalog(site).filter((page) => page.path !== "/").map((page) => ({ slug: staticSlugOf(page.path, ".png") }))];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** `/og/<path>`: the Open Graph image of a page (`/og` for the site). */
|
|
16
|
+
export function createOgRoute(site: SiteRuntime) {
|
|
17
|
+
return async function GET(_request: Request, context: SlugContext): Promise<Response> {
|
|
18
|
+
const { slug = [] } = await context.params;
|
|
19
|
+
const path = site.generated.output.kind === "static" ? pathFromStaticSlug(slug, ".png") : `/${slug.join("/")}`;
|
|
20
|
+
const page = slug.length === 0 || path === "/" ? undefined : findPage(site, path);
|
|
21
|
+
if (slug.length > 0 && path !== "/" && page === undefined) return new Response("Not found", { status: 404 });
|
|
22
|
+
return ogImage({
|
|
23
|
+
siteName: site.config.name,
|
|
24
|
+
title: page?.title ?? site.config.name,
|
|
25
|
+
description: page?.description ?? site.config.description,
|
|
26
|
+
colors: ogColors(siteTheme(site.preset))
|
|
27
|
+
});
|
|
28
|
+
};
|
|
29
|
+
}
|
package/src/output.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { OutputKind } from "@agentvibes/site-config";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Where a built site runs. `server` is the default: Next on a server, every
|
|
5
|
+
* page rendered per request. `static` is `next build` with `output: "export"`,
|
|
6
|
+
* for hosts that only serve files, optionally under a sub-path. `origin` and
|
|
7
|
+
* `basePath` say where the files will be served from, since no request exists
|
|
8
|
+
* at build time to read them from.
|
|
9
|
+
*/
|
|
10
|
+
export type SiteOutput =
|
|
11
|
+
| { readonly kind: "server" }
|
|
12
|
+
| {
|
|
13
|
+
readonly kind: "static";
|
|
14
|
+
/** The origin the files are served from: indexing and errors treat it as the request's host. */
|
|
15
|
+
readonly origin: string;
|
|
16
|
+
/** The sub-path of the site on that origin, `""` at the root, else `/x/name` (no trailing slash). */
|
|
17
|
+
readonly basePath: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const SERVER_OUTPUT: SiteOutput = { kind: "server" };
|
|
21
|
+
|
|
22
|
+
export class SiteOutputError extends Error {
|
|
23
|
+
override readonly name = "SiteOutputError";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Validated `static` output from the `--origin` and `--base-path` flags. */
|
|
27
|
+
export function staticOutput(origin: string, basePath: string): SiteOutput {
|
|
28
|
+
if (!URL.canParse(origin) || new URL(origin).origin !== origin) {
|
|
29
|
+
throw new SiteOutputError(`--origin must be an origin (scheme + host, no path or trailing slash), got ${JSON.stringify(origin)}`);
|
|
30
|
+
}
|
|
31
|
+
if (basePath !== "" && !/^(\/[A-Za-z0-9._~-]+)+$/.test(basePath)) {
|
|
32
|
+
throw new SiteOutputError(`--base-path must be empty or look like /x/name (no trailing slash), got ${JSON.stringify(basePath)}`);
|
|
33
|
+
}
|
|
34
|
+
return { kind: "static", origin, basePath };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The old `prepare` flags. They override site.config's `output`, so existing build scripts keep working. */
|
|
38
|
+
export type OutputFlags = { readonly static?: boolean | undefined; readonly origin?: string | undefined; readonly basePath?: string | undefined };
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The output a build uses: the flags if any, else site.config's `output`.
|
|
42
|
+
* A static site's origin is `siteUrl` unless `--origin` says where the
|
|
43
|
+
* files are served from (a preview or lab host).
|
|
44
|
+
*/
|
|
45
|
+
export function resolveOutput(configOutput: OutputKind, siteUrl: string, flags: OutputFlags): SiteOutput {
|
|
46
|
+
const kind: OutputKind = flags.static === true ? "static" : configOutput;
|
|
47
|
+
if (kind === "server") {
|
|
48
|
+
if (flags.origin !== undefined || flags.basePath !== undefined) {
|
|
49
|
+
throw new SiteOutputError('--origin and --base-path only apply to a static build: set output: "static" in site.config, or pass --static');
|
|
50
|
+
}
|
|
51
|
+
return SERVER_OUTPUT;
|
|
52
|
+
}
|
|
53
|
+
return staticOutput(flags.origin ?? new URL(siteUrl).origin, flags.basePath ?? "");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** `path` as the browser must ask for it: Next adds the base path to its links, but not to plain URLs. */
|
|
57
|
+
export function withBasePath(output: SiteOutput, path: string): string {
|
|
58
|
+
return output.kind === "static" ? `${output.basePath}${path}` : path;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The address of a page's Open Graph image; a static site has one PNG file per page. */
|
|
62
|
+
export function ogImageUrl(output: SiteOutput, path: string): string {
|
|
63
|
+
if (output.kind === "server") return path === "/" ? "/og" : `/og${path}`;
|
|
64
|
+
// Absolute: metadataBase is the canonical site, which is not where a copy's files live.
|
|
65
|
+
return `${output.origin}${output.basePath}/og${path === "/" ? "/index" : path}.png`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The address of a page's Markdown twin in `<link rel="alternate">`, for the same reason as {@link ogImageUrl}. */
|
|
69
|
+
export function markdownAlternateUrl(output: SiteOutput, markdownPath: string): string {
|
|
70
|
+
return output.kind === "server" ? markdownPath : `${output.origin}${output.basePath}${markdownPath}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The URL path a static route's last segment stands for: `["docs", "init.md"]` with `.md` → `/docs/init`. */
|
|
74
|
+
export function pathFromStaticSlug(slug: readonly string[], extension: string): string {
|
|
75
|
+
const joined = `/${slug.join("/")}`;
|
|
76
|
+
const path = joined.endsWith(extension) ? joined.slice(0, -extension.length) : joined;
|
|
77
|
+
return path === "/index" ? "/" : path;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** `/docs/init` with `.md` → `["docs", "init.md"]`; `/` → `["index.md"]`. */
|
|
81
|
+
export function staticSlugOf(path: string, extension: string): string[] {
|
|
82
|
+
const parts = path === "/" ? ["index"] : path.split("/").filter(Boolean);
|
|
83
|
+
const last = parts.pop() ?? "index";
|
|
84
|
+
return [...parts, `${last}${extension}`];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** A static site has no search server: Fumadocs' default dialog loads the prebuilt index the search route exported. */
|
|
88
|
+
export function searchProviderProps(output: SiteOutput) {
|
|
89
|
+
return output.kind === "server" ? {} : { search: { options: { type: "static" as const, api: withBasePath(output, "/api/search") } } };
|
|
90
|
+
}
|
package/src/pages.tsx
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { markdownPath } from "@agentvibes/site-llms";
|
|
2
|
+
import { pageMetadata, type IndexingDecision } from "@agentvibes/site-seo";
|
|
3
|
+
import { JsonLdScript } from "@agentvibes/site-seo/json-ld";
|
|
4
|
+
import { DocsBody, DocsDescription, DocsPage, DocsTitle } from "fumadocs-ui/layouts/docs/page";
|
|
5
|
+
import type { Metadata } from "next";
|
|
6
|
+
import { PageLink as Link } from "./components/page-link/page-link";
|
|
7
|
+
import { notFound, redirect } from "next/navigation";
|
|
8
|
+
|
|
9
|
+
import { documentViewport } from "./app-mode";
|
|
10
|
+
import { markdownAlternateUrl, ogImageUrl, withBasePath } from "./output";
|
|
11
|
+
import { hasOwnTitle } from "./docs-title";
|
|
12
|
+
import { ChangelogEntryView } from "./components/changelog-entry-view";
|
|
13
|
+
import { HomeShell } from "./components/home-shell";
|
|
14
|
+
import { JsdocView } from "./components/jsdoc-view/jsdoc-view";
|
|
15
|
+
import { PageActions } from "./components/page-actions/page-actions";
|
|
16
|
+
import { requestContext } from "./context";
|
|
17
|
+
import { mdxComponentsFor } from "./mdx";
|
|
18
|
+
import type { SiteRuntime } from "./runtime";
|
|
19
|
+
|
|
20
|
+
type SlugParams = { params: Promise<{ slug?: string[] }> };
|
|
21
|
+
type ModuleParams = { params: Promise<{ moduleId: string }> };
|
|
22
|
+
|
|
23
|
+
function meta(site: SiteRuntime, decision: IndexingDecision, page: { title: string; description: string | undefined; path: string }): Metadata {
|
|
24
|
+
const output = site.generated.output;
|
|
25
|
+
return pageMetadata(
|
|
26
|
+
site.seo,
|
|
27
|
+
{ title: page.title, description: page.description, path: page.path, markdownPath: markdownAlternateUrl(output, markdownPath(page.path)), ogImagePath: ogImageUrl(output, page.path) },
|
|
28
|
+
decision
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function breadcrumbs(path: string, title: string): Array<{ name: string; path: string }> {
|
|
33
|
+
const parts = path.split("/").filter(Boolean);
|
|
34
|
+
return parts.map((part, index) => ({ name: index === parts.length - 1 ? title : part, path: `/${parts.slice(0, index + 1).join("/")}` }));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function Actions(props: { site: SiteRuntime; path: string }) {
|
|
38
|
+
const ctx = await requestContext(props.site);
|
|
39
|
+
const md = withBasePath(props.site.generated.output, markdownPath(props.path));
|
|
40
|
+
return <PageActions markdownPath={md} markdownUrl={`${ctx.origin}${md}`} />;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** `/docs/[[...slug]]`. */
|
|
44
|
+
export function createDocsPage(site: SiteRuntime) {
|
|
45
|
+
async function generateMetadata(props: SlugParams): Promise<Metadata> {
|
|
46
|
+
const { slug } = await props.params;
|
|
47
|
+
const page = site.docs.getPage(slug ?? []);
|
|
48
|
+
if (page === undefined) notFound();
|
|
49
|
+
const ctx = await requestContext(site);
|
|
50
|
+
return meta(site, ctx.decision, { title: page.data.title ?? page.url, description: page.data.description, path: page.url });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function Page(props: SlugParams) {
|
|
54
|
+
const { slug } = await props.params;
|
|
55
|
+
const page = site.docs.getPage(slug ?? []);
|
|
56
|
+
if (page === undefined) notFound();
|
|
57
|
+
const Body = page.data.body;
|
|
58
|
+
const title = page.data.title ?? page.url;
|
|
59
|
+
const ownTitle = hasOwnTitle(page.data.toc);
|
|
60
|
+
return (
|
|
61
|
+
<DocsPage toc={page.data.toc} full={page.data.full ?? false}>
|
|
62
|
+
{ownTitle ? null : <DocsTitle>{title}</DocsTitle>}
|
|
63
|
+
{ownTitle || page.data.description === undefined ? null : <DocsDescription>{page.data.description}</DocsDescription>}
|
|
64
|
+
<Actions site={site} path={page.url} />
|
|
65
|
+
<DocsBody>
|
|
66
|
+
<Body components={mdxComponentsFor(site, { a: site.docs.relativeLink(page) })} />
|
|
67
|
+
</DocsBody>
|
|
68
|
+
<JsonLdScript site={site.seo} page={{ title, description: page.data.description, path: page.url, breadcrumbs: breadcrumbs(page.url, title) }} />
|
|
69
|
+
</DocsPage>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Static output: every page of the collection, as a file. */
|
|
74
|
+
function generateStaticParams() {
|
|
75
|
+
return site.docs.getPages().map((page) => ({ slug: [...page.slugs] }));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return { generateMetadata, generateStaticParams, Page, viewport: documentViewport(site) };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function moduleOf(site: SiteRuntime, moduleId: string) {
|
|
82
|
+
return site.config.sections.npm?.modules.find((mod) => mod.id === moduleId);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** `/docs/[moduleId]/jsdoc`: the API reference of an npm module. */
|
|
86
|
+
export function createJsdocPage(site: SiteRuntime) {
|
|
87
|
+
async function generateMetadata(props: ModuleParams): Promise<Metadata> {
|
|
88
|
+
const { moduleId } = await props.params;
|
|
89
|
+
const mod = moduleOf(site, moduleId);
|
|
90
|
+
if (mod === undefined) notFound();
|
|
91
|
+
const ctx = await requestContext(site);
|
|
92
|
+
return meta(site, ctx.decision, { title: `${mod.title} API reference`, description: `Exports of ${mod.npm.name}.`, path: `/docs/${mod.id}/jsdoc` });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function Page(props: ModuleParams) {
|
|
96
|
+
const { moduleId } = await props.params;
|
|
97
|
+
const mod = moduleOf(site, moduleId);
|
|
98
|
+
if (mod === undefined) notFound();
|
|
99
|
+
const path = `/docs/${mod.id}/jsdoc`;
|
|
100
|
+
return (
|
|
101
|
+
<DocsPage>
|
|
102
|
+
<DocsTitle>{mod.title} API reference</DocsTitle>
|
|
103
|
+
<DocsDescription>
|
|
104
|
+
Exports of <code>{mod.npm.name}</code>. <Link href={`/docs/${mod.id}`}>Back to the module</Link>
|
|
105
|
+
</DocsDescription>
|
|
106
|
+
<Actions site={site} path={path} />
|
|
107
|
+
<DocsBody>
|
|
108
|
+
<JsdocView items={site.generated.jsdoc.modules[mod.id]?.items ?? []} />
|
|
109
|
+
</DocsBody>
|
|
110
|
+
</DocsPage>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function generateStaticParams() {
|
|
115
|
+
return (site.config.sections.npm?.modules ?? []).map((mod) => ({ moduleId: mod.id }));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return { generateMetadata, generateStaticParams, Page, viewport: documentViewport(site) };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** `/changelog`: every package's CHANGELOG.md (written by changesets). */
|
|
122
|
+
export function createChangelogPage(site: SiteRuntime) {
|
|
123
|
+
async function generateMetadata(): Promise<Metadata> {
|
|
124
|
+
const ctx = await requestContext(site);
|
|
125
|
+
return meta(site, ctx.decision, { title: "Changelog", description: `Release notes of ${site.config.name}.`, path: "/changelog" });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function Page() {
|
|
129
|
+
return (
|
|
130
|
+
<HomeShell site={site}>
|
|
131
|
+
<div className="prose mx-auto max-w-3xl py-12">
|
|
132
|
+
<h1>Changelog</h1>
|
|
133
|
+
<Actions site={site} path="/changelog" />
|
|
134
|
+
{site.generated.changelog.length === 0 ? <p>No releases yet.</p> : null}
|
|
135
|
+
{site.generated.changelog.map((entry) => (
|
|
136
|
+
<ChangelogEntryView key={entry.packageName} packageName={entry.packageName} markdown={entry.markdown} />
|
|
137
|
+
))}
|
|
138
|
+
</div>
|
|
139
|
+
</HomeShell>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return { generateMetadata, Page, viewport: documentViewport(site) };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** `/`: `content/pages/index.mdx` in the home layout; without it, a docs site redirects to `/docs`. */
|
|
147
|
+
export function createLandingPage(site: SiteRuntime) {
|
|
148
|
+
async function generateMetadata(): Promise<Metadata> {
|
|
149
|
+
const page = site.pages.getPage([]);
|
|
150
|
+
const ctx = await requestContext(site);
|
|
151
|
+
return meta(site, ctx.decision, { title: page?.data.title ?? site.config.name, description: page?.data.description ?? site.config.description, path: "/" });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function Page() {
|
|
155
|
+
const page = site.pages.getPage([]);
|
|
156
|
+
if (page === undefined) {
|
|
157
|
+
if (site.config.kind === "landing") notFound();
|
|
158
|
+
redirect("/docs");
|
|
159
|
+
}
|
|
160
|
+
const Body = page.data.body;
|
|
161
|
+
return (
|
|
162
|
+
<HomeShell site={site}>
|
|
163
|
+
<div className="prose max-w-none">
|
|
164
|
+
<Body components={mdxComponentsFor(site)} />
|
|
165
|
+
</div>
|
|
166
|
+
</HomeShell>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return { generateMetadata, Page, viewport: documentViewport(site) };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function createNotFoundPage(site: SiteRuntime) {
|
|
174
|
+
return function NotFound() {
|
|
175
|
+
return (
|
|
176
|
+
<HomeShell site={site}>
|
|
177
|
+
<div className="mx-auto max-w-xl py-24 text-center">
|
|
178
|
+
<h1 className="mb-3 text-3xl font-semibold">Page not found</h1>
|
|
179
|
+
<p className="text-fd-muted-foreground">
|
|
180
|
+
<Link href="/">Back to {site.config.name}</Link>
|
|
181
|
+
</p>
|
|
182
|
+
</div>
|
|
183
|
+
</HomeShell>
|
|
184
|
+
);
|
|
185
|
+
};
|
|
186
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `public/_av/`: the static files prepare owns: the terminal font
|
|
3
|
+
* (`fonts/`), the app icons rendered from one SVG (`icons/`, with `app.pwa`)
|
|
4
|
+
* and the videos of `sections.media.videos` (`media/`). Binary files cannot
|
|
5
|
+
* carry the generated-file header, so the directory carries it: a marker file
|
|
6
|
+
* says prepare owns it, and its `.gitignore` keeps it out of git. A
|
|
7
|
+
* `public/_av/` without the marker belongs to the author and is never touched.
|
|
8
|
+
*/
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
|
|
12
|
+
import { ICONS_DIR } from "@agentvibes/site-app";
|
|
13
|
+
import { imageSize, renderIcons } from "@agentvibes/site-app/icons";
|
|
14
|
+
import { copyTerminalFonts } from "@agentvibes/site-mdx/node";
|
|
15
|
+
|
|
16
|
+
import type { MediaData, VideoEntry } from "../data";
|
|
17
|
+
import { GENERATED_MARKER } from "./files";
|
|
18
|
+
|
|
19
|
+
export const ASSETS_DIR = "public/_av";
|
|
20
|
+
export const ASSETS_MARKER_FILE = ".agentvibes-generated";
|
|
21
|
+
const MEDIA_URL = "/_av/media/";
|
|
22
|
+
|
|
23
|
+
export class AssetsError extends Error {
|
|
24
|
+
override readonly name = "AssetsError";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type AssetsPlan = {
|
|
28
|
+
/** The app icon and the background its maskable icons are drawn on, with `app.pwa`. */
|
|
29
|
+
readonly icon: { readonly svgFile: string; readonly background: string } | undefined;
|
|
30
|
+
/** `sections.media.videos`, resolved. */
|
|
31
|
+
readonly videosDir: string | undefined;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function claimDir(dir: string): void {
|
|
35
|
+
if (fs.existsSync(dir) && !fs.existsSync(path.join(dir, ASSETS_MARKER_FILE))) {
|
|
36
|
+
throw new AssetsError(`${dir} exists and was not written by agentvibes-site prepare; move it away so prepare can own it.`);
|
|
37
|
+
}
|
|
38
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
39
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
40
|
+
fs.writeFileSync(path.join(dir, ASSETS_MARKER_FILE), `${GENERATED_MARKER}: the whole directory is rewritten on every run.\n`);
|
|
41
|
+
fs.writeFileSync(path.join(dir, ".gitignore"), `# ${GENERATED_MARKER}\n*\n`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const VIDEO_FILE = /^(?<name>[\w.-]+?)(?<compact>-compact)?\.(?<ext>mp4|webm|png|jpe?g|webp|vtt)$/;
|
|
45
|
+
|
|
46
|
+
type Draft = { -readonly [K in keyof VideoEntry]?: VideoEntry[K] };
|
|
47
|
+
|
|
48
|
+
function assign(draft: Draft, file: string, compact: boolean, ext: string): void {
|
|
49
|
+
const url = `${MEDIA_URL}${file}`;
|
|
50
|
+
if (ext === "mp4") draft[compact ? "compactMp4" : "mp4"] = url;
|
|
51
|
+
else if (ext === "webm") draft[compact ? "compactWebm" : "webm"] = url;
|
|
52
|
+
else if (ext === "vtt" && !compact) draft.captions = url;
|
|
53
|
+
else if (!compact) draft.poster = url;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Copies the videos into `media/` and lists them by name: `deploy.mp4`,
|
|
58
|
+
* `deploy.webm`, `deploy.png` (poster), `deploy.vtt` (captions),
|
|
59
|
+
* `deploy-compact.{mp4,webm}`. Every video needs its H.264 MP4.
|
|
60
|
+
*/
|
|
61
|
+
async function collectVideos(sourceDir: string, outDir: string): Promise<MediaData["videos"]> {
|
|
62
|
+
if (!fs.existsSync(sourceDir)) throw new AssetsError(`sections.media.videos: ${sourceDir} does not exist`);
|
|
63
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
64
|
+
const drafts = new Map<string, { draft: Draft; posterFile?: string }>();
|
|
65
|
+
for (const file of fs.readdirSync(sourceDir).sort()) {
|
|
66
|
+
const match = VIDEO_FILE.exec(file);
|
|
67
|
+
const name = match?.groups?.["name"];
|
|
68
|
+
const ext = match?.groups?.["ext"];
|
|
69
|
+
if (name === undefined || ext === undefined) continue;
|
|
70
|
+
fs.copyFileSync(path.join(sourceDir, file), path.join(outDir, file));
|
|
71
|
+
const entry = drafts.get(name) ?? { draft: {} };
|
|
72
|
+
const compact = match?.groups?.["compact"] !== undefined;
|
|
73
|
+
assign(entry.draft, file, compact, ext);
|
|
74
|
+
if (!compact && /^(png|jpe?g|webp)$/.test(ext)) entry.posterFile = path.join(sourceDir, file);
|
|
75
|
+
drafts.set(name, entry);
|
|
76
|
+
}
|
|
77
|
+
const videos: Record<string, VideoEntry> = {};
|
|
78
|
+
for (const [name, { draft, posterFile }] of drafts) {
|
|
79
|
+
const mp4 = draft.mp4;
|
|
80
|
+
if (mp4 === undefined) throw new AssetsError(`sections.media.videos: ${name} has no ${name}.mp4 (H.264); iPhones play nothing else`);
|
|
81
|
+
const size = posterFile === undefined ? undefined : await imageSize(posterFile);
|
|
82
|
+
videos[name] = { ...draft, mp4, ...(size ?? {}) };
|
|
83
|
+
}
|
|
84
|
+
return videos;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Rewrites `public/_av/` and returns the media manifest baked into the site data. */
|
|
88
|
+
export async function writeAssets(siteDir: string, plan: AssetsPlan): Promise<MediaData> {
|
|
89
|
+
const dir = path.join(siteDir, ASSETS_DIR);
|
|
90
|
+
claimDir(dir);
|
|
91
|
+
copyTerminalFonts(path.join(dir, "fonts"));
|
|
92
|
+
if (plan.icon !== undefined) {
|
|
93
|
+
if (!fs.existsSync(plan.icon.svgFile)) throw new AssetsError(`app.pwa.icon: ${plan.icon.svgFile} does not exist`);
|
|
94
|
+
await renderIcons(plan.icon.svgFile, path.join(siteDir, "public", ICONS_DIR), plan.icon.background);
|
|
95
|
+
}
|
|
96
|
+
const videos = plan.videosDir === undefined ? {} : await collectVideos(plan.videosDir, path.join(dir, "media"));
|
|
97
|
+
return { videos };
|
|
98
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads the site's own `site.next.mjs` outside of `next build`, for the two steps that must know what it asks for:
|
|
3
|
+
* `prepare` (reject `headers()` in a static export before the build) and `finish-static` (turn `redirects()` into pages).
|
|
4
|
+
*/
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { pathToFileURL } from "node:url";
|
|
8
|
+
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
|
|
11
|
+
import { AUTHOR_NEXT_CONFIG } from "./templates";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The shape of the config the generated next.config.mjs hands to the author's hook. `transpilePackages` is the
|
|
15
|
+
* real list there; a hook that spreads or appends to it must not crash here, and nothing we read depends on its content.
|
|
16
|
+
*/
|
|
17
|
+
const AUTHOR_BASE_CONFIG = { reactStrictMode: true, transpilePackages: [] as string[] };
|
|
18
|
+
|
|
19
|
+
const AuthorModule = z.object({ default: z.function() });
|
|
20
|
+
// ast-grep-ignore: zod-optional-nullable -- the author's file is a NextConfig, where redirects and headers are optional keys
|
|
21
|
+
const AuthorConfig = z.object({ redirects: z.function().optional(), headers: z.function().optional() }).passthrough();
|
|
22
|
+
// strict: a redirect conditioned on `has` or `missing` needs a server, and must not silently become unconditional.
|
|
23
|
+
// ast-grep-ignore: zod-optional-nullable -- permanent and statusCode are optional in Next's redirect type
|
|
24
|
+
const AuthorRedirects = z.array(z.object({ source: z.string(), destination: z.string(), permanent: z.boolean().optional(), statusCode: z.number().optional() }).strict());
|
|
25
|
+
const AuthorHeaders = z.array(z.object({ source: z.string() }).passthrough());
|
|
26
|
+
|
|
27
|
+
export type AuthorRedirect = z.infer<typeof AuthorRedirects>[number];
|
|
28
|
+
|
|
29
|
+
/** What the hook returned: no file and a file that sets neither are the same to the callers. */
|
|
30
|
+
export type AuthorNextConfig = {
|
|
31
|
+
readonly redirects: () => Promise<AuthorRedirect[]>;
|
|
32
|
+
readonly headers: () => Promise<{ source: string }[]>;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const NONE: AuthorNextConfig = { redirects: async () => [], headers: async () => [] };
|
|
36
|
+
|
|
37
|
+
export async function loadAuthorNextConfig(siteDir: string): Promise<AuthorNextConfig> {
|
|
38
|
+
const file = path.join(siteDir, AUTHOR_NEXT_CONFIG);
|
|
39
|
+
if (!fs.existsSync(file)) return NONE;
|
|
40
|
+
const imported = AuthorModule.parse(await import(pathToFileURL(file).href));
|
|
41
|
+
const config = AuthorConfig.parse(await imported.default({ ...AUTHOR_BASE_CONFIG, transpilePackages: [] }));
|
|
42
|
+
return {
|
|
43
|
+
redirects: async () => (config.redirects === undefined ? [] : AuthorRedirects.parse(await config.redirects())),
|
|
44
|
+
headers: async () => (config.headers === undefined ? [] : AuthorHeaders.parse(await config.headers()))
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import type { ChangelogEntry } from "../data";
|
|
5
|
+
import { readPackageJson } from "./jsdoc-extract";
|
|
6
|
+
|
|
7
|
+
/** CHANGELOG.md (written by changesets) of each package directory that has one, title line removed. */
|
|
8
|
+
export function readChangelogs(repoRoot: string, packageDirs: readonly string[]): ChangelogEntry[] {
|
|
9
|
+
return packageDirs.flatMap((dir) => {
|
|
10
|
+
const abs = path.resolve(repoRoot, dir);
|
|
11
|
+
const file = path.join(abs, "CHANGELOG.md");
|
|
12
|
+
if (!fs.existsSync(file)) return [];
|
|
13
|
+
const markdown = fs.readFileSync(file, "utf8").replace(/^#\s+[^\n]*\n+/, "");
|
|
14
|
+
const packageName = readPackageJson(abs)?.name ?? path.basename(abs);
|
|
15
|
+
return [{ packageName, markdown }];
|
|
16
|
+
});
|
|
17
|
+
}
|