Compare commits

...
Author SHA1 Message Date
Railly Hugo 1710ae6a07 test(docs): refresh core API baseline after main merge 2026-09-23 13:46:01 -03:00
Railly Hugo fdb8982792 Merge main into feat/json-render-geistdocs 2026-09-23 13:45:47 -03:00
Railly Hugo 9f43f07108 fix(docs): migrate Jev page into the content collection and refresh baseline
The merge from main brought the v0.21.0 Jev docs page into the old
app/(main)/docs location that the Geistdocs migration had replaced, so
/docs/jev appeared in the sitemap but 404'd. Move it to
content/docs/jev.mdx with sidebar placement under a new Experimental
section, and refresh the docs baseline hashes for the pages whose
content changed with the merge (api/core, changelog, skills).
2026-09-21 19:00:57 -03:00
Railly Hugo 9164a703fb Merge branch 'main' into feat/json-render-geistdocs 2026-09-21 14:35:40 -03:00
Railly Hugo b900f21ce7 feat(docs): switch navbar to Vercel Labs brand
Bump @vercel/geistdocs to 2.4.1 and set navbarBrand: "labs" so the
docs navbar uses the Vercel Labs logo and links to vercel.com/labs
instead of the default OSS branding.
2026-09-21 14:32:06 -03:00
Railly Hugo 32e4b095b3 fix(chat): align Streamdown highlighter dependencies 2026-09-17 22:14:57 -03:00
Railly Hugo d547429818 feat(docs): migrate to Geistdocs 2026-09-17 21:57:26 -03:00
107 changed files with 9068 additions and 648 deletions
+21
View File
@@ -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
+3 -1
View File
@@ -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`
+1
View File
@@ -11,6 +11,7 @@
# next.js
/.next/
/.source/
/out/
# production
-35
View File
@@ -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>
</>
);
}
+8
View File
@@ -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;
}
+2 -12
View File
@@ -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;
+25
View File
@@ -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>
);
}
+7 -34
View File
@@ -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[] {
+14 -44
View File
@@ -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 },
);
}
+6
View File
@@ -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) {
+35 -6
View File
@@ -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
View File
@@ -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 />
+10
View File
@@ -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" },
});
}
+5 -1
View File
@@ -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>
);
}
+11
View File
@@ -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`,
};
}
+10
View File
@@ -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" },
},
);
}
+9
View File
@@ -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}`,
}));
}
+63 -6
View File
@@ -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>&#8984;</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>
);
}
+7 -5
View File
@@ -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">
+1
View File
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
+34
View File
@@ -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.
@@ -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`.
@@ -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.
@@ -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`.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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`.
@@ -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.
@@ -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.
@@ -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.
+42
View File
@@ -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.
@@ -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.
+1
View File
@@ -2,6 +2,7 @@ import { nextJsConfig } from "@internal/eslint-config/next-js";
/** @type {import("eslint").Linter.Config[]} */
export default [
{ ignores: [".source/**"] },
...nextJsConfig,
{
rules: {
+26
View File
@@ -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");
}
+69
View File
@@ -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)!));
}
+30
View File
@@ -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 },
});
+20
View File
@@ -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;
},
};
+10
View File
@@ -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",
+26
View File
@@ -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);
};
}
+3 -15
View File
@@ -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,
+6
View File
@@ -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
View File
@@ -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);
+8 -5
View File
@@ -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",
+62
View File
@@ -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*", "/(.*%.*)"],
};
+106
View File
@@ -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();
}
+42
View File
@@ -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],
},
});
+354
View File
@@ -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(/&amp;/g, "&")
.replace(/&quot;/g, '"')
.replace(/&#x27;/g, "'")
.replace(/&lt;/g, "<")
.replace(/&gt;/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");
}
});
File diff suppressed because it is too large Load Diff
+3 -4
View File
@@ -7,7 +7,8 @@
}
],
"paths": {
"@/*": ["./*"]
"@/*": ["./*"],
"@/.source/*": ["./.source/*"]
}
},
"include": [
@@ -17,7 +18,5 @@
"next.config.js",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
"exclude": ["node_modules"]
}
+6
View File
@@ -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