From 29475bde6649a6d90f05fe6e319e028d489182af Mon Sep 17 00:00:00 2001 From: olliethedev <5933733+olliethedev@users.noreply.github.com> Date: Sat, 29 Aug 2026 03:42:33 -0400 Subject: [PATCH 01/35] feat(registry): add canonical route docs consumer --- docs/content/docs/plugins/route-docs.mdx | 23 ++++++++++ docs/content/docs/shadcn-registry.mdx | 32 +++++++++++++- packages/stack/registry/btst-route-docs.json | 43 +++++++++++++++++++ packages/stack/registry/registry.json | 23 ++++++++++ packages/stack/scripts/build-registry.ts | 43 ++++++++++++++++++- packages/stack/scripts/test-registry.sh | 7 ++- .../src/plugins/route-docs/client/index.ts | 7 +++ .../src/plugins/route-docs/client/plugin.tsx | 27 ++++++++---- .../src/components/playground-client.tsx | 14 +++++- playground/src/components/plugin-selector.tsx | 26 +++++++++-- playground/src/lib/plugin-selection.ts | 13 +++++- 11 files changed, 239 insertions(+), 19 deletions(-) create mode 100644 packages/stack/registry/btst-route-docs.json diff --git a/docs/content/docs/plugins/route-docs.mdx b/docs/content/docs/plugins/route-docs.mdx index 58f91aaa7..a854cf0e5 100644 --- a/docs/content/docs/plugins/route-docs.mdx +++ b/docs/content/docs/plugins/route-docs.mdx @@ -129,6 +129,29 @@ createClientStack({ }) ``` +## Shadcn Registry + +Eject the Route Docs view layer while keeping route introspection and cache +behavior inside `@btst/stack`: + +```bash +npx shadcn@latest add https://github.com/better-stack-ai/better-stack/blob/main/packages/stack/registry/btst-route-docs.json +``` + +Then pass the ejected page back to the client plugin. Route Docs remains +client-only; do not add a matching backend plugin. + +```tsx title="lib/stack-client.tsx" +import { routeDocsClientPlugin } from "@btst/stack/plugins/route-docs/client" +import { DocsPageComponent } from "@/components/btst/route-docs/client/components/pages/docs-page" + +routeDocsClientPlugin({ + pageComponents: { + docs: DocsPageComponent, + }, +}) +``` + ## Page Layout The documentation page includes several sections: diff --git a/docs/content/docs/shadcn-registry.mdx b/docs/content/docs/shadcn-registry.mdx index 02f7ceb9f..760ddbe39 100644 --- a/docs/content/docs/shadcn-registry.mdx +++ b/docs/content/docs/shadcn-registry.mdx @@ -6,7 +6,7 @@ description: Eject and fully customize plugin UI components using the shadcn reg import { Tabs, Tab } from "fumadocs-ui/components/tabs"; import { Callout } from "fumadocs-ui/components/callout"; import { Card, Cards } from "fumadocs-ui/components/card"; -import { BookOpen, Bot, Database, FileText, Layout, Columns3, MessageSquare, ImageIcon } from "lucide-react"; +import { BookOpen, Bot, Database, FileText, Layout, Columns3, MessageSquare, ImageIcon, Route } from "lucide-react"; Every BTST plugin ships its page components as a [shadcn v4 registry](https://ui.shadcn.com/docs/registry) block. This lets you **eject the entire view layer** into your own codebase and customize it freely — while all data-fetching, API logic, hooks, and routing stay untouched inside `@btst/stack`. @@ -34,6 +34,7 @@ Pick the plugin you want to customize: } description="Boards list, board detail page" /> } description="Moderation pages, user comments pages, and reusable thread UI" /> } description="Media library page and reusable picker UI" /> + } description="Client-only interactive route reference" /> Or install a single plugin's UI directly: @@ -64,6 +65,9 @@ Or install a single plugin's UI directly: # Media npx shadcn@latest add https://github.com/better-stack-ai/better-stack/blob/main/packages/stack/registry/btst-media.json + + # Route Docs + npx shadcn@latest add https://github.com/better-stack-ai/better-stack/blob/main/packages/stack/registry/btst-route-docs.json ``` @@ -91,6 +95,9 @@ Or install a single plugin's UI directly: # Media pnpx shadcn@latest add https://github.com/better-stack-ai/better-stack/blob/main/packages/stack/registry/btst-media.json + + # Route Docs + pnpx shadcn@latest add https://github.com/better-stack-ai/better-stack/blob/main/packages/stack/registry/btst-route-docs.json ``` @@ -118,6 +125,9 @@ Or install a single plugin's UI directly: # Media bunx shadcn@latest add https://github.com/better-stack-ai/better-stack/blob/main/packages/stack/registry/btst-media.json + + # Route Docs + bunx shadcn@latest add https://github.com/better-stack-ai/better-stack/blob/main/packages/stack/registry/btst-route-docs.json ``` @@ -264,6 +274,25 @@ router configured so the ejected component uses the same runtime services as the built-in page. Upload mode belongs to `mediaClientPlugin()`; optional image compression and route lifecycle callbacks remain inferred provider overrides. +### Route Docs + +Route Docs is client-only. Eject its page and wire it back through the client +plugin without inventing a backend registration: + +```tsx title="lib/stack-client.tsx" +import { routeDocsClientPlugin } from "@btst/stack/plugins/route-docs/client" +import { DocsPageComponent } from "@/components/btst/route-docs/client/components/pages/docs-page" + +routeDocsClientPlugin({ + pageComponents: { + docs: DocsPageComponent, + }, +}) +``` + +Route introspection, schema generation, and caching remain package-owned; the +registry installs only the customizable page and loading view. + ## Available `pageComponents` keys The table below covers the plugins that currently support `pageComponents` overrides directly. Comments still use the direct-import pattern shown above. @@ -293,6 +322,7 @@ The table below covers the plugins that currently support `pageComponents` overr | Kanban | `newBoard` | — | New board | | Kanban | `board` | `{ params: { boardId: string } }` | Board detail | | Media | `library` | — | Media library page | +| Route Docs | `docs` | — | Interactive route documentation page | ## What the registry installs diff --git a/packages/stack/registry/btst-route-docs.json b/packages/stack/registry/btst-route-docs.json new file mode 100644 index 000000000..324c7b710 --- /dev/null +++ b/packages/stack/registry/btst-route-docs.json @@ -0,0 +1,43 @@ +{ + "name": "btst-route-docs", + "type": "registry:block", + "title": "Route Docs Plugin Page", + "description": "Ejectable page components for the client-only @btst/stack route-docs plugin. Customize the view while route introspection and caching stay in @btst/stack.", + "author": "BTST ", + "dependencies": [ + "@btst/stack" + ], + "registryDependencies": [ + "badge", + "button", + "card", + "input", + "label", + "scroll-area", + "separator", + "sheet", + "skeleton", + "table" + ], + "files": [ + { + "path": "btst/route-docs/client/components/loading/docs-skeleton.tsx", + "type": "registry:component", + "content": "\"use client\";\n\nimport { Skeleton } from \"@/components/ui/skeleton\";\nimport { ScrollArea } from \"@/components/ui/scroll-area\";\nimport { Card, CardContent } from \"@/components/ui/card\";\n\nexport function DocsPageSkeleton() {\n\treturn (\n\t\t
\n\t\t\t{/* Desktop Sidebar skeleton */}\n\t\t\t\n\n\t\t\t{/* Mobile header skeleton */}\n\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t
\n\n\t\t\t{/* Main content skeleton */}\n\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t{/* Title */}\n\t\t\t\t\t
\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t
\n\n\t\t\t\t\t{/* Separator */}\n\t\t\t\t\t
\n\n\t\t\t\t\t{/* Badges */}\n\t\t\t\t\t
\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t
\n\n\t\t\t\t\t{/* Routes card */}\n\t\t\t\t\t\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{/* Desktop table skeleton */}\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t{[1, 2, 3, 4].map((i) => (\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t{/* Mobile cards skeleton */}\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t{[1, 2, 3].map((i) => (\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t
\n\t\t\t
\n\t\t
\n\t);\n}\n", + "target": "src/components/btst/route-docs/client/components/loading/docs-skeleton.tsx" + }, + { + "path": "btst/route-docs/client/components/loading/index.tsx", + "type": "registry:component", + "content": "export { DocsPageSkeleton } from \"./docs-skeleton\";\n", + "target": "src/components/btst/route-docs/client/components/loading/index.tsx" + }, + { + "path": "btst/route-docs/client/components/pages/docs-page.tsx", + "type": "registry:page", + "content": "\"use client\";\n\nimport React, { useState, useMemo } from \"react\";\nimport {\n\tCard,\n\tCardContent,\n\tCardHeader,\n\tCardTitle,\n} from \"@/components/ui/card\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { ScrollArea } from \"@/components/ui/scroll-area\";\nimport { Separator } from \"@/components/ui/separator\";\nimport {\n\tTable,\n\tTableBody,\n\tTableCell,\n\tTableHead,\n\tTableHeader,\n\tTableRow,\n} from \"@/components/ui/table\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport {\n\tSheet,\n\tSheetContent,\n\tSheetHeader,\n\tSheetTitle,\n\tSheetTrigger,\n} from \"@/components/ui/sheet\";\nimport {\n\tChevronRight,\n\tExternalLink,\n\tFileText,\n\tFolder,\n\tFolderOpen,\n\tGlobe,\n\tLink2,\n\tMenu,\n\tNavigation,\n} from \"lucide-react\";\nimport { useSuspenseQuery, type QueryKey } from \"@tanstack/react-query\";\nimport { joinBasePath, useStackOrNull } from \"@btst/stack/context\";\nimport type {\n\tRouteDocsSchema,\n\tDocumentedPlugin,\n\tDocumentedRoute,\n\tRouteParameter,\n\tPluginSitemapEntry,\n} from \"@btst/stack/plugins/route-docs/client\";\nimport { generateSchema } from \"@btst/stack/plugins/route-docs/client\";\n\nfunction createSiteUrl(\n\tsiteBaseURL: string,\n\tsiteBasePath: string,\n\tpath: string,\n): string {\n\treturn `${siteBaseURL}${joinBasePath(siteBasePath, path)}`;\n}\n\nfunction openInNewTab(url: string): void {\n\twindow.open(url, \"_blank\", \"noopener,noreferrer\");\n}\n\n/**\n * Escapes regex special characters in a string, except for placeholders\n * that will be replaced with actual regex patterns.\n */\nfunction escapeRegexForRoutePath(path: string): string {\n\t// Use unique placeholders that won't appear in URLs\n\tconst PARAM_PLACEHOLDER = \"\\x00PARAM\\x00\";\n\tconst WILDCARD_PLACEHOLDER = \"\\x00WILDCARD\\x00\";\n\n\t// Replace dynamic segments with placeholders before escaping\n\tlet result = path\n\t\t.replace(/:[^/]+/g, PARAM_PLACEHOLDER)\n\t\t.replace(/\\*/g, WILDCARD_PLACEHOLDER);\n\n\t// Escape all regex metacharacters\n\tresult = result.replace(/[.+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\n\n\t// Replace placeholders with actual regex patterns\n\tresult = result\n\t\t.replace(new RegExp(PARAM_PLACEHOLDER, \"g\"), \"[^/]+\")\n\t\t.replace(new RegExp(WILDCARD_PLACEHOLDER, \"g\"), \".*\");\n\n\treturn result;\n}\n\n/**\n * Render a route path with highlighted parameters\n */\nfunction HighlightedPath({ path }: { path: string }) {\n\tconst parts = path.split(\"/\");\n\treturn (\n\t\t\n\t\t\t{parts.map((part, i) => {\n\t\t\t\tconst isParam = part.startsWith(\":\") || part.startsWith(\"*\");\n\t\t\t\treturn (\n\t\t\t\t\t\n\t\t\t\t\t\t{i > 0 && /}\n\t\t\t\t\t\t{isParam ? (\n\t\t\t\t\t\t\t{part}\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t{part}\n\t\t\t\t\t\t)}\n\t\t\t\t\t\n\t\t\t\t);\n\t\t\t})}\n\t\t\n\t);\n}\n\n/**\n * Mobile-friendly parameter card (used on small screens instead of table)\n */\nfunction ParameterCard({ param }: { param: RouteParameter }) {\n\treturn (\n\t\t
\n\t\t\t
\n\t\t\t\t\n\t\t\t\t\t{param.name}\n\t\t\t\t\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t\t{param.type}\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{param.required ? \"required\" : \"optional\"}\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t
\n\t\t\t{param.description && (\n\t\t\t\t

{param.description}

\n\t\t\t)}\n\t\t\t{param.schema?.enum && (\n\t\t\t\t

\n\t\t\t\t\tValues: {param.schema.enum.join(\" | \")}\n\t\t\t\t

\n\t\t\t)}\n\t\t
\n\t);\n}\n\n/**\n * Parameters section - responsive table on desktop, cards on mobile\n */\nfunction ParametersSection({\n\tparams,\n\ttitle,\n}: {\n\tparams: RouteParameter[];\n\ttitle: string;\n}) {\n\tif (params.length === 0) return null;\n\n\treturn (\n\t\t
\n\t\t\t

\n\t\t\t\t{title}\n\t\t\t

\n\n\t\t\t{/* Desktop table */}\n\t\t\t
\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\tName\n\t\t\t\t\t\t\tType\n\t\t\t\t\t\t\tRequired\n\t\t\t\t\t\t\tDescription\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{params.map((param) => (\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t{param.name}\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t{param.type}\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{param.schema?.enum && (\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t({param.schema.enum.join(\" | \")})\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t{param.required ? \"required\" : \"optional\"}\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{param.description || \"—\"}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t))}\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t
\n\n\t\t\t{/* Mobile cards */}\n\t\t\t
\n\t\t\t\t{params.map((param) => (\n\t\t\t\t\t\n\t\t\t\t))}\n\t\t\t
\n\t\t
\n\t);\n}\n\n/**\n * Navigation form for routes with path parameters\n */\nfunction NavigationForm({\n\troute,\n\tsiteBaseURL,\n\tsiteBasePath,\n}: {\n\troute: DocumentedRoute;\n\tsiteBaseURL: string;\n\tsiteBasePath: string;\n}) {\n\tconst [paramValues, setParamValues] = useState>({});\n\n\tconst handleParamChange = (name: string, value: string) => {\n\t\tsetParamValues((prev) => ({ ...prev, [name]: value }));\n\t};\n\n\tconst buildUrl = () => {\n\t\tlet url = route.path;\n\t\tfor (const param of route.pathParams) {\n\t\t\tconst value = paramValues[param.name] || `{${param.name}}`;\n\t\t\t// Handle different parameter patterns:\n\t\t\t// - *:name (named wildcard) - must check before :name\n\t\t\t// - * (anonymous wildcard, extracted as \"_\")\n\t\t\t// - :name (standard path param)\n\t\t\tif (param.name === \"_\") {\n\t\t\t\turl = url.replace(\"*\", value);\n\t\t\t} else if (url.includes(`*:${param.name}`)) {\n\t\t\t\turl = url.replace(`*:${param.name}`, value);\n\t\t\t} else {\n\t\t\t\turl = url.replace(`:${param.name}`, value);\n\t\t\t}\n\t\t}\n\t\treturn createSiteUrl(siteBaseURL, siteBasePath, url);\n\t};\n\n\tconst handleVisit = () => {\n\t\tconst url = buildUrl();\n\t\tconst hasUnfilledParams = route.pathParams.some(\n\t\t\t(p) => !paramValues[p.name],\n\t\t);\n\t\tif (hasUnfilledParams) {\n\t\t\treturn;\n\t\t}\n\t\topenInNewTab(url);\n\t};\n\n\tconst allParamsFilled = route.pathParams.every((p) => paramValues[p.name]);\n\tconst previewUrl = buildUrl();\n\n\treturn (\n\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\tNavigate to Route\n\t\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\t{route.pathParams.length > 0 ? (\n\t\t\t\t\t<>\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t{route.pathParams.map((param) => (\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\thandleParamChange(param.name, e.target.value)\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t
\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{previewUrl}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\tVisit\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t) : (\n\t\t\t\t\t
\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{createSiteUrl(siteBaseURL, siteBasePath, route.path)}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\topenInNewTab(\n\t\t\t\t\t\t\t\t\tcreateSiteUrl(siteBaseURL, siteBasePath, route.path),\n\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tclassName=\"shrink-0\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\tVisit\n\t\t\t\t\t\t\n\t\t\t\t\t
\n\t\t\t\t)}\n\t\t\t
\n\t\t
\n\t);\n}\n\n/**\n * Get sitemap entries that match a specific route\n */\nfunction getMatchingSitemapEntries(\n\troute: DocumentedRoute,\n\tsitemapEntries: PluginSitemapEntry[],\n): PluginSitemapEntry[] {\n\tconst hasParams = route.pathParams.length > 0;\n\n\tif (!hasParams) {\n\t\t// Static route - exact matches\n\t\treturn sitemapEntries.filter((e) => {\n\t\t\ttry {\n\t\t\t\tconst url = new URL(e.url);\n\t\t\t\treturn url.pathname.endsWith(route.path);\n\t\t\t} catch {\n\t\t\t\treturn false;\n\t\t\t}\n\t\t});\n\t} else {\n\t\t// Dynamic route - pattern matches\n\t\tconst routePattern = escapeRegexForRoutePath(route.path);\n\t\tconst regex = new RegExp(`${routePattern}$`);\n\t\treturn sitemapEntries.filter((e) => {\n\t\t\ttry {\n\t\t\t\tconst url = new URL(e.url);\n\t\t\t\treturn regex.test(url.pathname);\n\t\t\t} catch {\n\t\t\t\treturn false;\n\t\t\t}\n\t\t});\n\t}\n}\n\n/**\n * Route sitemap entries section - displays sitemap entries for a specific route\n */\nfunction RouteSitemapSection({\n\troute,\n\tsitemapEntries,\n}: {\n\troute: DocumentedRoute;\n\tsitemapEntries: PluginSitemapEntry[];\n}) {\n\tconst matchingEntries = useMemo(\n\t\t() => getMatchingSitemapEntries(route, sitemapEntries),\n\t\t[route, sitemapEntries],\n\t);\n\n\tif (matchingEntries.length === 0) return null;\n\n\treturn (\n\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\tSitemap Entries\n\t\t\t\t\t\n\t\t\t\t\t\t{matchingEntries.length}\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\t{/* Desktop table */}\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\tURL\n\t\t\t\t\t\t\t\tLast Modified\n\t\t\t\t\t\t\t\tPriority\n\t\t\t\t\t\t\t\tActions\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{matchingEntries.map((entry, idx) => (\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t{entry.url}\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t{formatDate(entry.lastModified)}\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t{entry.priority !== undefined ? entry.priority : \"—\"}\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t openInNewTab(entry.url)}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\n\t\t\t\t\t
\n\t\t\t\t
\n\n\t\t\t\t{/* Mobile cards */}\n\t\t\t\t
\n\t\t\t\t\t{matchingEntries.map((entry, idx) => (\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{entry.url}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t openInNewTab(entry.url)}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t{entry.lastModified && (\n\t\t\t\t\t\t\t\t\t{formatDate(entry.lastModified)}\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t{entry.priority !== undefined && (\n\t\t\t\t\t\t\t\t\tPriority: {entry.priority}\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t
\n\t\t\t\t\t))}\n\t\t\t\t
\n\t\t\t
\n\t\t
\n\t);\n}\n\n/**\n * Route detail view\n */\nfunction RouteDetail({\n\troute,\n\tpluginName,\n\tsitemapEntries,\n\tsiteBaseURL,\n\tsiteBasePath,\n}: {\n\troute: DocumentedRoute;\n\tpluginName: string;\n\tsitemapEntries: PluginSitemapEntry[];\n\tsiteBaseURL: string;\n\tsiteBasePath: string;\n}) {\n\treturn (\n\t\t
\n\t\t\t{/* Route metadata if available */}\n\t\t\t{route.meta && (route.meta.title || route.meta.description) && (\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{route.meta.title && (\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{route.meta.title}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t)}\n\t\t\t\t\t\n\t\t\t\t\t{(route.meta.description ||\n\t\t\t\t\t\t(route.meta.tags && route.meta.tags.length > 0)) && (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{route.meta.description && (\n\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t{route.meta.description}\n\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t{route.meta.tags && route.meta.tags.length > 0 && (\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t{route.meta.tags.map((tag) => (\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t{tag}\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t
\n\t\t\t\t\t)}\n\t\t\t\t
\n\t\t\t)}\n\n\t\t\t{/* Route path */}\n\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t\t{pluginName}\n\t\t\t
\n\n\t\t\t{/* Navigation form */}\n\t\t\t\n\n\t\t\t{/* Path parameters */}\n\t\t\t\n\n\t\t\t{/* Query parameters */}\n\t\t\t\n\n\t\t\t{/* Sitemap entries for this route */}\n\t\t\t\n\t\t
\n\t);\n}\n\n/**\n * Generate a unique anchor ID for a route\n */\nfunction getRouteAnchorId(pluginKey: string, routeKey: string): string {\n\treturn `route-${pluginKey}-${routeKey}`;\n}\n\n/**\n * Sidebar route item - now an anchor link\n */\nfunction SidebarRouteItem({\n\troute,\n\tpluginKey,\n\tonNavigate,\n}: {\n\troute: DocumentedRoute;\n\tpluginKey: string;\n\tonNavigate?: () => void;\n}) {\n\tconst anchorId = getRouteAnchorId(pluginKey, route.key);\n\n\tconst handleClick = (e: React.MouseEvent) => {\n\t\te.preventDefault();\n\t\tconst element = document.getElementById(anchorId);\n\t\tif (element) {\n\t\t\telement.scrollIntoView({ behavior: \"smooth\", block: \"start\" });\n\t\t\t// Update URL hash without scrolling (scrollIntoView handles it)\n\t\t\twindow.history.pushState(null, \"\", `#${anchorId}`);\n\t\t}\n\t\tonNavigate?.();\n\t};\n\n\treturn (\n\t\t\n\t\t\t\n\t\t\t{route.path}\n\t\t\n\t);\n}\n\n/**\n * Sidebar plugin group\n */\nfunction SidebarPluginGroup({\n\tplugin,\n\tonNavigate,\n}: {\n\tplugin: DocumentedPlugin;\n\tonNavigate?: () => void;\n}) {\n\tconst [isExpanded, setIsExpanded] = useState(true);\n\n\treturn (\n\t\t
\n\t\t\t setIsExpanded(!isExpanded)}\n\t\t\t>\n\t\t\t\t\n\t\t\t\t\t{isExpanded ? (\n\t\t\t\t\t\t\n\t\t\t\t\t) : (\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t\t{plugin.name}\n\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\t{isExpanded && (\n\t\t\t\t
\n\t\t\t\t\t{plugin.routes.map((route) => (\n\t\t\t\t\t\t\n\t\t\t\t\t))}\n\t\t\t\t
\n\t\t\t)}\n\t\t
\n\t);\n}\n\n/**\n * Sidebar content (shared between desktop and mobile)\n */\nfunction SidebarContent({\n\tschema,\n\tonNavigate,\n}: {\n\tschema: RouteDocsSchema;\n\tonNavigate?: () => void;\n}) {\n\treturn (\n\t\t
\n\t\t\t{schema.plugins.map((plugin) => (\n\t\t\t\t\n\t\t\t))}\n\t\t
\n\t);\n}\n\n/**\n * Mobile-friendly route card for the routes list\n */\nfunction RouteCard({\n\tpluginName,\n\troute,\n\thasParams,\n\tstaticUrl,\n\tsitemapCount = 0,\n\tonSelect,\n}: {\n\tpluginName: string;\n\troute: DocumentedRoute;\n\thasParams: boolean;\n\tstaticUrl: string | null;\n\tsitemapCount?: number;\n\tonSelect: () => void;\n}) {\n\treturn (\n\t\t
\n\t\t\t
\n\t\t\t\t\n\t\t\t\t{staticUrl ? (\n\t\t\t\t\t openInNewTab(staticUrl)}\n\t\t\t\t\t>\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t) : (\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t
\n\t\t\t{route.meta?.title && (\n\t\t\t\t

{route.meta.title}

\n\t\t\t)}\n\t\t\t
\n\t\t\t\t\n\t\t\t\t\t{pluginName}\n\t\t\t\t\n\t\t\t\t{hasParams && (\n\t\t\t\t\t\n\t\t\t\t\t\t{route.pathParams.length} param\n\t\t\t\t\t\t{route.pathParams.length > 1 ? \"s\" : \"\"}\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t\t{sitemapCount > 0 && (\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t{sitemapCount} in sitemap\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t
\n\t\t
\n\t);\n}\n\n/**\n * Format a date for display\n */\nfunction formatDate(date: string | Date | undefined): string {\n\tif (!date) return \"—\";\n\tconst d = typeof date === \"string\" ? new Date(date) : date;\n\treturn d.toLocaleDateString(undefined, {\n\t\tyear: \"numeric\",\n\t\tmonth: \"short\",\n\t\tday: \"numeric\",\n\t});\n}\n\n/**\n * Sitemap section - displays all sitemap entries\n */\nfunction SitemapSection({\n\tentries,\n\tschema,\n}: {\n\tentries: PluginSitemapEntry[];\n\tschema: RouteDocsSchema;\n}) {\n\tconst [isExpanded, setIsExpanded] = useState(false);\n\n\t// Get plugin name from schema\n\tconst getPluginName = (pluginKey: string): string => {\n\t\tconst plugin = schema.plugins.find((p) => p.key === pluginKey);\n\t\treturn plugin?.name || pluginKey;\n\t};\n\n\tif (entries.length === 0) return null;\n\n\t// Show first 10 entries by default, all when expanded\n\tconst displayedEntries = isExpanded ? entries : entries.slice(0, 10);\n\tconst hasMore = entries.length > 10;\n\n\treturn (\n\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\tSitemap Entries\n\t\t\t\t\t\n\t\t\t\t\t\t{entries.length}\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\t{/* Desktop table */}\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\tURL\n\t\t\t\t\t\t\t\tPlugin\n\t\t\t\t\t\t\t\tLast Modified\n\t\t\t\t\t\t\t\tActions\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{displayedEntries.map((entry, idx) => (\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t{entry.url}\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t{getPluginName(entry.pluginKey)}\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t{formatDate(entry.lastModified)}\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t openInNewTab(entry.url)}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\n\t\t\t\t\t
\n\t\t\t\t
\n\n\t\t\t\t{/* Mobile cards */}\n\t\t\t\t
\n\t\t\t\t\t{displayedEntries.map((entry, idx) => (\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{entry.url}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t openInNewTab(entry.url)}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{getPluginName(entry.pluginKey)}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{entry.lastModified && (\n\t\t\t\t\t\t\t\t\t{formatDate(entry.lastModified)}\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t
\n\t\t\t\t\t))}\n\t\t\t\t
\n\n\t\t\t\t{/* Show more button */}\n\t\t\t\t{hasMore && (\n\t\t\t\t\t
\n\t\t\t\t\t\t setIsExpanded(!isExpanded)}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{isExpanded ? \"Show less\" : `Show all ${entries.length} entries`}\n\t\t\t\t\t\t\n\t\t\t\t\t
\n\t\t\t\t)}\n\t\t\t
\n\t\t
\n\t);\n}\n\n/**\n * All routes section - table on desktop, cards on mobile\n */\nfunction AllRoutesSection({\n\tschema,\n\tsiteBaseURL,\n\tsiteBasePath,\n}: {\n\tschema: RouteDocsSchema;\n\tsiteBaseURL: string;\n\tsiteBasePath: string;\n}) {\n\tconst scrollToRoute = (pluginKey: string, routeKey: string) => {\n\t\tconst anchorId = getRouteAnchorId(pluginKey, routeKey);\n\t\tconst element = document.getElementById(anchorId);\n\t\tif (element) {\n\t\t\telement.scrollIntoView({ behavior: \"smooth\", block: \"start\" });\n\t\t\twindow.history.pushState(null, \"\", `#${anchorId}`);\n\t\t}\n\t};\n\tconst allRoutes = useMemo(() => {\n\t\tconst routes: Array<{\n\t\t\tpluginKey: string;\n\t\t\tpluginName: string;\n\t\t\troute: DocumentedRoute;\n\t\t\thasParams: boolean;\n\t\t\tstaticUrl: string | null;\n\t\t\tsitemapCount: number;\n\t\t}> = [];\n\n\t\tfor (const plugin of schema.plugins) {\n\t\t\tfor (const route of plugin.routes) {\n\t\t\t\tconst hasParams = route.pathParams.length > 0;\n\n\t\t\t\t// Count sitemap entries that match this route pattern\n\t\t\t\tlet sitemapCount = 0;\n\t\t\t\tif (!hasParams) {\n\t\t\t\t\t// Static route - count exact matches\n\t\t\t\t\tsitemapCount = plugin.sitemapEntries.filter((e) => {\n\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\tconst url = new URL(e.url);\n\t\t\t\t\t\t\treturn url.pathname.endsWith(route.path);\n\t\t\t\t\t\t} catch {\n\t\t\t\t\t\t\treturn false;\n\t\t\t\t\t\t}\n\t\t\t\t\t}).length;\n\t\t\t\t} else {\n\t\t\t\t\t// Dynamic route - count entries that could match the pattern\n\t\t\t\t\tconst routePattern = escapeRegexForRoutePath(route.path);\n\t\t\t\t\tconst regex = new RegExp(`${routePattern}$`);\n\t\t\t\t\tsitemapCount = plugin.sitemapEntries.filter((e) => {\n\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\tconst url = new URL(e.url);\n\t\t\t\t\t\t\treturn regex.test(url.pathname);\n\t\t\t\t\t\t} catch {\n\t\t\t\t\t\t\treturn false;\n\t\t\t\t\t\t}\n\t\t\t\t\t}).length;\n\t\t\t\t}\n\n\t\t\t\troutes.push({\n\t\t\t\t\tpluginKey: plugin.key,\n\t\t\t\t\tpluginName: plugin.name,\n\t\t\t\t\troute,\n\t\t\t\t\thasParams,\n\t\t\t\t\tstaticUrl: hasParams\n\t\t\t\t\t\t? null\n\t\t\t\t\t\t: createSiteUrl(siteBaseURL, siteBasePath, route.path),\n\t\t\t\t\tsitemapCount,\n\t\t\t\t});\n\t\t\t}\n\t\t}\n\n\t\treturn routes;\n\t}, [schema, siteBaseURL, siteBasePath]);\n\n\tif (allRoutes.length === 0) return null;\n\n\treturn (\n\t\t\n\t\t\t\n\t\t\t\tAll Routes\n\t\t\t\n\t\t\t\n\t\t\t\t{/* Desktop table */}\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\tRoute\n\t\t\t\t\t\t\t\tPlugin\n\t\t\t\t\t\t\t\tParams\n\t\t\t\t\t\t\t\tSitemap\n\t\t\t\t\t\t\t\tActions\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{allRoutes.map(\n\t\t\t\t\t\t\t\t({\n\t\t\t\t\t\t\t\t\tpluginKey,\n\t\t\t\t\t\t\t\t\tpluginName,\n\t\t\t\t\t\t\t\t\troute,\n\t\t\t\t\t\t\t\t\thasParams,\n\t\t\t\t\t\t\t\t\tstaticUrl,\n\t\t\t\t\t\t\t\t\tsitemapCount,\n\t\t\t\t\t\t\t\t}) => (\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t scrollToRoute(pluginKey, route.key)}\n\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"text-left hover:underline\"\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t{route.path}\n\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t{route.meta?.title && (\n\t\t\t\t\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t\t\t\t\t{route.meta.title}\n\t\t\t\t\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t{pluginName}\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t{hasParams ? (\n\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t{route.pathParams.length}\n\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t{sitemapCount > 0 ? (\n\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t{sitemapCount}\n\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t{staticUrl ? (\n\t\t\t\t\t\t\t\t\t\t\t\t openInNewTab(staticUrl)}\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t\t\t\t scrollToRoute(pluginKey, route.key)}\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t
\n\n\t\t\t\t{/* Mobile cards */}\n\t\t\t\t
\n\t\t\t\t\t{allRoutes.map(\n\t\t\t\t\t\t({\n\t\t\t\t\t\t\tpluginKey,\n\t\t\t\t\t\t\tpluginName,\n\t\t\t\t\t\t\troute,\n\t\t\t\t\t\t\thasParams,\n\t\t\t\t\t\t\tstaticUrl,\n\t\t\t\t\t\t\tsitemapCount,\n\t\t\t\t\t\t}) => (\n\t\t\t\t\t\t\t scrollToRoute(pluginKey, route.key)}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t),\n\t\t\t\t\t)}\n\t\t\t\t
\n\t\t\t
\n\t\t
\n\t);\n}\n\n/**\n * Route documentation page component\n */\nexport interface DocsPageProps {\n\ttitle?: string;\n\tdescription?: string;\n\tsiteBaseURL?: string;\n\tsiteBasePath?: string;\n\tqueryKey: QueryKey;\n}\n\nexport function DocsPageComponent({\n\ttitle = \"Route Documentation\",\n\tdescription = \"Documentation for all client routes in your application\",\n\tsiteBaseURL = \"\",\n\tsiteBasePath = \"/pages\",\n\tqueryKey,\n}: DocsPageProps) {\n\tconst stack = useStackOrNull();\n\tconst context = stack?.clientStackContext ?? null;\n\t// Read schema from React Query (prefetched by loader on server, or generated on client)\n\tconst { data: schema } = useSuspenseQuery(\n\t\t{\n\t\t\tqueryKey,\n\t\t\tqueryFn: () => generateSchema(context),\n\t\t\tstaleTime: Infinity, // Don't refetch - schema is static for this session\n\t\t},\n\t\tstack?.queryClient,\n\t);\n\tconst [mobileMenuOpen, setMobileMenuOpen] = useState(false);\n\n\tconst totalRoutes = schema.plugins.reduce(\n\t\t(sum, p) => sum + p.routes.length,\n\t\t0,\n\t);\n\n\tconst handleMobileNavigate = () => {\n\t\tsetMobileMenuOpen(false);\n\t};\n\n\treturn (\n\t\t
\n\t\t\t{/* Desktop Sidebar - sticky */}\n\t\t\t\n\n\t\t\t{/* Mobile Header with Menu */}\n\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t

\n\t\t\t\t\t\tRoute Docs\n\t\t\t\t\t

\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\tRoutes\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t
\n\n\t\t\t{/* Main content - scrollable list of all routes */}\n\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t\t{/* Header */}\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t{title}\n\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t{description}\n\t\t\t\t\t\t\t

\n\t\t\t\t\t\t
\n\n\t\t\t\t\t\t\n\n\t\t\t\t\t\t{totalRoutes > 0 ? (\n\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t{/* Summary badges */}\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t{schema.plugins.length} plugins\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{totalRoutes} routes\n\t\t\t\t\t\t\t\t\t{schema.allSitemapEntries.length > 0 && (\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t{schema.allSitemapEntries.length} sitemap entries\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t
\n\n\t\t\t\t\t\t\t\t{/* All routes overview table */}\n\t\t\t\t\t\t\t\t\n\n\t\t\t\t\t\t\t\t{/* All route details - one after another */}\n\t\t\t\t\t\t\t\t{schema.plugins.map((plugin) => (\n\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\t{/* Plugin header */}\n\t\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t

{plugin.name}

\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t{plugin.routes.length} routes\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t
\n\n\t\t\t\t\t\t\t\t\t\t{/* Routes in this plugin */}\n\t\t\t\t\t\t\t\t\t\t{plugin.routes.map((route) => (\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\t))}\n\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t))}\n\n\t\t\t\t\t\t\t\t{/* Global sitemap section */}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t\tNo documented routes found.\n\t\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t\tAdd client plugins with routes to see documentation here.\n\t\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t)}\n\t\t\t\t\t
\n\t\t\t\t
\n\t\t\t\n\t\t\n\t);\n}\n", + "target": "src/components/btst/route-docs/client/components/pages/docs-page.tsx" + } + ], + "docs": "https://better-stack.ai/docs/plugins/route-docs" +} diff --git a/packages/stack/registry/registry.json b/packages/stack/registry/registry.json index 21eeb2069..33ef6116e 100644 --- a/packages/stack/registry/registry.json +++ b/packages/stack/registry/registry.json @@ -239,6 +239,29 @@ "tabs" ], "docs": "https://better-stack.ai/docs/plugins/media" + }, + { + "name": "btst-route-docs", + "type": "registry:block", + "title": "Route Docs Plugin Page", + "description": "Ejectable page components for the client-only @btst/stack route-docs plugin. Customize the view while route introspection and caching stay in @btst/stack.", + "author": "BTST ", + "dependencies": [ + "@btst/stack" + ], + "registryDependencies": [ + "badge", + "button", + "card", + "input", + "label", + "scroll-area", + "separator", + "sheet", + "skeleton", + "table" + ], + "docs": "https://better-stack.ai/docs/plugins/route-docs" } ] } diff --git a/packages/stack/scripts/build-registry.ts b/packages/stack/scripts/build-registry.ts index 18e28b371..7da8dabaa 100644 --- a/packages/stack/scripts/build-registry.ts +++ b/packages/stack/scripts/build-registry.ts @@ -170,6 +170,8 @@ interface PluginConfig { * consumer project layouts). */ pluginRootFiles: string[]; + /** Client data-layer files that remain package-owned instead of ejectable. */ + excludedClientFiles?: string[]; } const PLUGINS: PluginConfig[] = [ @@ -327,6 +329,17 @@ const PLUGINS: PluginConfig[] = [ "asset-url.ts", ], }, + { + name: "route-docs", + title: "Route Docs Plugin Page", + description: + "Ejectable page components for the client-only @btst/stack route-docs plugin. " + + "Customize the view while route introspection and caching stay in @btst/stack.", + extraNpmDeps: [], + extraRegistryDeps: [], + pluginRootFiles: [], + excludedClientFiles: ["constants.ts", "hooks.ts", "schema.ts"], + }, ]; // --------------------------------------------------------------------------- @@ -527,6 +540,28 @@ function rewriteApiAndQueryKeyImports( ); } +/** Keep Route Docs introspection and schema generation in the npm package. */ +function rewriteRouteDocsDataImports( + content: string, + absPath: string, + pluginDir: string, +): string { + const fileDir = dirname(absPath); + const generatorFile = join(pluginDir, "generator"); + const schemaFile = join(pluginDir, "client/schema"); + + return content.replace( + /from\s+(['"])(\.\.?\/[^'"]+)\1/g, + (match, quote, importPath) => { + const resolved = resolve(fileDir, importPath); + if (resolved === generatorFile || resolved === schemaFile) { + return `from ${quote}@btst/stack/plugins/route-docs/client${quote}`; + } + return match; + }, + ); +} + /** * Additional rewrites for workspace/ui source files pulled from * packages/ui/src/. Their relative ../lib/ and ../components/ imports @@ -1133,7 +1168,10 @@ async function buildPlugin(config: PluginConfig): Promise { const stats = await stat(absPathCheck); if (!stats.isFile()) continue; - if (shouldExclude(relPath)) { + if ( + config.excludedClientFiles?.includes(relPath) || + shouldExclude(relPath) + ) { console.log(` skip ${relPath}`); continue; } @@ -1158,6 +1196,9 @@ async function buildPlugin(config: PluginConfig): Promise { pluginDir, pluginName, ); + if (pluginName === "route-docs") { + content = rewriteRouteDocsDataImports(content, absPath, pluginDir); + } const fileType = classifyClientFile(relPath); diff --git a/packages/stack/scripts/test-registry.sh b/packages/stack/scripts/test-registry.sh index da4239149..653d55ace 100755 --- a/packages/stack/scripts/test-registry.sh +++ b/packages/stack/scripts/test-registry.sh @@ -47,7 +47,9 @@ SERVER_PORT=8766 SERVER_PID="" TEST_PASSED=false -PLUGIN_NAMES=("ui-builder" "blog" "ai-chat" "cms" "form-builder" "kanban" "comments" "media") +# Includes a representative data plugin (blog), client-only plugin (route-docs), +# and composed client plugin (ui-builder over CMS) in the install/build matrix. +PLUGIN_NAMES=("ui-builder" "blog" "ai-chat" "cms" "form-builder" "kanban" "comments" "media" "route-docs") # --------------------------------------------------------------------------- # Cleanup @@ -345,6 +347,7 @@ import { BoardsListPageComponent } from "@/components/btst/kanban/client/compone import { ModerationPageComponent } from "@/components/btst/comments/client/components/pages/moderation-page"; import { PageListPage } from "@/components/btst/ui-builder/client/components/pages/page-list-page"; import { LibraryPageComponent } from "@/components/btst/media/client/components/pages/library-page"; +import { DocsPageComponent } from "@/components/btst/route-docs/client/components/pages/docs-page"; // Suppress unused-import warnings while still forcing TS to resolve everything. void HomePageComponent; @@ -355,6 +358,7 @@ void BoardsListPageComponent; void ModerationPageComponent; void PageListPage; void LibraryPageComponent; +void DocsPageComponent; export default function SmokeTestPage() { return
Registry smoke test — all plugin imports resolved.
; @@ -374,6 +378,7 @@ SMOKE_EOF kanban) FAILED_SYMBOL="BoardsListPageComponent" ;; comments) FAILED_SYMBOL="ModerationPageComponent" ;; media) FAILED_SYMBOL="LibraryPageComponent" ;; + route-docs) FAILED_SYMBOL="DocsPageComponent" ;; *) continue ;; esac sed -i "/${FAILED_SYMBOL}/d" src/app/btst-smoke-test/page.tsx diff --git a/packages/stack/src/plugins/route-docs/client/index.ts b/packages/stack/src/plugins/route-docs/client/index.ts index aa87b503b..392ae7be6 100644 --- a/packages/stack/src/plugins/route-docs/client/index.ts +++ b/packages/stack/src/plugins/route-docs/client/index.ts @@ -7,3 +7,10 @@ export { type RegisteredRoute, } from "./plugin"; export { useRegisteredRoutes } from "./hooks"; +export type { + RouteDocsSchema, + DocumentedPlugin, + DocumentedRoute, + RouteParameter, + PluginSitemapEntry, +} from "../generator"; diff --git a/packages/stack/src/plugins/route-docs/client/plugin.tsx b/packages/stack/src/plugins/route-docs/client/plugin.tsx index 87a218ef4..3c50754d8 100644 --- a/packages/stack/src/plugins/route-docs/client/plugin.tsx +++ b/packages/stack/src/plugins/route-docs/client/plugin.tsx @@ -74,6 +74,11 @@ export interface RouteDocsClientConfig { title?: string; /** Description for the documentation page. */ description?: string; + /** Optional replacement for the Route Docs page. */ + pageComponents?: { + /** Replaces the interactive route documentation page. */ + docs?: ComponentType; + }; } interface ResolvedRouteDocsClientConfig extends RouteDocsClientConfig { @@ -265,15 +270,19 @@ function createResolvedRouteDocsPlugin(config: ResolvedRouteDocsClientConfig) { const resolvedContext = context ?? null; return { docs: defineRoute("/route-docs", { - page: () => ( - - ), + page: () => { + const PageComponent = + config.pageComponents?.docs ?? DocsPageComponent; + return ( + + ); + }, loading: DocsPageSkeleton, error: DocsErrorComponent, loader: createRouteDocsLoader(config, resolvedContext), diff --git a/playground/src/components/playground-client.tsx b/playground/src/components/playground-client.tsx index c994f54b3..b45a97c8e 100644 --- a/playground/src/components/playground-client.tsx +++ b/playground/src/components/playground-client.tsx @@ -14,7 +14,10 @@ import type { Framework, } from "@btst/codegen/meta"; import { generateProject } from "@/app/actions"; -import { getEffectivePlugins } from "@/lib/plugin-selection"; +import { + getEffectivePlugins, + PLAYGROUND_UNSUPPORTED_PLUGINS, +} from "@/lib/plugin-selection"; import { PluginSelector } from "./plugin-selector"; import { RouteDrawer } from "./route-drawer"; import { StackBlitzEmbed } from "./stackblitz-embed"; @@ -247,7 +250,13 @@ export function PlaygroundClient({ onClick={() => setSelected( plugins - .filter((p) => p.key !== "route-docs") + .filter( + (p) => + p.key !== "route-docs" && + !PLAYGROUND_UNSUPPORTED_PLUGINS[ + p.key as PluginKey + ], + ) .map((p) => p.key as PluginKey), ) } @@ -264,6 +273,7 @@ export function PlaygroundClient({ onChange={setSelected} seededPlugins={seededPlugins} onSeedChange={(keys) => setSeededRaw(keys)} + unsupportedPlugins={PLAYGROUND_UNSUPPORTED_PLUGINS} />
diff --git a/playground/src/components/plugin-selector.tsx b/playground/src/components/plugin-selector.tsx index 3566f68db..f439d3d4d 100644 --- a/playground/src/components/plugin-selector.tsx +++ b/playground/src/components/plugin-selector.tsx @@ -10,6 +10,7 @@ interface PluginSelectorProps { seededPlugins: PluginKey[]; onSeedChange: (seeded: PluginKey[]) => void; disabled?: boolean; + unsupportedPlugins?: Partial>; } const PLUGIN_DESCRIPTIONS: Record = { @@ -32,6 +33,7 @@ export function PluginSelector({ seededPlugins, onSeedChange, disabled, + unsupportedPlugins = {}, }: PluginSelectorProps) { function toggle(key: PluginKey) { if (selected.includes(key)) { @@ -53,8 +55,10 @@ export function PluginSelector({
{plugins.map((plugin) => { const isRouteDocs = plugin.key === "route-docs"; + const unsupportedReason = unsupportedPlugins[plugin.key as PluginKey]; const isSelected = - isRouteDocs || selected.includes(plugin.key as PluginKey); + isRouteDocs || + (!unsupportedReason && selected.includes(plugin.key as PluginKey)); const isSeedable = Boolean(plugin.hasSeedData) && isSelected && !isRouteDocs; const isSeeded = @@ -63,14 +67,18 @@ export function PluginSelector({ \n
\n \n \n );\n })}\n append({})}\n className=\"mt-4 flex items-center\"\n >\n \n Add\n \n \n \n );\n}\n\n/**\n * PrimitiveAutoFormArray — does NOT use useFieldArray.\n *\n * useFieldArray wraps every element in an object `{ id: \"...\", }` which\n * corrupts primitive arrays (string[], number[], boolean[]). Instead we use\n * useWatch to observe the raw array and form.setValue to mutate it, keeping\n * the values as plain primitives that will pass Zod validation on submit.\n */\nfunction PrimitiveAutoFormArray({\n name,\n item,\n form,\n path = [],\n fieldConfig,\n itemDefType,\n title,\n}: {\n name: string;\n item: z.ZodArray | z.ZodDefault;\n form: ReturnType;\n path?: string[];\n fieldConfig?: any;\n itemDefType: z.ZodType | null;\n title: string;\n}) {\n const fieldPath = path.join(\".\");\n const rawValues: unknown[] = useWatch({ control: form.control, name: fieldPath }) ?? [];\n const values = Array.isArray(rawValues) ? rawValues : [];\n\n const appendItem = () => {\n const def = itemDefType ? getPrimitiveDefault(itemDefType) : \"\";\n form.setValue(fieldPath as any, [...values, def] as any, {\n shouldDirty: true,\n shouldValidate: false,\n });\n };\n\n const removeItem = (index: number) => {\n const next = values.filter((_, i) => i !== index);\n form.setValue(fieldPath as any, next as any, {\n shouldDirty: true,\n shouldValidate: false,\n });\n };\n\n return (\n \n {title}\n \n {values.map((_, index) => {\n const cellPath = `${fieldPath}.${index}`;\n return (\n
\n {itemDefType ? (\n \n ) : null}\n
\n removeItem(index)}\n >\n \n \n
\n \n
\n );\n })}\n \n \n Add\n \n
\n
\n );\n}\n\nfunction PrimitiveArrayRow({\n form,\n itemSchema,\n cellPath,\n}: {\n form: ReturnType;\n itemSchema: z.ZodType;\n cellPath: string;\n}) {\n const baseSchema = getBaseSchema(itemSchema) as z.ZodType | null;\n const t = baseSchema ? getBaseType(baseSchema) : \"\";\n\n if (t === \"ZodBoolean\") {\n return (\n (\n \n \n field.onChange(e.target.checked)}\n />\n \n \n {(field.value as boolean | undefined) ? \"Selected\" : \"Not selected\"}\n \n \n \n )}\n />\n );\n }\n\n const inputType = t === \"ZodNumber\" ? (\"number\" as const) : (\"text\" as const);\n\n return (\n (\n \n Row value\n \n \n field.onChange(\n inputType === \"number\"\n ? Number.isNaN(ev.target.valueAsNumber)\n ? undefined\n : ev.target.valueAsNumber\n : ev.target.value,\n )\n }\n />\n \n \n \n )}\n />\n );\n}\n\n", + "type": "registry:component", + "target": "components/ui/auto-form/fields/array.tsx" + }, + { + "path": "components/ui/auto-form/fields/checkbox.tsx", + "content": "import { Checkbox } from \"@/components/ui/checkbox\";\nimport { FormControl, FormItem } from \"@/components/ui/form\";\nimport AutoFormTooltip from \"../common/tooltip\";\nimport type { AutoFormInputComponentProps } from \"../types\";\nimport AutoFormLabel from \"../common/label\";\n\nexport default function AutoFormCheckbox({\n label,\n isRequired,\n field,\n fieldConfigItem,\n fieldProps,\n}: AutoFormInputComponentProps) {\n return (\n
\n \n
\n \n \n \n \n
\n
\n \n
\n );\n}\n", + "type": "registry:component", + "target": "components/ui/auto-form/fields/checkbox.tsx" + }, + { + "path": "components/ui/auto-form/fields/date.tsx", + "content": "import { DatePicker } from \"@/components/ui/date-picker\";\nimport { FormControl, FormItem, FormMessage } from \"@/components/ui/form\";\nimport AutoFormLabel from \"../common/label\";\nimport AutoFormTooltip from \"../common/tooltip\";\nimport type { AutoFormInputComponentProps } from \"../types\";\nimport { getBaseType } from \"../helpers\";\n\n/**\n * Convert a value to a Date object if needed.\n * Handles both Date objects (from z.date()) and ISO strings (from z.fromJSONSchema with format: date-time)\n */\nfunction toDate(value: unknown): Date | undefined {\n if (!value) return undefined;\n if (value instanceof Date) return value;\n if (typeof value === \"string\") {\n const date = new Date(value);\n return isNaN(date.getTime()) ? undefined : date;\n }\n return undefined;\n}\n\nexport default function AutoFormDate({\n label,\n isRequired,\n field,\n fieldConfigItem,\n fieldProps,\n zodItem,\n}: AutoFormInputComponentProps) {\n // Determine if the underlying schema expects a Date object or string\n // z.date() has base type \"ZodDate\", while z.fromJSONSchema with format: date-time creates a ZodString\n const baseType = getBaseType(zodItem);\n const expectsDateObject = baseType === \"ZodDate\";\n \n const handleChange = (date: Date | undefined) => {\n if (!date) {\n field.onChange(undefined);\n return;\n }\n // If the schema is z.date(), pass Date object\n // If the schema is string (from JSON Schema), pass ISO string\n if (expectsDateObject) {\n field.onChange(date);\n } else {\n field.onChange(date.toISOString());\n }\n };\n\n return (\n \n \n \n \n \n \n\n \n \n );\n}\n", + "type": "registry:component", + "target": "components/ui/auto-form/fields/date.tsx" + }, + { + "path": "components/ui/auto-form/fields/enum.tsx", + "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport {\n FormControl,\n FormItem,\n FormMessage,\n} from \"@/components/ui/form\";\nimport {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from \"@/components/ui/select\";\nimport * as z from \"zod\";\nimport AutoFormLabel from \"../common/label\";\nimport AutoFormTooltip from \"../common/tooltip\";\nimport type { AutoFormInputComponentProps } from \"../types\";\nimport { getBaseSchema } from \"../helpers\";\n\n/**\n * Get enum values from a ZodEnum schema.\n * In Zod v4, enum values are accessed via the .options property or .enum property.\n */\nfunction getEnumValues(schema: z.ZodEnum): string[] {\n // Zod v4: use .options or .enum to get the array of enum values\n if (Array.isArray((schema as any).options)) {\n return (schema as any).options;\n }\n // Fallback: try the .enum property which contains {value: value} entries\n if ((schema as any).enum) {\n return Object.values((schema as any).enum);\n }\n // Last resort: check _zod.def.entries\n const def = (schema as any)._zod?.def;\n if (def?.entries) {\n return Object.values(def.entries);\n }\n return [];\n}\n\nexport default function AutoFormEnum({\n label,\n isRequired,\n field,\n fieldConfigItem,\n zodItem,\n fieldProps,\n}: AutoFormInputComponentProps) {\n const baseSchema = getBaseSchema(zodItem) as unknown as z.ZodEnum;\n const baseValues = getEnumValues(baseSchema);\n\n let values: [string, string][] = [];\n if (!baseValues || baseValues.length === 0) {\n values = [];\n } else {\n values = baseValues.map((value: string) => [value, value]);\n }\n\n function findItem(value: any) {\n return values.find((item) => item[0] === value);\n }\n\n // Guard: Ignore empty value changes when a valid value is already set.\n // This prevents Radix Select from resetting the value during controlled value transitions.\n const handleValueChange = (val: string) => {\n if (val === \"\" && field.value && field.value !== \"\") {\n return; // Ignore spurious empty value callback\n }\n field.onChange(val);\n };\n\n return (\n \n \n \n \n \n \n {field.value ? findItem(field.value)?.[1] : \"Select an option\"}\n \n \n \n {values.map(([value, label]) => (\n \n {label}\n \n ))}\n \n \n \n \n \n \n );\n}\n", + "type": "registry:component", + "target": "components/ui/auto-form/fields/enum.tsx" + }, + { + "path": "components/ui/auto-form/fields/input.tsx", + "content": "import { FormControl, FormItem, FormMessage } from \"@/components/ui/form\";\nimport { Input } from \"@/components/ui/input\";\nimport AutoFormLabel from \"../common/label\";\nimport AutoFormTooltip from \"../common/tooltip\";\nimport type { AutoFormInputComponentProps } from \"../types\";\n\nexport default function AutoFormInput({\n label,\n isRequired,\n fieldConfigItem,\n fieldProps,\n}: AutoFormInputComponentProps) {\n const { showLabel: _showLabel, ...fieldPropsWithoutShowLabel } = fieldProps;\n const showLabel = _showLabel === undefined ? true : _showLabel;\n const type = fieldProps.type || \"text\";\n\n return (\n
\n \n {showLabel && (\n \n )}\n \n \n \n \n \n \n
\n );\n}\n", + "type": "registry:component", + "target": "components/ui/auto-form/fields/input.tsx" + }, + { + "path": "components/ui/auto-form/fields/number.tsx", + "content": "import { FormControl, FormItem, FormMessage } from \"@/components/ui/form\";\nimport { Input } from \"@/components/ui/input\";\nimport AutoFormLabel from \"../common/label\";\nimport AutoFormTooltip from \"../common/tooltip\";\nimport type { AutoFormInputComponentProps } from \"../types\";\n\nexport default function AutoFormNumber({\n label,\n isRequired,\n fieldConfigItem,\n fieldProps,\n}: AutoFormInputComponentProps) {\n const { showLabel: _showLabel, ...fieldPropsWithoutShowLabel } = fieldProps;\n const showLabel = _showLabel === undefined ? true : _showLabel;\n\n return (\n \n {showLabel && (\n \n )}\n \n \n \n \n \n \n );\n}\n", + "type": "registry:component", + "target": "components/ui/auto-form/fields/number.tsx" + }, + { + "path": "components/ui/auto-form/fields/object.tsx", + "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport {\n Accordion,\n AccordionContent,\n AccordionItem,\n AccordionTrigger,\n} from \"@/components/ui/accordion\";\nimport { FormField } from \"@/components/ui/form\";\nimport { useForm, useFormContext } from \"react-hook-form\";\nimport * as z from \"zod\";\nimport { DEFAULT_ZOD_HANDLERS, INPUT_COMPONENTS } from \"../config\";\nimport type { Dependency, FieldConfig, FieldConfigItem } from \"../types\";\nimport {\n beautifyObjectName,\n getBaseSchema,\n getBaseType,\n sortFieldsByOrder,\n zodToHtmlInputProps,\n} from \"../helpers\";\nimport AutoFormArray from \"./array\";\nimport resolveDependencies from \"../dependencies\";\n\nfunction DefaultParent({ children }: { children: React.ReactNode }) {\n return <>{children};\n}\n\nexport default function AutoFormObject<\n SchemaType extends z.ZodObject,\n>({\n schema,\n form,\n fieldConfig,\n path = [],\n dependencies = [],\n}: {\n schema: SchemaType | z.ZodType>;\n form: ReturnType;\n fieldConfig?: FieldConfig>;\n path?: string[];\n dependencies?: Dependency>[];\n}) {\n const { watch } = useFormContext(); // Use useFormContext to access the watch function\n\n if (!schema) {\n return null;\n }\n const { shape } = getBaseSchema(schema as SchemaType) || {};\n\n if (!shape) {\n return null;\n }\n\n const handleIfZodNumber = (item: z.ZodType) => {\n // Check for ZodNumber (Zod v4 uses type in _zod.def)\n const def = (item as any)._zod?.def;\n const defType = def?.type;\n const innerDefType = def?.innerType?._zod?.def?.type;\n\n const isZodNumber =\n defType === \"number\" || defType === \"int\" || defType === \"float\";\n const isInnerZodNumber =\n innerDefType === \"number\" ||\n innerDefType === \"int\" ||\n innerDefType === \"float\";\n\n if (isZodNumber && def) {\n def.coerce = true;\n } else if (isInnerZodNumber && def?.innerType?._zod?.def) {\n def.innerType._zod.def.coerce = true;\n }\n\n return item;\n };\n\n const sortedFieldKeys = sortFieldsByOrder(fieldConfig, Object.keys(shape));\n\n return (\n \n {sortedFieldKeys.map((name) => {\n let item = shape[name] as z.ZodType;\n item = handleIfZodNumber(item);\n const zodBaseType = getBaseType(item);\n const itemName = beautifyObjectName(name);\n const key = [...path, name].join(\".\");\n\n const {\n isHidden,\n isDisabled,\n isRequired: isRequiredByDependency,\n overrideOptions,\n } = resolveDependencies(dependencies, name, watch);\n if (isHidden) {\n return null;\n }\n\n if (zodBaseType === \"ZodObject\") {\n // Check if there's a custom fieldType for this object field\n // This allows relation fields (belongsTo) and other custom handlers to override default object behavior\n const objectFieldConfig: FieldConfigItem = fieldConfig?.[name] ?? {};\n if (typeof objectFieldConfig.fieldType === \"function\") {\n // Custom component for this object field - render it like a regular field\n const zodInputProps = zodToHtmlInputProps(item);\n // Determine required status (same logic as regular fields)\n let isRequired =\n isRequiredByDependency || zodInputProps.required || false;\n if (objectFieldConfig.inputProps?.required !== undefined) {\n isRequired = objectFieldConfig.inputProps.required;\n }\n const CustomComponent = objectFieldConfig.fieldType;\n const ParentElement =\n objectFieldConfig.renderParent ?? DefaultParent;\n return (\n {\n const fieldProps = {\n ...zodInputProps,\n ...field,\n ...objectFieldConfig.inputProps,\n disabled:\n objectFieldConfig.inputProps?.disabled || isDisabled,\n ref: undefined,\n value: field.value,\n };\n return (\n \n \n \n );\n }}\n />\n );\n }\n\n return (\n \n {itemName}\n \n }\n form={form}\n fieldConfig={(fieldConfig?.[name] ?? {}) as any}\n path={[...path, name]}\n />\n \n \n );\n }\n if (zodBaseType === \"ZodArray\") {\n // Check if there's a custom fieldType for this array field\n // This allows relation fields and other custom array handlers to override default array behavior\n const arrayFieldConfig: FieldConfigItem = fieldConfig?.[name] ?? {};\n if (typeof arrayFieldConfig.fieldType === \"function\") {\n // Custom component for this array field - render it like a regular field\n const zodInputProps = zodToHtmlInputProps(item);\n // Determine required status (same logic as regular fields)\n let isRequired =\n isRequiredByDependency || zodInputProps.required || false;\n if (arrayFieldConfig.inputProps?.required !== undefined) {\n isRequired = arrayFieldConfig.inputProps.required;\n }\n const CustomComponent = arrayFieldConfig.fieldType;\n const ParentElement = arrayFieldConfig.renderParent ?? DefaultParent;\n return (\n {\n const fieldProps = {\n ...zodInputProps,\n ...field,\n ...arrayFieldConfig.inputProps,\n disabled:\n arrayFieldConfig.inputProps?.disabled || isDisabled,\n ref: undefined,\n value: field.value,\n };\n return (\n \n \n \n );\n }}\n />\n );\n }\n\n return (\n }\n form={form}\n fieldConfig={arrayFieldConfig}\n path={[...path, name]}\n />\n );\n }\n\n const fieldConfigItem: FieldConfigItem = fieldConfig?.[name] ?? {};\n const zodInputProps = zodToHtmlInputProps(item);\n \n // Determine required status:\n // 1. If dependency sets required, use that\n // 2. If fieldConfig explicitly sets required (true/false), use that\n // 3. Otherwise, use zodInputProps.required\n let isRequired = isRequiredByDependency || zodInputProps.required || false;\n if (fieldConfigItem.inputProps?.required !== undefined) {\n isRequired = fieldConfigItem.inputProps.required;\n }\n\n if (overrideOptions) {\n item = z.enum(overrideOptions) as unknown as z.ZodType;\n }\n\n return (\n {\n const inputType =\n fieldConfigItem.fieldType ??\n DEFAULT_ZOD_HANDLERS[zodBaseType] ??\n \"fallback\";\n\n const InputComponent =\n typeof inputType === \"function\"\n ? inputType\n : INPUT_COMPONENTS[inputType];\n\n const ParentElement =\n fieldConfigItem.renderParent ?? DefaultParent;\n\n const defaultValue = fieldConfigItem.inputProps?.defaultValue;\n const value = field.value ?? defaultValue ?? \"\";\n\n const fieldProps = {\n ...zodToHtmlInputProps(item),\n ...field,\n ...fieldConfigItem.inputProps,\n disabled: fieldConfigItem.inputProps?.disabled || isDisabled,\n ref: undefined,\n value: value,\n };\n\n if (InputComponent === undefined) {\n return <>;\n }\n\n return (\n \n \n \n );\n }}\n />\n );\n })}\n \n );\n}\n", + "type": "registry:component", + "target": "components/ui/auto-form/fields/object.tsx" + }, + { + "path": "components/ui/auto-form/fields/radio-group.tsx", + "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport {\n FormControl,\n FormItem,\n FormLabel,\n FormMessage,\n} from \"@/components/ui/form\";\nimport { RadioGroup, RadioGroupItem } from \"@/components/ui/radio-group\";\nimport * as z from \"zod\";\nimport AutoFormLabel from \"../common/label\";\nimport AutoFormTooltip from \"../common/tooltip\";\nimport type { AutoFormInputComponentProps } from \"../types\";\nimport { getBaseSchema } from \"../helpers\";\n\n/**\n * Get enum values from a ZodEnum schema.\n * In Zod v4, enum values are accessed via the .options property or .enum property.\n */\nfunction getEnumValues(schema: z.ZodEnum): string[] {\n // Zod v4: use .options or .enum to get the array of enum values\n if (Array.isArray((schema as any).options)) {\n return (schema as any).options;\n }\n // Fallback: try the .enum property which contains {value: value} entries\n if ((schema as any).enum) {\n return Object.values((schema as any).enum);\n }\n // Last resort: check _zod.def.entries\n const def = (schema as any)._zod?.def;\n if (def?.entries) {\n return Object.values(def.entries);\n }\n return [];\n}\n\nexport default function AutoFormRadioGroup({\n label,\n isRequired,\n field,\n zodItem,\n fieldProps,\n fieldConfigItem,\n}: AutoFormInputComponentProps) {\n const baseSchema = getBaseSchema(zodItem) as unknown as z.ZodEnum;\n const values = getEnumValues(baseSchema);\n\n return (\n
\n \n \n \n \n {values?.map((value: string) => (\n \n \n \n \n {value}\n \n ))}\n \n \n \n \n \n
\n );\n}\n", + "type": "registry:component", + "target": "components/ui/auto-form/fields/radio-group.tsx" + }, + { + "path": "components/ui/auto-form/fields/switch.tsx", + "content": "import { FormControl, FormItem } from \"@/components/ui/form\";\nimport { Switch } from \"@/components/ui/switch\";\nimport AutoFormLabel from \"../common/label\";\nimport AutoFormTooltip from \"../common/tooltip\";\nimport type { AutoFormInputComponentProps } from \"../types\";\n\nexport default function AutoFormSwitch({\n label,\n isRequired,\n field,\n fieldConfigItem,\n fieldProps,\n}: AutoFormInputComponentProps) {\n return (\n
\n \n
\n \n \n \n \n
\n
\n \n
\n );\n}\n", + "type": "registry:component", + "target": "components/ui/auto-form/fields/switch.tsx" + }, + { + "path": "components/ui/auto-form/fields/textarea.tsx", + "content": "import { FormControl, FormItem, FormMessage } from \"@/components/ui/form\";\nimport { Textarea } from \"@/components/ui/textarea\";\nimport AutoFormLabel from \"../common/label\";\nimport AutoFormTooltip from \"../common/tooltip\";\nimport type { AutoFormInputComponentProps } from \"../types\";\n\nexport default function AutoFormTextarea({\n label,\n isRequired,\n fieldConfigItem,\n fieldProps,\n}: AutoFormInputComponentProps) {\n const { showLabel: _showLabel, ...fieldPropsWithoutShowLabel } = fieldProps;\n const showLabel = _showLabel === undefined ? true : _showLabel;\n return (\n \n {showLabel && (\n \n )}\n \n