mirror of
https://github.com/vercel-labs/json-render.git
synced 2026-10-02 12:00:58 +08:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1710ae6a07 | ||
|
|
fdb8982792 | ||
|
|
9f43f07108 | ||
|
|
9164a703fb | ||
|
|
b900f21ce7 | ||
|
|
32e4b095b3 | ||
|
|
d547429818 |
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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`
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/.source/
|
||||
/out/
|
||||
|
||||
# production
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { DocsMobileNav } from "@/components/docs-mobile-nav";
|
||||
import { DocsSidebar } from "@/components/docs-sidebar";
|
||||
import { CopyPageButton } from "@/components/copy-page-button";
|
||||
import { TableOfContents } from "@/components/table-of-contents";
|
||||
|
||||
export default function DocsLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<DocsMobileNav />
|
||||
<div className="max-w-7xl mx-auto px-6 py-8 lg:py-12 flex gap-12">
|
||||
{/* Sidebar */}
|
||||
<aside className="w-48 shrink-0 hidden lg:block sticky top-28 h-[calc(100vh-7rem)] overflow-y-auto">
|
||||
<DocsSidebar />
|
||||
</aside>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0 max-w-2xl pb-20">
|
||||
<div className="flex justify-end mb-4">
|
||||
<CopyPageButton />
|
||||
</div>
|
||||
<article>{children}</article>
|
||||
</div>
|
||||
|
||||
{/* On this page */}
|
||||
<aside className="w-44 shrink-0 hidden xl:block sticky top-28 h-[calc(100vh-7rem)] overflow-y-auto">
|
||||
<TableOfContents />
|
||||
</aside>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -34,37 +31,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[] {
|
||||
|
||||
@@ -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"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "@vercel/geistdocs/styles.css";
|
||||
|
||||
@theme {
|
||||
--breakpoint-sm: 40rem;
|
||||
--breakpoint-md: 48rem;
|
||||
--breakpoint-lg: 64rem;
|
||||
--breakpoint-xl: 80rem;
|
||||
--breakpoint-2xl: 96rem;
|
||||
}
|
||||
|
||||
@source "../node_modules/streamdown/dist/index.js";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
@custom-variant dark (&:is(.dark-theme *));
|
||||
|
||||
:root {
|
||||
--radius: 0.5rem;
|
||||
@@ -31,7 +38,7 @@
|
||||
--chat-bg: oklch(0.95 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
.dark-theme {
|
||||
--ds-gray-500: oklch(0.39 0 0);
|
||||
/* Monochrome dark theme */
|
||||
--background: oklch(0.0 0 0);
|
||||
@@ -189,9 +196,31 @@ article table {
|
||||
background-color: var(--shiki-light-bg) !important;
|
||||
}
|
||||
|
||||
.dark .shiki,
|
||||
.dark .shiki span {
|
||||
.dark-theme .shiki,
|
||||
.dark-theme .shiki span {
|
||||
color: var(--shiki-dark) !important;
|
||||
background-color: var(--shiki-dark-bg) !important;
|
||||
}
|
||||
|
||||
@container (width < 1200px) {
|
||||
#nd-page { @apply px-6 pt-6; }
|
||||
#nd-page > [data-mobile-docs-bar],
|
||||
#nd-page > div:has([data-mobile-toc-trigger]) { display: flex; }
|
||||
#nd-page [data-mobile-toc-trigger] { width: 44px; height: 44px; }
|
||||
#nd-page > div:has(> h1) { padding-inline-end: 3.5rem; }
|
||||
#nd-page > div:has(> h1) + div,
|
||||
#nd-page > div:has(> h1) + p + div { margin-top: 0; }
|
||||
}
|
||||
|
||||
@container (961px <= width < 1200px) {
|
||||
#nd-page > [data-mobile-docs-bar] { display: none; }
|
||||
}
|
||||
|
||||
header .pointer-events-none.opacity-0 {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
#nd-page div:has(> [aria-live="polite"]) > div > .max-sm\:hidden {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
|
||||
+31
-6
@@ -3,11 +3,16 @@ import localFont from "next/font/local";
|
||||
import { GeistPixelSquare } from "geist/font/pixel";
|
||||
import "./globals.css";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { DocsProvider } from "@/components/geistdocs-provider";
|
||||
import { Navbar } from "@vercel/geistdocs/navbar";
|
||||
import { Footer } from "@vercel/geistdocs/footer";
|
||||
import { config } from "@/lib/geistdocs/config";
|
||||
import { DocsChat } from "@/components/docs-chat";
|
||||
import { Analytics } from "@vercel/analytics/next";
|
||||
import { SpeedInsights } from "@vercel/speed-insights/next";
|
||||
import { PAGE_TITLES } from "@/lib/page-titles";
|
||||
import { cookies } from "next/headers";
|
||||
import { isPreview, siteUrl, siteDescription } from "@/lib/site";
|
||||
|
||||
const geistSans = localFont({
|
||||
src: "./fonts/GeistVF.woff",
|
||||
@@ -19,7 +24,8 @@ const geistMono = localFont({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL("https://json-render.dev"),
|
||||
metadataBase: new URL(siteUrl),
|
||||
alternates: { canonical: "/" },
|
||||
title: {
|
||||
default: `json-render | ${PAGE_TITLES[""]}`,
|
||||
template: "%s | json-render",
|
||||
@@ -64,8 +70,8 @@ export const metadata: Metadata = {
|
||||
images: ["/og"],
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
index: !isPreview,
|
||||
follow: !isPreview,
|
||||
},
|
||||
icons: {
|
||||
icon: "/favicon.ico",
|
||||
@@ -79,15 +85,30 @@ export default async function RootLayout({
|
||||
}>) {
|
||||
const cookieStore = await cookies();
|
||||
const chatOpen = cookieStore.get("docs-chat-open")?.value === "true";
|
||||
const chatWidth = Number(cookieStore.get("docs-chat-width")?.value) || 400;
|
||||
const chatWidth = Math.min(
|
||||
700,
|
||||
Math.max(300, Number(cookieStore.get("docs-chat-width")?.value) || 400),
|
||||
);
|
||||
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify({
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebSite",
|
||||
name: "json-render",
|
||||
url: siteUrl,
|
||||
description: siteDescription,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
{chatOpen && (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `@media(min-width:640px){body{padding-right:${chatWidth}px}}`,
|
||||
__html: `@media(min-width:640px){body{padding-right:min(${chatWidth}px, calc(100vw - 320px))}}`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -96,7 +117,11 @@ export default async function RootLayout({
|
||||
className={`${geistSans.variable} ${geistMono.variable} ${GeistPixelSquare.variable}`}
|
||||
>
|
||||
<ThemeProvider>
|
||||
{children}
|
||||
<DocsProvider>
|
||||
<Navbar config={config} />
|
||||
{children}
|
||||
<Footer />
|
||||
</DocsProvider>
|
||||
<DocsChat defaultOpen={chatOpen} defaultWidth={chatWidth} />
|
||||
</ThemeProvider>
|
||||
<Analytics />
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { loadAllDocsSources } from "@/lib/docs-source";
|
||||
import { siteDescription, siteUrl } from "@/lib/site";
|
||||
|
||||
export async function GET() {
|
||||
const pages = await loadAllDocsSources();
|
||||
const body = `# json-render\n\n${siteDescription}\n\n## Documentation\n\n${pages.map((page) => `- [${page.title}](${siteUrl}${page.markdownUrl})`).join("\n")}\n`;
|
||||
return new Response(body, {
|
||||
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
@@ -3,5 +3,9 @@ export default function PlaygroundLayout({
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <div className="h-dvh flex flex-col overflow-hidden">{children}</div>;
|
||||
return (
|
||||
<main className="h-[calc(100dvh-4rem)] flex flex-col overflow-hidden">
|
||||
{children}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { isPreview, siteUrl } from "@/lib/site";
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: isPreview
|
||||
? { userAgent: "*", disallow: "/" }
|
||||
: { userAgent: "*", allow: "/" },
|
||||
sitemap: `${siteUrl}/sitemap.xml`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { docsPages } from "@/lib/docs-source";
|
||||
|
||||
export function GET() {
|
||||
return new Response(
|
||||
`# json-render documentation\n\n${docsPages.map((page) => `- [${page.title}](${page.href})`).join("\n")}\n`,
|
||||
{
|
||||
headers: { "Content-Type": "text/markdown; charset=utf-8" },
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { PAGE_TITLES } from "@/lib/page-titles";
|
||||
import { siteUrl } from "@/lib/site";
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
return Object.keys(PAGE_TITLES).map((slug) => ({
|
||||
url: `${siteUrl}/${slug}`,
|
||||
}));
|
||||
}
|
||||
@@ -141,6 +141,7 @@ export function DocsChat({
|
||||
);
|
||||
const messagesScrollRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const launcherRef = useRef<HTMLButtonElement>(null);
|
||||
const restoredRef = useRef(false);
|
||||
const isDraggingRef = useRef(false);
|
||||
|
||||
@@ -173,13 +174,51 @@ export function DocsChat({
|
||||
}
|
||||
}, [open, hasMounted]);
|
||||
|
||||
useEffect(() => {
|
||||
const launcher = launcherRef.current;
|
||||
if (!hasMounted || open || !launcher) return;
|
||||
const footer = document.querySelector("footer");
|
||||
let frame = 0;
|
||||
const update = () => {
|
||||
frame = 0;
|
||||
const rect = document
|
||||
.querySelector("footer fieldset")
|
||||
?.getBoundingClientRect();
|
||||
const overlap =
|
||||
rect && rect.width > 0 && rect.bottom > 0
|
||||
? Math.max(0, innerHeight - rect.top)
|
||||
: 0;
|
||||
launcher.style.setProperty("--chat-launcher-bottom", `${24 + overlap}px`);
|
||||
};
|
||||
const schedule = () => {
|
||||
if (!frame) frame = requestAnimationFrame(update);
|
||||
};
|
||||
const resize = new ResizeObserver(schedule);
|
||||
resize.observe(document.body);
|
||||
const mutation = new MutationObserver(schedule);
|
||||
if (footer) {
|
||||
resize.observe(footer);
|
||||
mutation.observe(footer, { childList: true, subtree: true });
|
||||
}
|
||||
window.addEventListener("scroll", schedule, { passive: true });
|
||||
window.addEventListener("resize", schedule);
|
||||
update();
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
resize.disconnect();
|
||||
mutation.disconnect();
|
||||
window.removeEventListener("scroll", schedule);
|
||||
window.removeEventListener("resize", schedule);
|
||||
};
|
||||
}, [hasMounted, open]);
|
||||
|
||||
// Push page content on desktop when pane is open.
|
||||
// Use padding on body so the page scrollbar stays at the viewport edge (behind the sidebar)
|
||||
// instead of appearing right next to the sidebar's scrollbar.
|
||||
useEffect(() => {
|
||||
const body = document.body;
|
||||
if (isDesktop && open) {
|
||||
body.style.paddingRight = `${desktopWidth}px`;
|
||||
body.style.paddingRight = `min(${desktopWidth}px, calc(100vw - 320px))`;
|
||||
if (!isDraggingRef.current) {
|
||||
body.style.transition = "padding-right 150ms ease";
|
||||
}
|
||||
@@ -273,7 +312,13 @@ export function DocsChat({
|
||||
return !prev;
|
||||
});
|
||||
}
|
||||
if (e.key === "Escape" && open && isDesktop) {
|
||||
if (
|
||||
e.key === "Escape" &&
|
||||
open &&
|
||||
(isDesktop ||
|
||||
(e.target instanceof Element &&
|
||||
e.target.closest("#json-render-chat-mobile")))
|
||||
) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
@@ -459,6 +504,7 @@ export function DocsChat({
|
||||
rows={1}
|
||||
enterKeyHint="send"
|
||||
placeholder="Ask a question..."
|
||||
aria-label="Ask a question"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
@@ -496,12 +542,19 @@ export function DocsChat({
|
||||
{/* Ask AI trigger button */}
|
||||
{!open && (
|
||||
<button
|
||||
ref={launcherRef}
|
||||
data-docs-chat-launcher
|
||||
onClick={() => setOpen(true)}
|
||||
className="fixed z-50 bottom-4 left-1/2 -translate-x-1/2 sm:left-auto sm:translate-x-0 sm:right-4 flex items-center gap-2 px-4 py-2 rounded-lg border border-primary bg-primary text-primary-foreground shadow-lg hover:bg-primary/90 transition-colors text-sm font-medium"
|
||||
className="fixed z-30 bottom-[calc(1rem+env(safe-area-inset-bottom))] left-1/2 -translate-x-1/2 min-[640px]:left-auto min-[640px]:translate-x-0 min-[640px]:right-6 min-[640px]:bottom-[var(--chat-launcher-bottom,24px)] flex h-10 items-center gap-2 px-4 py-2 rounded-lg border border-primary bg-primary text-primary-foreground shadow-lg hover:bg-primary/90 transition-colors text-sm font-medium"
|
||||
aria-label="Ask AI"
|
||||
aria-expanded={open}
|
||||
aria-controls={
|
||||
isDesktop ? "json-render-chat-desktop" : "json-render-chat-mobile"
|
||||
}
|
||||
aria-keyshortcuts="Meta+I Control+I"
|
||||
>
|
||||
Ask AI
|
||||
<kbd className="hidden sm:inline-flex items-center gap-0.5 text-xs opacity-60 font-mono">
|
||||
<kbd className="hidden min-[640px]:inline-flex items-center gap-0.5 text-xs opacity-60 font-mono">
|
||||
<span>⌘</span>I
|
||||
</kbd>
|
||||
</button>
|
||||
@@ -509,9 +562,11 @@ export function DocsChat({
|
||||
|
||||
{/* Desktop: resizable side pane — always rendered, hidden on mobile via CSS */}
|
||||
<aside
|
||||
id="json-render-chat-desktop"
|
||||
inert={!open || !isDesktop}
|
||||
className={`hidden sm:flex fixed top-0 right-0 bottom-0 z-40 border-l bg-background transition-transform duration-150 ease-in-out ${open ? "translate-x-0" : "translate-x-full"}`}
|
||||
style={{ width: desktopWidth }}
|
||||
aria-hidden={!open}
|
||||
style={{ width: `min(${desktopWidth}px, calc(100vw - 320px))` }}
|
||||
aria-hidden={!open || !isDesktop}
|
||||
>
|
||||
{/* Resize handle */}
|
||||
<div
|
||||
@@ -525,6 +580,8 @@ export function DocsChat({
|
||||
{hasMounted && !isDesktop && (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetContent
|
||||
id="json-render-chat-mobile"
|
||||
aria-describedby={undefined}
|
||||
side="right"
|
||||
overlayClassName="!bg-background"
|
||||
className="!inset-0 !w-full !h-full !max-w-none p-0 flex flex-col"
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { GeistdocsProvider } from "@vercel/geistdocs/layout";
|
||||
import type { ReactNode } from "react";
|
||||
import { config } from "@/lib/geistdocs/config";
|
||||
|
||||
export function DocsProvider({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<GeistdocsProvider config={config} lang="en">
|
||||
{children}
|
||||
</GeistdocsProvider>
|
||||
);
|
||||
}
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
import { CodeBlock } from "./code-block";
|
||||
import { CopyButton } from "./copy-button";
|
||||
import { Toaster } from "./ui/sonner";
|
||||
import { Header } from "./header";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -346,6 +345,11 @@ export function Playground() {
|
||||
const mobileInputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const versionsEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const input = inputRef.current;
|
||||
if (input?.getClientRects().length) input.focus({ preventScroll: true });
|
||||
}, []);
|
||||
|
||||
// Track the currently generating version ID
|
||||
const generatingVersionIdRef = useRef<string | null>(null);
|
||||
|
||||
@@ -400,7 +404,8 @@ export function Playground() {
|
||||
|
||||
// Scroll to bottom when versions change
|
||||
useEffect(() => {
|
||||
versionsEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
const container = versionsEndRef.current?.parentElement;
|
||||
container?.scrollTo({ top: container.scrollHeight, behavior: "smooth" });
|
||||
}, [versions]);
|
||||
|
||||
// Update version when streaming completes
|
||||
@@ -722,7 +727,6 @@ ${jsx}
|
||||
maxLength={model === "typesafe-ai/jev" ? 1000 : undefined}
|
||||
className="w-full bg-background text-base sm:text-sm resize-none outline-none placeholder:text-muted-foreground/50"
|
||||
rows={2}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex justify-between items-end gap-2 mt-2">
|
||||
<PlaygroundControls
|
||||
@@ -1067,8 +1071,6 @@ ${jsx}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<Header />
|
||||
|
||||
{/* Desktop: 3-pane resizable layout */}
|
||||
<div className="hidden lg:flex flex-1 min-h-0">
|
||||
<ResizablePanelGroup className="flex-1">
|
||||
|
||||
@@ -6,6 +6,7 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<NextThemesProvider
|
||||
attribute="class"
|
||||
value={{ dark: "dark-theme", light: "light-theme" }}
|
||||
defaultTheme="dark"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/a2ui")
|
||||
|
||||
# A2UI Integration
|
||||
---
|
||||
title: "A2UI Integration"
|
||||
---
|
||||
|
||||
Use `@json-render/core` to support [A2UI](https://a2ui.org) natively.
|
||||
|
||||
+3
-4
@@ -1,7 +1,6 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/adaptive-cards")
|
||||
|
||||
# Adaptive Cards Integration
|
||||
---
|
||||
title: "Adaptive Cards Integration"
|
||||
---
|
||||
|
||||
Use json-render to render [Microsoft Adaptive Cards](https://adaptivecards.io) natively.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/ag-ui")
|
||||
|
||||
# AG-UI Integration
|
||||
---
|
||||
title: "AG-UI Integration"
|
||||
---
|
||||
|
||||
Use json-render to support [AG-UI](https://docs.copilotkit.ai/ag-ui) (Agent User Interaction Protocol) from CopilotKit.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/ai-sdk")
|
||||
|
||||
# AI SDK Integration
|
||||
---
|
||||
title: "AI SDK Integration"
|
||||
---
|
||||
|
||||
Use json-render with the [Vercel AI SDK](https://sdk.vercel.ai) for seamless streaming. json-render supports two modes: **Standalone** (standalone UI) and **Inline** (UI embedded in conversation). See [Generation Modes](/docs/generation-modes) for a detailed comparison.
|
||||
|
||||
+3
-4
@@ -1,7 +1,6 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/api/codegen")
|
||||
|
||||
# @json-render/codegen
|
||||
---
|
||||
title: "@json-render/codegen"
|
||||
---
|
||||
|
||||
Utilities for generating code from UI trees.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/api/core")
|
||||
|
||||
# @json-render/core
|
||||
---
|
||||
title: "@json-render/core"
|
||||
---
|
||||
|
||||
Core types, schemas, and utilities.
|
||||
|
||||
+3
-4
@@ -1,7 +1,6 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/api/devtools-react")
|
||||
|
||||
# @json-render/devtools-react
|
||||
---
|
||||
title: "@json-render/devtools-react"
|
||||
---
|
||||
|
||||
React adapter for the json-render devtools. Drop-in `<JsonRenderDevtools />` component.
|
||||
|
||||
+3
-4
@@ -1,7 +1,6 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/api/devtools-solid")
|
||||
|
||||
# @json-render/devtools-solid
|
||||
---
|
||||
title: "@json-render/devtools-solid"
|
||||
---
|
||||
|
||||
SolidJS adapter for the json-render devtools. Drop-in `<JsonRenderDevtools />` component.
|
||||
|
||||
+3
-4
@@ -1,7 +1,6 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/api/devtools-svelte")
|
||||
|
||||
# @json-render/devtools-svelte
|
||||
---
|
||||
title: "@json-render/devtools-svelte"
|
||||
---
|
||||
|
||||
Svelte adapter for the json-render devtools. Drop-in `<JsonRenderDevtools />` component.
|
||||
|
||||
+3
-4
@@ -1,7 +1,6 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/api/devtools-vue")
|
||||
|
||||
# @json-render/devtools-vue
|
||||
---
|
||||
title: "@json-render/devtools-vue"
|
||||
---
|
||||
|
||||
Vue adapter for the json-render devtools. Drop-in `<JsonRenderDevtools />` component.
|
||||
|
||||
+3
-4
@@ -1,7 +1,6 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/api/devtools")
|
||||
|
||||
# @json-render/devtools
|
||||
---
|
||||
title: "@json-render/devtools"
|
||||
---
|
||||
|
||||
Framework-agnostic core for the json-render devtools — vanilla TS panel UI, event store, DOM picker, and stream tap utilities. Every framework-specific adapter package depends on this.
|
||||
|
||||
+3
-4
@@ -1,7 +1,6 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/api/directives")
|
||||
|
||||
# @json-render/directives
|
||||
---
|
||||
title: "@json-render/directives"
|
||||
---
|
||||
|
||||
Pre-built custom directives for `@json-render/core`. Drop them into your catalog and renderer to add formatting, math, string manipulation, and i18n.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/api/image")
|
||||
|
||||
# @json-render/image
|
||||
---
|
||||
title: "@json-render/image"
|
||||
---
|
||||
|
||||
Image renderer. Turn JSON specs into SVG and PNG images using [Satori](https://github.com/vercel/satori).
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/api/ink")
|
||||
|
||||
# @json-render/ink
|
||||
---
|
||||
title: "@json-render/ink"
|
||||
---
|
||||
|
||||
Terminal renderer for [Ink](https://github.com/vadimdemedes/ink) with multiple standard components, providers, hooks, and streaming support.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/api/jotai")
|
||||
|
||||
# @json-render/jotai
|
||||
---
|
||||
title: "@json-render/jotai"
|
||||
---
|
||||
|
||||
Jotai adapter for json-render's `StateStore` interface.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/api/mcp")
|
||||
|
||||
# @json-render/mcp
|
||||
---
|
||||
title: "@json-render/mcp"
|
||||
---
|
||||
|
||||
MCP Apps integration for json-render. Serve json-render UIs as interactive [MCP Apps](https://modelcontextprotocol.io/docs/extensions/apps) inside Claude, ChatGPT, Cursor, VS Code, and other MCP-capable clients.
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"title": "API Reference",
|
||||
"pages": [
|
||||
"core",
|
||||
"react",
|
||||
"next",
|
||||
"tanstack-start",
|
||||
"react-pdf",
|
||||
"react-email",
|
||||
"shadcn",
|
||||
"shadcn-svelte",
|
||||
"react-native",
|
||||
"image",
|
||||
"remotion",
|
||||
"ink",
|
||||
"vue",
|
||||
"svelte",
|
||||
"solid",
|
||||
"react-three-fiber",
|
||||
"directives",
|
||||
"codegen",
|
||||
"devtools",
|
||||
"devtools-react",
|
||||
"devtools-vue",
|
||||
"devtools-svelte",
|
||||
"devtools-solid",
|
||||
"mcp",
|
||||
"redux",
|
||||
"zustand",
|
||||
"jotai",
|
||||
"xstate",
|
||||
"yaml"
|
||||
]
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/api/next")
|
||||
|
||||
# @json-render/next
|
||||
---
|
||||
title: "@json-render/next"
|
||||
---
|
||||
|
||||
Next.js renderer. JSON becomes full Next.js applications with routes, layouts, metadata, and SSR.
|
||||
|
||||
+3
-4
@@ -1,7 +1,6 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/api/react-email")
|
||||
|
||||
# @json-render/react-email
|
||||
---
|
||||
title: "@json-render/react-email"
|
||||
---
|
||||
|
||||
React Email renderer. Turn JSON specs into HTML or plain-text emails using `@react-email/components` and `@react-email/render`.
|
||||
|
||||
+3
-4
@@ -1,7 +1,6 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/api/react-native")
|
||||
|
||||
# @json-render/react-native
|
||||
---
|
||||
title: "@json-render/react-native"
|
||||
---
|
||||
|
||||
React Native renderer with standard components, providers, and hooks.
|
||||
|
||||
+3
-4
@@ -1,7 +1,6 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/api/react-pdf")
|
||||
|
||||
# @json-render/react-pdf
|
||||
---
|
||||
title: "@json-render/react-pdf"
|
||||
---
|
||||
|
||||
PDF document renderer. Turn JSON specs into PDFs using `@react-pdf/renderer`.
|
||||
|
||||
+3
-4
@@ -1,7 +1,6 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/api/react-three-fiber")
|
||||
|
||||
# @json-render/react-three-fiber
|
||||
---
|
||||
title: "@json-render/react-three-fiber"
|
||||
---
|
||||
|
||||
React Three Fiber renderer for json-render. 20 built-in 3D components for meshes, lights, models, gaussian splats, environments, text, cameras, and controls.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/api/react")
|
||||
|
||||
# @json-render/react
|
||||
---
|
||||
title: "@json-render/react"
|
||||
---
|
||||
|
||||
React components, providers, and hooks.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/api/redux")
|
||||
|
||||
# @json-render/redux
|
||||
---
|
||||
title: "@json-render/redux"
|
||||
---
|
||||
|
||||
Redux / Redux Toolkit adapter for json-render's `StateStore` interface.
|
||||
|
||||
+3
-4
@@ -1,7 +1,6 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/api/remotion")
|
||||
|
||||
# @json-render/remotion
|
||||
---
|
||||
title: "@json-render/remotion"
|
||||
---
|
||||
|
||||
Remotion video renderer. Turn JSON timeline specs into video compositions.
|
||||
|
||||
+3
-4
@@ -1,7 +1,6 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/api/shadcn-svelte")
|
||||
|
||||
# @json-render/shadcn-svelte
|
||||
---
|
||||
title: "@json-render/shadcn-svelte"
|
||||
---
|
||||
|
||||
Pre-built [shadcn-svelte](https://www.shadcn-svelte.com/) components for json-render. 36 components built on Svelte 5 + Tailwind CSS, ready to use with `defineCatalog` and `defineRegistry`.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/api/shadcn")
|
||||
|
||||
# @json-render/shadcn
|
||||
---
|
||||
title: "@json-render/shadcn"
|
||||
---
|
||||
|
||||
Pre-built [shadcn/ui](https://ui.shadcn.com/) components for json-render. 36 components built on Radix UI + Tailwind CSS, ready to use with `defineCatalog` and `defineRegistry`.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata";
|
||||
export const metadata = pageMetadata("docs/api/solid");
|
||||
|
||||
# @json-render/solid
|
||||
---
|
||||
title: "@json-render/solid"
|
||||
---
|
||||
|
||||
SolidJS components, providers, and hooks for rendering json-render specs.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/api/svelte")
|
||||
|
||||
# @json-render/svelte
|
||||
---
|
||||
title: "@json-render/svelte"
|
||||
---
|
||||
|
||||
Svelte 5 components, providers, and helpers for rendering json-render specs.
|
||||
|
||||
+3
-5
@@ -1,8 +1,6 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata";
|
||||
|
||||
export const metadata = pageMetadata("docs/api/tanstack-start");
|
||||
|
||||
# @json-render/tanstack-start
|
||||
---
|
||||
title: "@json-render/tanstack-start"
|
||||
---
|
||||
|
||||
TanStack Start renderer for JSON-defined applications with routes, layouts,
|
||||
head metadata, SSR loaders, prerender paths, and client navigation.
|
||||
@@ -1,7 +1,6 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/api/vue")
|
||||
|
||||
# @json-render/vue
|
||||
---
|
||||
title: "@json-render/vue"
|
||||
---
|
||||
|
||||
Vue 3 components, providers, and composables.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/api/xstate")
|
||||
|
||||
# @json-render/xstate
|
||||
---
|
||||
title: "@json-render/xstate"
|
||||
---
|
||||
|
||||
[XState Store](https://stately.ai/docs/xstate-store) adapter for json-render's `StateStore` interface.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/api/yaml")
|
||||
|
||||
# @json-render/yaml
|
||||
---
|
||||
title: "@json-render/yaml"
|
||||
---
|
||||
|
||||
YAML wire format for json-render. Progressive rendering and surgical edits via streaming YAML.
|
||||
|
||||
+3
-4
@@ -1,7 +1,6 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/api/zustand")
|
||||
|
||||
# @json-render/zustand
|
||||
---
|
||||
title: "@json-render/zustand"
|
||||
---
|
||||
|
||||
Zustand adapter for json-render's `StateStore` interface.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata";
|
||||
export const metadata = pageMetadata("docs/catalog");
|
||||
|
||||
# Catalog
|
||||
---
|
||||
title: "Catalog"
|
||||
---
|
||||
|
||||
The catalog defines what AI can generate. It's your guardrail.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/changelog")
|
||||
|
||||
# Changelog
|
||||
---
|
||||
title: "Changelog"
|
||||
---
|
||||
|
||||
Notable changes and updates to json-render.
|
||||
|
||||
+3
-4
@@ -1,7 +1,6 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/code-export")
|
||||
|
||||
# Code Export
|
||||
---
|
||||
title: "Code Export"
|
||||
---
|
||||
|
||||
Export generated UI as standalone code for your framework.
|
||||
|
||||
+3
-4
@@ -1,7 +1,6 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/computed-values")
|
||||
|
||||
# Computed Values
|
||||
---
|
||||
title: "Computed Values"
|
||||
---
|
||||
|
||||
Derive dynamic prop values using registered functions or string templates.
|
||||
|
||||
+3
-4
@@ -1,7 +1,6 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/custom-schema")
|
||||
|
||||
# Custom Schema & Renderer
|
||||
---
|
||||
title: "Custom Schema & Renderer"
|
||||
---
|
||||
|
||||
Build your own schema and renderer with `@json-render/core`.
|
||||
|
||||
+3
-4
@@ -1,7 +1,6 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/data-binding")
|
||||
|
||||
# Data Binding
|
||||
---
|
||||
title: "Data Binding"
|
||||
---
|
||||
|
||||
Connect UI elements to dynamic data using expressions in your JSON specs.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/devtools")
|
||||
|
||||
# Devtools
|
||||
---
|
||||
title: "Devtools"
|
||||
---
|
||||
|
||||
A drop-in inspector panel for any json-render app. See the spec tree, edit state inline, watch dispatched actions, follow stream patches live, browse your catalog, and pick DOM elements to map them back to spec keys.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/directives")
|
||||
|
||||
# Directives
|
||||
---
|
||||
title: "Directives"
|
||||
---
|
||||
|
||||
Extend the spec language with custom `$`-prefixed dynamic values. Directives let you add formatting, math, string manipulation, i18n, and any other transformation without modifying core.
|
||||
|
||||
+3
-4
@@ -1,7 +1,6 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/generation-modes")
|
||||
|
||||
# Generation Modes
|
||||
---
|
||||
title: "Generation Modes"
|
||||
---
|
||||
|
||||
json-render supports two modes for AI-generated UI: **Standalone mode** for standalone UI and **Inline mode** for inline UI within a conversation.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs")
|
||||
|
||||
# Introduction
|
||||
---
|
||||
title: "Introduction"
|
||||
---
|
||||
|
||||
json-render is a framework for **Generative UI** — AI-generated interfaces that are safe, predictable, and render natively on any platform.
|
||||
|
||||
+3
-4
@@ -1,7 +1,6 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/installation")
|
||||
|
||||
# Installation
|
||||
---
|
||||
title: "Installation"
|
||||
---
|
||||
|
||||
Install the core package plus your renderer of choice.
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/jev")
|
||||
|
||||
# Jev (Experimental)
|
||||
|
||||
---
|
||||
title: "Jev (Experimental)"
|
||||
---
|
||||
**Experimental:** `experimental_composeSpec` and `experimental_createEvaluator` are reusable APIs in `@json-render/core`. Like AI SDK's experimental APIs, names prefixed with `experimental_` or `Experimental_` may change in any release. Pin exact package versions (no `^` or `~`) and review release notes before upgrading.
|
||||
|
||||
**Availability:** these APIs are unreleased. You can try the source build below before they appear in a published npm version.
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"title": "Documentation",
|
||||
"pages": [
|
||||
"---Getting Started---",
|
||||
"index",
|
||||
"installation",
|
||||
"quick-start",
|
||||
"skills",
|
||||
"migration",
|
||||
"changelog",
|
||||
"---Core---",
|
||||
"specs",
|
||||
"schemas",
|
||||
"catalog",
|
||||
"data-binding",
|
||||
"computed-values",
|
||||
"visibility",
|
||||
"watchers",
|
||||
"validation",
|
||||
"directives",
|
||||
"---Rendering---",
|
||||
"renderers",
|
||||
"registry",
|
||||
"streaming",
|
||||
"generation-modes",
|
||||
"---Examples---",
|
||||
"[Browse All Examples](/examples)",
|
||||
"---Guides---",
|
||||
"custom-schema",
|
||||
"code-export",
|
||||
"devtools",
|
||||
"---Experimental---",
|
||||
"jev",
|
||||
"---Integrations---",
|
||||
"ai-sdk",
|
||||
"a2ui",
|
||||
"adaptive-cards",
|
||||
"ag-ui",
|
||||
"openapi",
|
||||
"api"
|
||||
]
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/migration")
|
||||
|
||||
# Migration Guide
|
||||
---
|
||||
title: "Migration Guide"
|
||||
---
|
||||
|
||||
This guide covers breaking changes introduced in v0.6.0 and how to update your code.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/openapi")
|
||||
|
||||
# OpenAPI Integration
|
||||
---
|
||||
title: "OpenAPI Integration"
|
||||
---
|
||||
|
||||
Use json-render to generate dynamic forms and UIs from [OpenAPI/Swagger](https://swagger.io/specification/) schemas.
|
||||
|
||||
+3
-4
@@ -1,7 +1,6 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/quick-start")
|
||||
|
||||
# Quick Start
|
||||
---
|
||||
title: "Quick Start"
|
||||
---
|
||||
|
||||
Get up and running with json-render in 5 minutes.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata";
|
||||
export const metadata = pageMetadata("docs/registry");
|
||||
|
||||
# Registry
|
||||
---
|
||||
title: "Registry"
|
||||
---
|
||||
|
||||
A registry maps your [catalog](/docs/catalog) definitions to platform-specific implementations. The catalog defines _what_ AI can generate; the registry provides the _how_.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata";
|
||||
export const metadata = pageMetadata("docs/renderers");
|
||||
|
||||
# Renderers
|
||||
---
|
||||
title: "Renderers"
|
||||
---
|
||||
|
||||
json-render supports multiple output targets. Each renderer takes the same core concept -- a JSON spec constrained to a catalog -- and renders it natively on a different platform or into a different format.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/schemas")
|
||||
|
||||
# Schemas
|
||||
---
|
||||
title: "Schemas"
|
||||
---
|
||||
|
||||
Schemas define the structure and validation rules for your UI specs.
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata";
|
||||
|
||||
export const metadata = pageMetadata("docs/skills");
|
||||
|
||||
# Skills
|
||||
---
|
||||
title: "Skills"
|
||||
---
|
||||
|
||||
json-render ships with skills that teach AI coding agents how to use each package. Install a skill and your agent in Cursor, Claude Code, or Codex can generate json-render UIs without manual guidance.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata";
|
||||
export const metadata = pageMetadata("docs/specs");
|
||||
|
||||
# Specs
|
||||
---
|
||||
title: "Specs"
|
||||
---
|
||||
|
||||
A spec is a JSON document that describes your UI.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/streaming")
|
||||
|
||||
# Streaming
|
||||
---
|
||||
title: "Streaming"
|
||||
---
|
||||
|
||||
Progressively render UI as AI generates it.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/validation")
|
||||
|
||||
# Validation
|
||||
---
|
||||
title: "Validation"
|
||||
---
|
||||
|
||||
Validate form inputs with built-in and custom functions.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/visibility")
|
||||
|
||||
# Visibility
|
||||
---
|
||||
title: "Visibility"
|
||||
---
|
||||
|
||||
Conditionally show or hide components based on state values and logic.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/watchers")
|
||||
|
||||
# Watchers
|
||||
---
|
||||
title: "Watchers"
|
||||
---
|
||||
|
||||
React to state changes by triggering actions when watched paths update.
|
||||
|
||||
@@ -2,6 +2,7 @@ import { nextJsConfig } from "@internal/eslint-config/next-js";
|
||||
|
||||
/** @type {import("eslint").Linter.Config[]} */
|
||||
export default [
|
||||
{ ignores: [".source/**"] },
|
||||
...nextJsConfig,
|
||||
{
|
||||
rules: {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
const negotiationHeaders = [
|
||||
"Accept",
|
||||
"User-Agent",
|
||||
"Signature-Agent",
|
||||
"Sec-Fetch-Mode",
|
||||
"Sec-Fetch-Dest",
|
||||
"RSC",
|
||||
"Next-Router-Prefetch",
|
||||
"Next-Router-Segment-Prefetch",
|
||||
"Purpose",
|
||||
"Sec-Purpose",
|
||||
];
|
||||
|
||||
export function applyDocsResponseHeaders(headers: Headers) {
|
||||
const tokens = new Map<string, string>();
|
||||
for (const token of [
|
||||
...(headers.get("Vary") ?? "").split(/\s*,\s*/),
|
||||
...negotiationHeaders,
|
||||
]) {
|
||||
if (token) tokens.set(token.toLowerCase(), token);
|
||||
}
|
||||
headers.set("Vary", [...tokens.values()].join(", "));
|
||||
headers.set("Cache-Control", "private, no-store");
|
||||
headers.set("CDN-Cache-Control", "no-store");
|
||||
headers.set("Vercel-CDN-Cache-Control", "no-store");
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { parse } from "yaml";
|
||||
import { allDocsPages } from "./docs-navigation";
|
||||
import { mdxToCleanMarkdown } from "./mdx-to-markdown";
|
||||
import { siteUrl } from "./site";
|
||||
|
||||
export const docsPages = allDocsPages.filter(
|
||||
(page) => page.href === "/docs" || page.href.startsWith("/docs/"),
|
||||
);
|
||||
const inventory = new Map(docsPages.map((page) => [page.href, page]));
|
||||
const pending = new Map<string, Promise<DocsSource>>();
|
||||
|
||||
export type DocsSource = {
|
||||
href: string;
|
||||
title: string;
|
||||
markdown: string;
|
||||
markdownUrl: string;
|
||||
canonicalUrl: string;
|
||||
};
|
||||
|
||||
export function isSafePathSegments(segments: readonly string[]) {
|
||||
return segments.every(
|
||||
(part) =>
|
||||
part.length > 0 &&
|
||||
part !== "." &&
|
||||
part !== ".." &&
|
||||
!part.includes("/") &&
|
||||
!part.includes("\\"),
|
||||
);
|
||||
}
|
||||
|
||||
export function loadDocsSource(pathname: string): Promise<DocsSource> | null {
|
||||
const href = pathname.endsWith("/") ? pathname.slice(0, -1) : pathname;
|
||||
if (!inventory.has(href)) return null;
|
||||
let result = pending.get(href);
|
||||
if (!result) {
|
||||
const slug = href === "/docs" ? "index" : href.slice("/docs/".length);
|
||||
result = readFile(
|
||||
join(process.cwd(), "content", "docs", `${slug}.mdx`),
|
||||
"utf8",
|
||||
).then((raw) => {
|
||||
const frontmatter = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/);
|
||||
if (!frontmatter) throw new Error(`Missing frontmatter for ${href}`);
|
||||
const metadata = parse(frontmatter[1] ?? "") as {
|
||||
title?: unknown;
|
||||
} | null;
|
||||
if (typeof metadata?.title !== "string")
|
||||
throw new Error(`Missing title for ${href}`);
|
||||
const title = metadata.title;
|
||||
return {
|
||||
href,
|
||||
title,
|
||||
markdown: mdxToCleanMarkdown(
|
||||
`# ${title}\n${raw.slice(frontmatter[0].length)}`,
|
||||
),
|
||||
markdownUrl: `${href}.md`,
|
||||
canonicalUrl: `${siteUrl}${href}`,
|
||||
};
|
||||
});
|
||||
pending.set(href, result);
|
||||
result.catch(() => pending.delete(href));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function loadAllDocsSources() {
|
||||
return Promise.all(docsPages.map((page) => loadDocsSource(page.href)!));
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { defineConfig } from "@vercel/geistdocs/config";
|
||||
import { siteUrl } from "@/lib/site";
|
||||
|
||||
export const config = defineConfig({
|
||||
title: "json-render",
|
||||
siteUrl,
|
||||
defaultLanguage: "en",
|
||||
logo: <span className="font-medium">json-render</span>,
|
||||
navbarActiveProduct: "json-render",
|
||||
navbarBrand: "labs",
|
||||
github: {
|
||||
owner: "vercel-labs",
|
||||
repo: "json-render",
|
||||
branch: "main",
|
||||
editPath: "apps/web/content/docs",
|
||||
},
|
||||
content: [
|
||||
{ id: "docs", label: "Documentation", dir: "content/docs", route: "/docs" },
|
||||
],
|
||||
nav: [
|
||||
{ label: "Docs", href: "/docs" },
|
||||
{ label: "Playground", href: "/playground" },
|
||||
{ label: "Examples", href: "/examples" },
|
||||
],
|
||||
ai: { enabled: false },
|
||||
feedback: { enabled: false },
|
||||
language: { enabled: false },
|
||||
pageActions: { askAI: false, openInChat: false },
|
||||
webmcp: { enabled: true },
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import {
|
||||
createSource,
|
||||
type GeistdocsSourceBundle,
|
||||
} from "@vercel/geistdocs/source";
|
||||
import { docs } from "@/.source/server";
|
||||
import { loadDocsSource } from "@/lib/docs-source";
|
||||
import { config } from "./config";
|
||||
|
||||
const bundle = createSource({ docs, config, baseUrl: "/docs" });
|
||||
|
||||
export const geistdocsSource: GeistdocsSourceBundle = {
|
||||
...bundle,
|
||||
async getPageMarkdown(page) {
|
||||
const source = await loadDocsSource(
|
||||
`/docs${page.slugs.length ? `/${page.slugs.join("/")}` : ""}`,
|
||||
);
|
||||
if (!source) throw new Error(`Documentation source not found: ${page.url}`);
|
||||
return source.markdown;
|
||||
},
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import { PAGE_TITLES } from "./page-titles";
|
||||
import { isPreview, siteUrl } from "./site";
|
||||
|
||||
const DESCRIPTION =
|
||||
"The Generative UI framework. Generate dashboards, widgets, and apps from prompts — safely constrained to components you define.";
|
||||
@@ -14,7 +15,16 @@ export function pageMetadata(slug: string): Metadata {
|
||||
|
||||
return {
|
||||
title: displayTitle,
|
||||
description: DESCRIPTION,
|
||||
robots: { index: !isPreview, follow: !isPreview },
|
||||
alternates: {
|
||||
canonical: `/${slug}`,
|
||||
...(slug === "docs" || slug.startsWith("docs/")
|
||||
? { types: { "text/markdown": `/${slug}.md` } }
|
||||
: {}),
|
||||
},
|
||||
openGraph: {
|
||||
url: `${siteUrl}/${slug}`,
|
||||
type: "website",
|
||||
locale: "en_US",
|
||||
siteName: "json-render",
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
type MarkdownNode = {
|
||||
type: string;
|
||||
depth?: number;
|
||||
value?: string;
|
||||
children?: MarkdownNode[];
|
||||
data?: { hProperties?: Record<string, unknown> };
|
||||
};
|
||||
|
||||
function text(node: MarkdownNode): string {
|
||||
return node.children ? node.children.map(text).join("") : (node.value ?? "");
|
||||
}
|
||||
|
||||
export function remarkLegacyHeadings() {
|
||||
return function walk(node: MarkdownNode) {
|
||||
if (node.type === "heading" && (node.depth === 2 || node.depth === 3)) {
|
||||
node.data ??= {};
|
||||
node.data.hProperties ??= {};
|
||||
node.data.hProperties.id = text(node)
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\s-]/g, "")
|
||||
.replace(/\s+/g, "-")
|
||||
.trim();
|
||||
}
|
||||
node.children?.forEach(walk);
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
import { readFile } from "fs/promises";
|
||||
import { join } from "path";
|
||||
import { docsNavigation } from "./docs-navigation";
|
||||
import { mdxToCleanMarkdown } from "./mdx-to-markdown";
|
||||
import { loadDocsSource } from "./docs-source";
|
||||
|
||||
export type IndexEntry = {
|
||||
title: string;
|
||||
@@ -33,15 +31,6 @@ function stripMarkdown(md: string): string {
|
||||
);
|
||||
}
|
||||
|
||||
function mdxFileForSlug(slug: string): string {
|
||||
const docsRoot = join(process.cwd(), "app", "(main)", "docs");
|
||||
if (slug === "/docs") {
|
||||
return join(docsRoot, "page.mdx");
|
||||
}
|
||||
const rest = slug.replace(/^\/docs\/?/, "");
|
||||
return join(docsRoot, ...rest.split("/"), "page.mdx");
|
||||
}
|
||||
|
||||
export async function getSearchIndex(): Promise<IndexEntry[]> {
|
||||
if (cached) return cached;
|
||||
|
||||
@@ -51,9 +40,8 @@ export async function getSearchIndex(): Promise<IndexEntry[]> {
|
||||
for (const item of section.items) {
|
||||
if (item.external) continue;
|
||||
try {
|
||||
const raw = await readFile(mdxFileForSlug(item.href), "utf-8");
|
||||
const md = mdxToCleanMarkdown(raw);
|
||||
const content = stripMarkdown(md);
|
||||
const source = await loadDocsSource(item.href);
|
||||
const content = stripMarkdown(source?.markdown ?? "");
|
||||
entries.push({
|
||||
title: item.title,
|
||||
href: item.href,
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export const siteUrl = "https://json-render.dev";
|
||||
export const siteDescription =
|
||||
"The Generative UI framework. Generate dashboards, widgets, and apps from prompts — safely constrained to components you define.";
|
||||
export const isPreview = Boolean(
|
||||
process.env.VERCEL_ENV && process.env.VERCEL_ENV !== "production",
|
||||
);
|
||||
+24
-2
@@ -1,9 +1,31 @@
|
||||
import createMDX from "@next/mdx";
|
||||
import process from "node:process";
|
||||
import { createGeistdocs } from "@vercel/geistdocs/next";
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
serverExternalPackages: ["bash-tool", "just-bash", "@mongodb-js/zstd"],
|
||||
pageExtensions: ["js", "jsx", "ts", "tsx", "md", "mdx"],
|
||||
skipProxyUrlNormalize: true,
|
||||
outputFileTracingIncludes: { "/*": ["./content/docs/**/*.mdx"] },
|
||||
async headers() {
|
||||
return process.env.VERCEL_ENV && process.env.VERCEL_ENV !== "production"
|
||||
? [
|
||||
{
|
||||
source: "/:path*",
|
||||
headers: [{ key: "X-Robots-Tag", value: "noindex, nofollow" }],
|
||||
},
|
||||
]
|
||||
: [];
|
||||
},
|
||||
async rewrites() {
|
||||
return {
|
||||
beforeFiles: [
|
||||
{ source: "/docs.md", destination: "/api/docs-md" },
|
||||
{ source: "/docs/index.md", destination: "/api/docs-md" },
|
||||
{ source: "/docs/:path*.md", destination: "/api/docs-md/:path*" },
|
||||
],
|
||||
};
|
||||
},
|
||||
async redirects() {
|
||||
return [
|
||||
{
|
||||
@@ -20,7 +42,7 @@ const nextConfig = {
|
||||
},
|
||||
};
|
||||
|
||||
const withMDX = createMDX({});
|
||||
const withMDX = createGeistdocs();
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const config = withMDX(nextConfig);
|
||||
|
||||
@@ -7,10 +7,13 @@
|
||||
"scripts": {
|
||||
"predev": "command -v portless >/dev/null 2>&1 || (echo '\\nportless is required but not installed. Run: npm i -g portless\\nSee: https://github.com/vercel-labs/portless\\n' && exit 1)",
|
||||
"dev": "portless json-render next dev --turbopack",
|
||||
"postinstall": "fumadocs-mdx",
|
||||
"prebuild": "fumadocs-mdx",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"test:routes": "node scripts/test-routes.mjs",
|
||||
"lint": "eslint --max-warnings 0",
|
||||
"check-types": "next typegen && tsc --noEmit"
|
||||
"check-types": "fumadocs-mdx && next typegen && tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/gateway": "^3.0.13",
|
||||
@@ -21,16 +24,14 @@
|
||||
"@json-render/devtools-react": "workspace:*",
|
||||
"@json-render/react": "workspace:*",
|
||||
"@json-render/yaml": "workspace:*",
|
||||
"@mdx-js/loader": "^3.1.1",
|
||||
"@mdx-js/mdx": "^3.1.1",
|
||||
"@mdx-js/react": "^3.1.1",
|
||||
"@next/mdx": "^16.1.6",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@upstash/ratelimit": "^2.0.8",
|
||||
"@upstash/redis": "^1.36.1",
|
||||
"@vercel/analytics": "^1.6.1",
|
||||
"@vercel/geistdocs": "2.4.1",
|
||||
"@vercel/speed-insights": "^1.3.1",
|
||||
"@visual-json/react": "0.1.1",
|
||||
"ai": "^6.0.33",
|
||||
@@ -39,9 +40,11 @@
|
||||
"clsx": "^2.1.1",
|
||||
"diff": "^8.0.3",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"fumadocs-core": "16.2.2",
|
||||
"fumadocs-mdx": "14.0.4",
|
||||
"geist": "1.7.0",
|
||||
"lucide-react": "^0.562.0",
|
||||
"next": "16.1.1",
|
||||
"next": "16.3.4",
|
||||
"next-themes": "^0.4.6",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.2.3",
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { createProxy } from "@vercel/geistdocs/proxy";
|
||||
import { createI18nMiddleware } from "fumadocs-core/i18n/middleware";
|
||||
import {
|
||||
NextResponse,
|
||||
type NextRequest,
|
||||
type NextFetchEvent,
|
||||
} from "next/server";
|
||||
import { config as geistdocsConfig } from "@/lib/geistdocs/config";
|
||||
import { applyDocsResponseHeaders } from "@/lib/docs-response-headers";
|
||||
|
||||
const localeProxy = createI18nMiddleware({
|
||||
defaultLanguage: "en",
|
||||
languages: ["en"],
|
||||
hideLocale: "default-locale",
|
||||
});
|
||||
const geistdocsProxy = createProxy({
|
||||
config: geistdocsConfig,
|
||||
markdownRoutes: [{ from: "/docs/*path", to: "/api/docs-md/*path" }],
|
||||
before: async ({ request, context }) => {
|
||||
if (
|
||||
request.headers.get("rsc") === "1" ||
|
||||
request.headers.has("next-router-prefetch") ||
|
||||
request.headers.has("next-router-segment-prefetch") ||
|
||||
/\bprefetch\b/i.test(request.headers.get("purpose") ?? "") ||
|
||||
/\bprefetch\b/i.test(request.headers.get("sec-purpose") ?? "")
|
||||
) {
|
||||
return (await localeProxy(request, context)) ?? NextResponse.next();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export default async function proxy(
|
||||
request: NextRequest,
|
||||
event: NextFetchEvent,
|
||||
) {
|
||||
const { pathname } = request.nextUrl;
|
||||
try {
|
||||
decodeURIComponent(pathname);
|
||||
} catch (error) {
|
||||
if (!(error instanceof URIError)) throw error;
|
||||
return new NextResponse("Not Found", { status: 404 });
|
||||
}
|
||||
let response: Response;
|
||||
if (pathname === "/en/docs" || pathname.startsWith("/en/docs/")) {
|
||||
const destination = request.nextUrl.clone();
|
||||
destination.pathname = pathname.slice(3);
|
||||
response = NextResponse.redirect(destination, 308);
|
||||
} else if (
|
||||
(pathname === "/docs" || pathname.startsWith("/docs/")) &&
|
||||
!pathname.includes(".")
|
||||
) {
|
||||
response = await geistdocsProxy(request, event);
|
||||
} else {
|
||||
return NextResponse.next();
|
||||
}
|
||||
applyDocsResponseHeaders(response.headers);
|
||||
return response;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/docs/:path*", "/en/docs/:path*", "/(.*%.*)"],
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
import process from "node:process";
|
||||
import { spawn } from "node:child_process";
|
||||
import { createServer } from "node:net";
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const cwd = fileURLToPath(new URL("../", import.meta.url));
|
||||
const socket = createServer();
|
||||
await new Promise((resolve, reject) => {
|
||||
socket.once("error", reject);
|
||||
socket.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
const port = socket.address().port;
|
||||
await new Promise((resolve, reject) =>
|
||||
socket.close((error) => (error ? reject(error) : resolve())),
|
||||
);
|
||||
|
||||
function launch(args, env) {
|
||||
const child = spawn(process.execPath, args, { cwd, stdio: "inherit", env });
|
||||
const result = { child, done: false, exited: undefined };
|
||||
result.exited = new Promise((resolve) => {
|
||||
child.once("error", (error) => {
|
||||
console.error(error.message);
|
||||
result.done = true;
|
||||
resolve(1);
|
||||
});
|
||||
child.once("exit", (code) => {
|
||||
result.done = true;
|
||||
resolve(code ?? 1);
|
||||
});
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
const server = launch(
|
||||
[
|
||||
fileURLToPath(import.meta.resolve("next/dist/bin/next")),
|
||||
"start",
|
||||
"--hostname",
|
||||
"127.0.0.1",
|
||||
],
|
||||
{ ...process.env, NODE_ENV: "production", PORT: String(port) },
|
||||
);
|
||||
const url = `http://127.0.0.1:${port}`;
|
||||
let tests;
|
||||
let shutdown;
|
||||
let stopping = false;
|
||||
const stop = () => {
|
||||
stopping = true;
|
||||
shutdown ??= Promise.all(
|
||||
[tests, server].map(async (owned) => {
|
||||
if (!owned || owned.done) return;
|
||||
owned.child.kill("SIGTERM");
|
||||
const deadline = setTimeout(() => owned.child.kill("SIGKILL"), 5000);
|
||||
try {
|
||||
await owned.exited;
|
||||
} finally {
|
||||
clearTimeout(deadline);
|
||||
}
|
||||
}),
|
||||
);
|
||||
return shutdown;
|
||||
};
|
||||
for (const [signal, code] of [
|
||||
["SIGINT", 130],
|
||||
["SIGTERM", 143],
|
||||
]) {
|
||||
process.once(signal, () => {
|
||||
void stop().finally(() => process.exit(code));
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const deadline = Date.now() + 60000;
|
||||
let ready = false;
|
||||
while (Date.now() < deadline && !stopping) {
|
||||
if (server.done)
|
||||
throw new Error(`Docs server exited with ${await server.exited}`);
|
||||
try {
|
||||
const response = await fetch(`${url}/robots.txt`, {
|
||||
signal: AbortSignal.timeout(1000),
|
||||
});
|
||||
await response.body?.cancel();
|
||||
if (response.ok) {
|
||||
ready = true;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
ready = false;
|
||||
}
|
||||
await sleep(100);
|
||||
}
|
||||
if (!ready && !stopping)
|
||||
throw new Error(
|
||||
"Docs server did not become ready. Run the docs build first.",
|
||||
);
|
||||
if (!stopping) {
|
||||
tests = launch(["--test", "tests/docs-routes.test.mjs"], {
|
||||
...process.env,
|
||||
DOCS_TEST_URL: url,
|
||||
});
|
||||
process.exitCode = await tests.exited;
|
||||
}
|
||||
} finally {
|
||||
await stop();
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import {
|
||||
defineGeistdocsSourceConfig,
|
||||
geistdocsFrontmatterSchema,
|
||||
geistdocsMetaSchema,
|
||||
} from "@vercel/geistdocs/source-config";
|
||||
import { defineConfig, defineDocs } from "fumadocs-mdx/config";
|
||||
import { remarkLegacyHeadings } from "./lib/remark-legacy-headings";
|
||||
|
||||
export const docs = defineDocs({
|
||||
dir: "content/docs",
|
||||
docs: {
|
||||
schema: geistdocsFrontmatterSchema,
|
||||
postprocess: { includeProcessedMarkdown: true },
|
||||
},
|
||||
meta: { schema: geistdocsMetaSchema },
|
||||
});
|
||||
|
||||
const config = defineGeistdocsSourceConfig({
|
||||
mdxOptions: {
|
||||
remarkStructureOptions: {
|
||||
types: [
|
||||
"heading",
|
||||
"paragraph",
|
||||
"blockquote",
|
||||
"tableCell",
|
||||
"mdxJsxFlowElement",
|
||||
"mdxJsxTextElement",
|
||||
"text",
|
||||
"inlineCode",
|
||||
"code",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default defineConfig({
|
||||
...config,
|
||||
mdxOptions: {
|
||||
...config.mdxOptions,
|
||||
remarkPlugins: [remarkLegacyHeadings],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,354 @@
|
||||
import process from "node:process";
|
||||
import { Buffer } from "node:buffer";
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { request as httpRequest } from "node:http";
|
||||
import { request as httpsRequest } from "node:https";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { test } from "node:test";
|
||||
|
||||
const base = process.env.DOCS_TEST_URL;
|
||||
if (!base) throw new Error("Set DOCS_TEST_URL to a running production build.");
|
||||
const noindex = process.env.DOCS_EXPECT_NOINDEX === "1";
|
||||
const origin = "https://json-render.dev";
|
||||
const { pages } = JSON.parse(
|
||||
await readFile(
|
||||
new URL("./fixtures/docs-baseline.json", import.meta.url),
|
||||
"utf8",
|
||||
),
|
||||
);
|
||||
const hash = (value) => createHash("sha256").update(value).digest("hex");
|
||||
const get = (path, options = {}) =>
|
||||
fetch(new URL(path, base), {
|
||||
redirect: "manual",
|
||||
signal: AbortSignal.timeout(30000),
|
||||
...options,
|
||||
headers: {
|
||||
"user-agent": "Mozilla/5.0",
|
||||
accept: "text/html",
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
function rawGet(path, headers) {
|
||||
const url = new URL(path, base);
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = (url.protocol === "https:" ? httpsRequest : httpRequest)(
|
||||
url,
|
||||
{
|
||||
headers,
|
||||
signal: AbortSignal.timeout(30000),
|
||||
},
|
||||
(incoming) => {
|
||||
const chunks = [];
|
||||
incoming.on("data", (chunk) => chunks.push(chunk));
|
||||
incoming.on("error", reject);
|
||||
incoming.on("end", () => {
|
||||
const responseHeaders = new Headers();
|
||||
for (let i = 0; i < incoming.rawHeaders.length; i += 2) {
|
||||
responseHeaders.append(
|
||||
incoming.rawHeaders[i],
|
||||
incoming.rawHeaders[i + 1],
|
||||
);
|
||||
}
|
||||
resolve(
|
||||
new Response(Buffer.concat(chunks), {
|
||||
status: incoming.statusCode,
|
||||
headers: responseHeaders,
|
||||
}),
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
request.on("error", reject);
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
const decode = (value) =>
|
||||
value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
const attrs = (html, tag) =>
|
||||
[...html.matchAll(new RegExp(`<${tag}\\b[^>]*>`, "g"))].map(([value]) =>
|
||||
Object.fromEntries(
|
||||
[...value.matchAll(/([\w:-]+)="([^"]*)"/g)].map((m) => [
|
||||
m[1],
|
||||
decode(m[2]),
|
||||
]),
|
||||
),
|
||||
);
|
||||
const meta = (html, key) =>
|
||||
attrs(html, "meta").find((tag) => tag.name === key || tag.property === key)
|
||||
?.content;
|
||||
const canonical = (html) =>
|
||||
attrs(html, "link").find((tag) => tag.rel === "canonical")?.href;
|
||||
const contentType = (response) =>
|
||||
response.headers.get("content-type")?.split(";")[0];
|
||||
function negotiationHeaders(response, html = false) {
|
||||
const tokens = (response.headers.get("vary") ?? "")
|
||||
.toLowerCase()
|
||||
.split(/\s*,\s*/);
|
||||
for (const name of [
|
||||
"accept",
|
||||
"user-agent",
|
||||
"signature-agent",
|
||||
"sec-fetch-mode",
|
||||
"sec-fetch-dest",
|
||||
"rsc",
|
||||
"next-router-prefetch",
|
||||
"next-router-segment-prefetch",
|
||||
"purpose",
|
||||
"sec-purpose",
|
||||
...(html ? ["next-router-state-tree"] : []),
|
||||
])
|
||||
assert.ok(tokens.includes(name), `${response.url}: missing Vary ${name}`);
|
||||
assert.match(response.headers.get("cache-control") ?? "", /private/);
|
||||
assert.match(response.headers.get("cache-control") ?? "", /no-store/);
|
||||
assert.equal(response.headers.get("cdn-cache-control"), "no-store");
|
||||
assert.equal(response.headers.get("vercel-cdn-cache-control"), "no-store");
|
||||
}
|
||||
|
||||
for (const page of pages) {
|
||||
test(`${page.path}: content and existing anchors survive migration`, async () => {
|
||||
const raw = await readFile(
|
||||
new URL(`../${page.file}`, import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const body = raw.replace(/^---\n[\s\S]*?\n---\n/, "");
|
||||
assert.equal(hash(page.prefix + body), page.sourceSha256);
|
||||
const response = await get(page.path);
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(contentType(response), "text/html");
|
||||
negotiationHeaders(response, true);
|
||||
const html = await response.text();
|
||||
assert.equal(canonical(html), `${origin}${page.path}`);
|
||||
assert.equal(
|
||||
decode(html.match(/<title>([\s\S]*?)<\/title>/)?.[1] ?? ""),
|
||||
page.metadataTitle,
|
||||
);
|
||||
assert.equal(meta(html, "og:url"), `${origin}${page.path}`);
|
||||
assert.equal(meta(html, "og:image"), `${origin}/og${page.path}`);
|
||||
assert.equal(html.match(/<h1(?:\s|>)/g)?.length, 1);
|
||||
assert.equal((meta(html, "robots") ?? "").includes("noindex"), noindex);
|
||||
if (noindex)
|
||||
assert.match(response.headers.get("x-robots-tag") ?? "", /noindex/);
|
||||
for (const { id } of page.headings)
|
||||
assert.ok(html.includes(`id="${id}"`), `${page.path}: missing #${id}`);
|
||||
});
|
||||
test(`${page.path}: legacy, explicit and negotiated Markdown have exact parity`, async () => {
|
||||
for (const [path, headers] of [
|
||||
[`/api/docs-markdown?path=${encodeURIComponent(page.path)}`, {}],
|
||||
[`${page.path}.md`, {}],
|
||||
[page.path, { accept: "text/markdown" }],
|
||||
]) {
|
||||
const response = await get(path, { headers });
|
||||
assert.equal(response.status, 200, path);
|
||||
assert.equal(contentType(response), "text/markdown");
|
||||
assert.equal(hash(await response.text()), page.markdownSha256, path);
|
||||
assert.equal(
|
||||
response.headers.get("link"),
|
||||
`<${origin}${page.path}>; rel="canonical"`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("homepage, examples and playground keep public HTML routes", async () => {
|
||||
for (const path of ["/", "/examples", "/playground"]) {
|
||||
const response = await get(path, {
|
||||
headers: { accept: "text/markdown", "user-agent": "ClaudeBot/1.0" },
|
||||
});
|
||||
assert.equal(response.status, 200, path);
|
||||
assert.equal(contentType(response), "text/html");
|
||||
assert.equal(
|
||||
canonical(await response.text()),
|
||||
`${origin}${path === "/" ? "" : path}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("public pages share one Geistdocs header, footer and provider", async () => {
|
||||
for (const path of [
|
||||
"/",
|
||||
"/examples",
|
||||
"/playground",
|
||||
"/docs",
|
||||
"/docs/api/core",
|
||||
]) {
|
||||
const response = await get(path);
|
||||
assert.equal(response.status, 200, path);
|
||||
const html = await response.text();
|
||||
assert.equal(attrs(html, "header").length, 1, path);
|
||||
assert.equal(attrs(html, "footer").length, 1, path);
|
||||
assert.equal(attrs(html, "main").length, 1, path);
|
||||
assert.equal(
|
||||
attrs(html, "div").filter(
|
||||
(tag) => tag["data-geistdocs-container"] === "true",
|
||||
).length,
|
||||
1,
|
||||
path,
|
||||
);
|
||||
assert.ok(html.includes('aria-label="Primary navigation"'), path);
|
||||
assert.ok(html.includes('aria-label="GitHub repository"'), path);
|
||||
assert.ok(html.includes("Open Source Program"), path);
|
||||
if (path === "/playground") {
|
||||
assert.ok(
|
||||
attrs(html, "textarea").every(
|
||||
(tag) => !Object.hasOwn(tag, "autofocus"),
|
||||
),
|
||||
"Playground focuses without scrolling the shared page",
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("negotiation honors quality, browser previews and Next navigation", async () => {
|
||||
for (const [headers, type, suffix] of [
|
||||
[{ accept: "text/markdown;q=0,text/html" }, "text/html", ""],
|
||||
[{ accept: "text/markdown;q=0.5,text/html;q=1" }, "text/html", ""],
|
||||
[
|
||||
{ accept: "*/*", "user-agent": "Slackbot-LinkExpanding 1.0" },
|
||||
"text/html",
|
||||
"",
|
||||
],
|
||||
[{ accept: "*/*", "user-agent": "Discordbot/2.0" }, "text/html", ""],
|
||||
[{ accept: "*/*", "user-agent": "Googlebot/2.1" }, "text/html", ""],
|
||||
[{ accept: "*/*", "user-agent": "ClaudeBot/1.0" }, "text/markdown", ""],
|
||||
[
|
||||
{ accept: "text/markdown", rsc: "1", "user-agent": "ClaudeBot/1.0" },
|
||||
"text/x-component",
|
||||
"?_rsc",
|
||||
],
|
||||
[{ accept: "text/markdown", "next-router-prefetch": "1" }, "text/html", ""],
|
||||
[
|
||||
{ accept: "text/markdown", "next-router-segment-prefetch": "/_tree" },
|
||||
"text/html",
|
||||
"",
|
||||
],
|
||||
[{ accept: "text/markdown", purpose: "prefetch" }, "text/html", ""],
|
||||
[{ accept: "text/markdown", "sec-purpose": "prefetch" }, "text/html", ""],
|
||||
[
|
||||
{
|
||||
accept: "text/markdown",
|
||||
"sec-fetch-mode": "navigate",
|
||||
"sec-fetch-dest": "document",
|
||||
},
|
||||
"text/markdown",
|
||||
"",
|
||||
],
|
||||
[
|
||||
{
|
||||
accept: "*/*",
|
||||
"user-agent": "ClaudeBot/1.0",
|
||||
"sec-fetch-mode": "navigate",
|
||||
"sec-fetch-dest": "document",
|
||||
},
|
||||
"text/html",
|
||||
"",
|
||||
],
|
||||
]) {
|
||||
const response = headers["sec-fetch-mode"]
|
||||
? await rawGet(`/docs/installation${suffix}`, headers)
|
||||
: await get(`/docs/installation${suffix}`, { headers });
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(contentType(response), type, JSON.stringify(headers));
|
||||
negotiationHeaders(response, type !== "text/markdown");
|
||||
await response.body?.cancel();
|
||||
}
|
||||
});
|
||||
|
||||
test("legacy and language aliases preserve permanent destinations", async () => {
|
||||
for (const [path, target] of [
|
||||
["/docs/components", "/docs/registry"],
|
||||
["/docs/actions", "/docs/registry#action-handlers"],
|
||||
["/en/docs", "/docs"],
|
||||
["/en/docs/api/core", "/docs/api/core"],
|
||||
]) {
|
||||
const response = await get(path);
|
||||
assert.equal(response.status, 308, path);
|
||||
const location = new URL(response.headers.get("location"), base);
|
||||
assert.equal(location.pathname + location.hash, target);
|
||||
await response.body?.cancel();
|
||||
}
|
||||
const redirect = await get("/en/docs?utm_source=test&value=a%2Fb");
|
||||
assert.equal(
|
||||
new URL(redirect.headers.get("location"), base).search,
|
||||
"?utm_source=test&value=a%2Fb",
|
||||
);
|
||||
assert.equal(redirect.headers.get("set-cookie"), null);
|
||||
});
|
||||
|
||||
test("unknown and malformed routes return real 404s", async () => {
|
||||
for (const path of [
|
||||
"/docs/missing",
|
||||
"/docs/missing.md",
|
||||
"/docs/%",
|
||||
"/docs/%25",
|
||||
"/docs/%2F",
|
||||
"/docs/%E0%A4%A",
|
||||
"/fr/docs",
|
||||
]) {
|
||||
const response = await get(path);
|
||||
assert.equal(response.status, 404, path);
|
||||
await response.body?.cancel();
|
||||
}
|
||||
assert.equal((await get("/api/docs-markdown")).status, 400);
|
||||
assert.equal(
|
||||
(await get("/api/docs-markdown?path=/docs/../package")).status,
|
||||
400,
|
||||
);
|
||||
});
|
||||
|
||||
test("indexes expose canonical docs and environment-aware robots", async () => {
|
||||
const xml = await (await get("/sitemap.xml")).text();
|
||||
assert.equal([...xml.matchAll(/<loc>/g)].length, pages.length + 3);
|
||||
const llms = await (await get("/llms.txt")).text();
|
||||
const markdown = await (await get("/sitemap.md")).text();
|
||||
for (const page of pages) {
|
||||
assert.ok(xml.includes(`${origin}${page.path}</loc>`));
|
||||
assert.ok(llms.includes(`${origin}${page.path}.md`));
|
||||
assert.ok(markdown.includes(`](${page.path})`));
|
||||
}
|
||||
const robots = await (await get("/robots.txt")).text();
|
||||
assert.ok(robots.includes(noindex ? "Disallow: /" : "Allow: /"));
|
||||
assert.ok(robots.includes(`${origin}/sitemap.xml`));
|
||||
});
|
||||
|
||||
test("native and legacy search remain usable", async () => {
|
||||
const native = await (
|
||||
await get("/api/search?query=StateProvider&locale=en")
|
||||
).json();
|
||||
assert.ok(
|
||||
Array.isArray(native) &&
|
||||
native.some((item) => item.url.startsWith("/docs/")),
|
||||
);
|
||||
const legacy = await (await get("/api/search?q=catalog")).json();
|
||||
assert.ok(legacy.results.some((item) => item.href === "/docs/catalog"));
|
||||
assert.deepEqual(
|
||||
await (
|
||||
await get("/api/search?query=zzzz-no-document-12345&locale=en")
|
||||
).json(),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test("static assets and HEAD bypass representation mistakes", async () => {
|
||||
for (const path of ["/og", "/og/docs/api/core", "/favicon.ico"]) {
|
||||
const response = await get(path, { headers: { accept: "text/markdown" } });
|
||||
assert.equal(response.status, 200);
|
||||
assert.match(contentType(response), /^image\//);
|
||||
await response.body?.cancel();
|
||||
}
|
||||
for (const accept of ["text/html", "text/markdown"]) {
|
||||
const response = await get("/docs", {
|
||||
method: "HEAD",
|
||||
headers: { accept },
|
||||
});
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(contentType(response), accept);
|
||||
assert.equal(await response.text(), "");
|
||||
negotiationHeaders(response, accept === "text/html");
|
||||
}
|
||||
});
|
||||
+3821
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,8 @@
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
"@/*": ["./*"],
|
||||
"@/.source/*": ["./.source/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
@@ -17,7 +18,5 @@
|
||||
"next.config.js",
|
||||
".next/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
||||
@@ -39,6 +39,12 @@ pnpm dev
|
||||
|
||||
Requires global [`portless`](https://github.com/vercel-labs/portless). The `predev` script checks for it automatically.
|
||||
|
||||
## Build checks
|
||||
|
||||
From the monorepo root, run `pnpm --filter example-chat check-types` and `pnpm turbo run build --filter=example-chat`.
|
||||
|
||||
The type check also verifies that Streamdown and `@streamdown/code` resolve the same Shiki installation. The workspace dependency extension and scoped override keep their exported highlighting types compatible without relying on whichever Shiki version happens to be hoisted.
|
||||
|
||||
## Files
|
||||
|
||||
- `app/page.tsx` -- chat UI with `useChat`, message rendering, and inline spec display
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user