Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,27 @@ jobs:
run: pnpm turbo run build --filter='./packages/*'
- run: pnpm test

docs:
name: Docs (${{ matrix.environment }})
runs-on: ubuntu-latest
strategy:
matrix:
environment: [production, preview]
env:
VERCEL_ENV: ${{ matrix.environment }}
DOCS_EXPECT_NOINDEX: ${{ matrix.environment == 'preview' && '1' || '0' }}
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version-file: .node-version
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm turbo run build --filter='web^...'
- run: pnpm --filter web build
- run: pnpm --filter web test:routes

typecheck:
name: Type Check
runs-on: ubuntu-latest
Expand Down
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ Do **not** add `--port` flags -- portless handles port assignment automatically.
## Workflow

- Run `pnpm type-check` after each turn to ensure type safety
- Documentation lives in `apps/web/content/docs/` and uses Geistdocs frontmatter. Keep public `/docs` URLs, heading IDs, `lib/page-titles.ts`, `lib/docs-navigation.ts`, and the content `meta.json` files in sync.
- For docs routing or infrastructure changes, run `pnpm turbo run build --filter='web^...'`, `pnpm --filter web build`, and `pnpm --filter web test:routes`. Existing-page source and Markdown parity are covered by `apps/web/tests/fixtures/docs-baseline.json`; update fixtures only when intentionally changing the documented content.
- When making user-facing changes (new packages, API changes, new features, renamed exports, changed behavior), update the relevant documentation:
- Package `README.md` files in `packages/*/README.md`
- Root `README.md` (if packages table, install commands, or examples are affected)
Expand All @@ -90,7 +92,7 @@ When asked to prepare a release (e.g. "prepare v0.17.0"):
5. **Fill documentation gaps** — every public package should have:
- A row in the root `README.md` packages table
- A renderer section in the root `README.md` (if it's a renderer)
- An API reference page at `apps/web/app/(main)/docs/api/<name>/page.mdx`
- An API reference page at `apps/web/content/docs/api/<name>.mdx`
- An entry in `apps/web/lib/page-titles.ts` and `apps/web/lib/docs-navigation.ts`
- An entry in the docs-chat system prompt (`apps/web/app/api/docs-chat/route.ts`)
- A skill at `skills/<name>/SKILL.md`
Expand Down
1 change: 1 addition & 0 deletions apps/web/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

# next.js
/.next/
/.source/
/out/

# production
Expand Down
35 changes: 0 additions & 35 deletions apps/web/app/(main)/docs/layout.tsx

This file was deleted.

8 changes: 8 additions & 0 deletions apps/web/app/(main)/examples/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { ReactNode } from "react";
import { pageMetadata } from "@/lib/page-metadata";

export const metadata = pageMetadata("examples");

export default function ExamplesLayout({ children }: { children: ReactNode }) {
return children;
}
14 changes: 2 additions & 12 deletions apps/web/app/(main)/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,7 @@
import { Header } from "@/components/header";
import { getStarCount } from "@/lib/github";

export default async function MainLayout({
export default function MainLayout({
children,
}: {
children: React.ReactNode;
}) {
const stars = await getStarCount();

return (
<div className="min-h-screen flex flex-col">
<Header stars={stars} />
<main className="flex-1">{children}</main>
</div>
);
return <main className="min-h-[calc(100dvh-4rem)]">{children}</main>;
}
43 changes: 43 additions & 0 deletions apps/web/app/[lang]/docs/[[...slug]]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { MobileDocsBar } from "@vercel/geistdocs/mobile-docs-bar";
import { createDocsPage } from "@vercel/geistdocs/pages/docs";
import { notFound } from "next/navigation";
import { GenerationModesDiagram } from "@/components/generation-modes-diagram";
import { PackageInstall } from "@/components/package-install";
import { isSafePathSegments } from "@/lib/docs-source";
import { config } from "@/lib/geistdocs/config";
import { geistdocsSource } from "@/lib/geistdocs/source";
import { pageMetadata } from "@/lib/page-metadata";

type PageProps = { params: Promise<{ lang: string; slug?: string[] }> };

async function validate(params: PageProps["params"]) {
const resolved = await params;
if (resolved.lang !== "en" || !isSafePathSegments(resolved.slug ?? []))
notFound();
try {
if (!geistdocsSource.source.getPage(resolved.slug, resolved.lang))
notFound();
} catch (error) {
if (error instanceof URIError) notFound();
throw error;
}
return resolved;
}

const docsPage = createDocsPage({
config,
source: geistdocsSource,
mdx: { GenerationModesDiagram, PackageInstall },
renderTop: ({ data }) => <MobileDocsBar toc={data.toc} />,
});

export default async function Page({ params }: PageProps) {
return <docsPage.Page params={Promise.resolve(await validate(params))} />;
}

export async function generateMetadata({ params }: PageProps) {
const { slug = [] } = await validate(params);
return pageMetadata(["docs", ...slug].join("/"));
}

export const generateStaticParams = docsPage.generateStaticParams;
25 changes: 25 additions & 0 deletions apps/web/app/[lang]/docs/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { ReactNode } from "react";
import { notFound } from "next/navigation";
import { GeistdocsDocsLayout } from "@vercel/geistdocs/layout";
import { config } from "@/lib/geistdocs/config";
import { geistdocsSource } from "@/lib/geistdocs/source";

export default async function DocsLayout({
children,
params,
}: {
children: ReactNode;
params: Promise<{ lang: string }>;
}) {
const { lang } = await params;
if (lang !== "en") notFound();
return (
<GeistdocsDocsLayout
config={config}
tree={geistdocsSource.source.getPageTree(lang)}
containerProps={{ className: "mx-auto max-w-[1448px]" }}
>
{children}
</GeistdocsDocsLayout>
);
}
41 changes: 7 additions & 34 deletions apps/web/app/api/docs-chat/route.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
import { readFile } from "fs/promises";
import { join } from "path";
import { convertToModelMessages, stepCountIs, streamText } from "ai";
import type { ModelMessage, UIMessage } from "ai";
import { createBashTool } from "bash-tool";
import { headers } from "next/headers";
import { allDocsPages } from "@/lib/docs-navigation";
import { mdxToCleanMarkdown } from "@/lib/mdx-to-markdown";
import { loadAllDocsSources } from "@/lib/docs-source";
import { minuteRateLimit, dailyRateLimit } from "@/lib/rate-limit";

export const maxDuration = 60;
Expand All @@ -32,37 +29,13 @@ When answering questions:
- Do NOT use emojis in your responses`;

async function loadDocsFiles(): Promise<Record<string, string>> {
const files: Record<string, string> = {};

const results = await Promise.allSettled(
allDocsPages.map(async (page) => {
const slug =
page.href === "/docs" ? "" : page.href.replace(/^\/docs\/?/, "");
const filePath = slug
? join(
process.cwd(),
"app",
"(main)",
"docs",
...slug.split("/"),
"page.mdx",
)
: join(process.cwd(), "app", "(main)", "docs", "page.mdx");

const raw = await readFile(filePath, "utf-8");
const md = mdxToCleanMarkdown(raw);
const fileName = slug ? `/docs/${slug}.md` : "/docs/index.md";
return { fileName, md };
}),
const pages = await loadAllDocsSources();
return Object.fromEntries(
pages.map((page) => [
page.href === "/docs" ? "/docs/index.md" : `${page.href}.md`,
page.markdown,
]),
);

for (const result of results) {
if (result.status === "fulfilled") {
files[result.value.fileName] = result.value.md;
}
}

return files;
}

function addCacheControl(messages: ModelMessage[]): ModelMessage[] {
Expand Down
58 changes: 14 additions & 44 deletions apps/web/app/api/docs-markdown/route.ts
Original file line number Diff line number Diff line change
@@ -1,55 +1,25 @@
import { readFile } from "fs/promises";
import { join } from "path";
import { NextRequest, NextResponse } from "next/server";
import { mdxToCleanMarkdown } from "@/lib/mdx-to-markdown";
import { loadDocsSource } from "@/lib/docs-source";

export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const docPath = searchParams.get("path");

if (!docPath) {
const docPath = req.nextUrl.searchParams.get("path");
if (!docPath)
return NextResponse.json(
{ error: "Missing ?path= parameter" },
{ status: 400 },
);
}

// Sanitize path: only allow docs paths, no traversal
const normalized = docPath
.replace(/^\//, "")
.replace(/\.\./g, "")
.replace(/[^a-zA-Z0-9/-]/g, "");

if (!normalized.startsWith("docs")) {
const path = docPath.startsWith("/") ? docPath : `/${docPath}`;
if (!/^\/docs(?:\/[a-zA-Z0-9_-]+)*\/?$/.test(path)) {
return NextResponse.json({ error: "Invalid path" }, { status: 400 });
}

// Map URL path to file path
// /docs -> /app/(main)/docs/page.mdx
// /docs/installation -> /app/(main)/docs/installation/page.mdx
const slug = normalized === "docs" ? "" : normalized.replace(/^docs\/?/, "");
const filePath = slug
? join(
process.cwd(),
"app",
"(main)",
"docs",
...slug.split("/"),
"page.mdx",
)
: join(process.cwd(), "app", "(main)", "docs", "page.mdx");

try {
const raw = await readFile(filePath, "utf-8");
const markdown = mdxToCleanMarkdown(raw);

return new NextResponse(markdown, {
headers: {
"Content-Type": "text/markdown; charset=utf-8",
"Cache-Control": "public, max-age=3600",
},
});
} catch {
const page = await loadDocsSource(path);
if (!page)
return NextResponse.json({ error: "Page not found" }, { status: 404 });
}
return new NextResponse(page.markdown, {
headers: {
"Content-Type": "text/markdown; charset=utf-8",
"Cache-Control": "public, max-age=3600",
Link: `<${page.canonicalUrl}>; rel="canonical"`,
},
});
}
21 changes: 21 additions & 0 deletions apps/web/app/api/docs-md/[[...slug]]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { loadDocsSource, isSafePathSegments } from "@/lib/docs-source";
import { applyDocsResponseHeaders } from "@/lib/docs-response-headers";

export async function GET(
_request: Request,
{ params }: { params: Promise<{ slug?: string[] }> },
) {
const { slug = [] } = await params;
const path = `/docs${slug.length ? `/${slug.join("/")}` : ""}`;
const page = isSafePathSegments(slug) ? await loadDocsSource(path) : null;
const headers = new Headers({
"Content-Type": "text/markdown; charset=utf-8",
});
applyDocsResponseHeaders(headers);
if (page) headers.set("Link", `<${page.canonicalUrl}>; rel="canonical"`);
return new Response(
page?.markdown ??
"# Page Not Found\n\nSee [the documentation index](/llms.txt).\n",
{ status: page ? 200 : 404, headers },
);
}
6 changes: 6 additions & 0 deletions apps/web/app/api/search/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import { NextRequest, NextResponse } from "next/server";
import { getSearchIndex } from "@/lib/search-index";
import { createSearchRoute } from "@vercel/geistdocs/routes/search";
import { geistdocsSource } from "@/lib/geistdocs/source";
import { config } from "@/lib/geistdocs/config";

const docsSearch = createSearchRoute({ config, source: geistdocsSource });

export async function GET(req: NextRequest) {
if (req.nextUrl.searchParams.has("query")) return docsSearch(req);
const q = req.nextUrl.searchParams.get("q")?.trim().toLowerCase();

if (!q) {
Expand Down
Loading
Loading