diff --git a/.agents/skills/btst-build-config/SKILL.md b/.agents/skills/btst-build-config/SKILL.md index b2ac46ee0..45dc771dc 100644 --- a/.agents/skills/btst-build-config/SKILL.md +++ b/.agents/skills/btst-build-config/SKILL.md @@ -67,7 +67,9 @@ point, update ALL three generated projects: **Next.js** (`codegen-projects/nextjs/`) - `lib/stack.ts` — backend plugin registration - `lib/stack-client.tsx` — client plugin registration -- `app/pages/layout.tsx` — override configuration +- `app/pages/client-layout.tsx` — shared client provider and overrides +- `app/(request)/pages/layout.tsx` — trusted request-origin hydration +- `app/(static)/pages/layout.tsx` — header-free SSG/ISR provider wrapper - `app/globals.css` — `@import "@btst/stack/plugins/{name}/css";` **React Router** (`codegen-projects/react-router/`) diff --git a/.agents/skills/btst-integration/REFERENCE.md b/.agents/skills/btst-integration/REFERENCE.md index 3d1075065..4404a2905 100644 --- a/.agents/skills/btst-integration/REFERENCE.md +++ b/.agents/skills/btst-integration/REFERENCE.md @@ -3,15 +3,16 @@ ## lib/stack.ts shape ```ts -import { stack } from "@btst/stack" +import { createBackendStack } from "@btst/stack/api" import { createMemoryAdapter } from "@btst/adapter-memory" import { blogBackendPlugin } from "@btst/stack/plugins/blog/api" import { aiChatBackendPlugin } from "@btst/stack/plugins/ai-chat/api" +import { openai } from "@ai-sdk/openai" import { serverAuth } from "./authorization.server" // import more plugins… -function createStack() { - return stack({ +function createAppStack() { + return createBackendStack({ basePath: "/api/data", plugins: { blog: blogBackendPlugin({ @@ -33,17 +34,17 @@ function createStack() { } // Memory adapter + Next.js: pin the exact app type across API and page bundles. -type AppStack = ReturnType +type AppStack = ReturnType const g = globalThis as typeof globalThis & { __btst__?: AppStack } -export const myStack = g.__btst__ ??= createStack() +export const myStack = g.__btst__ ??= createAppStack() export const { handler, dbSchema } = myStack ``` **Rules:** -- For any real DB adapter (Drizzle, Prisma, Kysely, MongoDB), call the typed `createStack()` factory at module level — no `globalThis` needed. +- For any real DB adapter (Drizzle, Prisma, Kysely, MongoDB), call the typed `createBackendStack()` factory at module level — no `globalThis` needed. - Only pin to `globalThis` when using `@btst/adapter-memory` in Next.js. -- `access: "authorized"` requires a bound `serverAuth`. Omitting `stack({ auth })` intentionally preserves permissive compatibility and does not protect operations. +- `access: "authorized"` requires a bound `serverAuth`. Omitting `createBackendStack({ auth })` intentionally preserves permissive compatibility and does not protect operations. --- @@ -109,16 +110,20 @@ export const Route = createFileRoute("/api/data/$")({ ## Pages catch-all route -**Next.js** (`app/pages/[[...all]]/page.tsx`): +**Next.js** (`app/(request)/pages/[[...all]]/page.tsx`): ```tsx import { createNextPage } from "@btst/stack/next" +import { headers } from "next/headers" import { getOrCreateQueryClient } from "@/lib/query-client" -import { getStackClient } from "@/lib/stack-client" +import { getStackClientForRequest } from "@/lib/stack-client.server" export const dynamic = "force-dynamic" const page = createNextPage({ - getStackClient, + getStackClient: async (queryClient) => + getStackClientForRequest(queryClient, { + headers: new Headers(await headers()), + }), getQueryClient: getOrCreateQueryClient, }) export default page.Page @@ -131,12 +136,18 @@ export const generateMetadata = page.generateMetadata import { createReactRouterPage } from "@btst/stack/react-router" import { getOrCreateQueryClient } from "~/lib/query-client" import { getStackClient } from "~/lib/stack-client" +import { getStackClientForRequest } from "~/lib/stack-client.server" const page = createReactRouterPage({ getStackClient, getQueryClient: getOrCreateQueryClient, }) -export const loader = page.loader +export const loader = page.createLoader((queryClient, { request }) => + getStackClientForRequest(queryClient, { + headers: request.headers, + requestOrigin: new URL(request.url).origin, + }), +) export const meta = page.meta export const ErrorBoundary = page.ErrorBoundary export default page.Component @@ -147,10 +158,38 @@ export default page.Component ```tsx import { createFileRoute } from "@tanstack/react-router" import { createTanStackPageOptions } from "@btst/stack/tanstack" +import type { QueryClient } from "@tanstack/react-query" +import { createIsomorphicFn } from "@tanstack/react-start" +import { getRequest } from "@tanstack/react-start/server" +import { getOrCreateQueryClient } from "@/lib/query-client" import { getStackClient } from "@/lib/stack-client" +import { getStackClientForRequest } from "@/lib/stack-client.server" +import { getTrustedClientOrigins } from "@/lib/stack-client.origins" + +const getLoaderRequestContext = createIsomorphicFn() + .server(() => { + const request = getRequest() + return { + headers: request.headers, + requestOrigin: new URL(request.url).origin, + } + }) + .client(() => undefined) + +const getNavigationClientStack = async (queryClient: QueryClient) => + getStackClient(queryClient, await getTrustedClientOrigins()) export const Route = createFileRoute("/pages/$")( - createTanStackPageOptions({ getStackClient }), + createTanStackPageOptions({ + getStackClient, + getLoaderStackClient: async (queryClient) => { + const requestContext = await getLoaderRequestContext() + return requestContext + ? getStackClientForRequest(queryClient, requestContext) + : getNavigationClientStack(queryClient) + }, + getQueryClient: getOrCreateQueryClient, + }), ) ``` @@ -160,43 +199,42 @@ consumer routes. --- -## getBaseURL helper - -A server/client-safe URL helper for the resolved client stack runtime. - -```ts -// Next.js -const getBaseURL = () => - typeof window !== "undefined" - ? (process.env.NEXT_PUBLIC_BASE_URL || window.location.origin) - : (process.env.BASE_URL || "http://localhost:3000") - -// Vite (React Router / TanStack) -const getBaseURL = () => - typeof window !== "undefined" - ? (import.meta.env.VITE_BASE_URL || window.location.origin) - : (process.env.BASE_URL || "http://localhost:5173") -``` - ---- - ## lib/stack-client.tsx shape ```tsx -import { createClientStack } from "@btst/stack/client" +import { + createClientStack, + type ClientPluginEndpointOverride, +} from "@btst/stack/client" import { blogClientPlugin } from "@btst/stack/plugins/blog/client" -import { QueryClient } from "@tanstack/react-query" - -const getBaseURL = () => /* see above */ +import type { QueryClient } from "@tanstack/react-query" + +/** Browser-safe origins resolved once by the server layout. */ +export interface StackClientOptions { + /** Trusted destination for browser API requests. */ + apiOrigin?: string + /** Trusted public origin used to build application links. */ + siteOrigin?: string +} -export const getStackClient = ( +export function createAppClientStack( queryClient: QueryClient, - options?: { headers?: HeadersInit }, -) => { - const baseURL = getBaseURL() + options?: StackClientOptions & { headers?: HeadersInit }, +) { + const siteOrigin = getSiteOrigin(options?.siteOrigin) + const apiOrigin = getApiOrigin(options?.apiOrigin, siteOrigin) + const crossOriginBlogEndpoint = getCrossOriginBlogEndpoint( + apiOrigin, + siteOrigin, + ) + return createClientStack({ - api: { baseURL, basePath: "/api/data", headers: options?.headers }, - site: { baseURL, basePath: "/pages" }, + api: { + baseURL: apiOrigin, + basePath: "/api/data", + ...(options?.headers ? { headers: options.headers } : {}), + }, + site: { baseURL: siteOrigin, basePath: "/pages" }, queryClient, plugins: { blog: blogClientPlugin({ @@ -209,10 +247,152 @@ export const getStackClient = ( }), // add more plugins… }, + ...(crossOriginBlogEndpoint + ? { endpoints: { blog: crossOriginBlogEndpoint } } + : {}), + }) +} + +export function getStackClient( + queryClient: QueryClient, + options?: StackClientOptions, +) { + return createAppClientStack(queryClient, options) +} + +function getSiteOrigin(serverOrigin?: string) { + if (serverOrigin) return serverOrigin + if (typeof window !== "undefined") { + return ( + process.env.NEXT_PUBLIC_SITE_URL || + process.env.NEXT_PUBLIC_BASE_URL || + window.location.origin + ) + } + return ( + process.env.BTST_SITE_URL || + process.env.NEXT_PUBLIC_SITE_URL || + process.env.NEXT_PUBLIC_BASE_URL || + process.env.BASE_URL || + "http://localhost:3000" + ) +} + +function getApiOrigin(serverOrigin: string | undefined, siteOrigin: string) { + if (serverOrigin) return serverOrigin + if (typeof window !== "undefined") { + return ( + process.env.NEXT_PUBLIC_API_URL || + process.env.NEXT_PUBLIC_BASE_URL || + siteOrigin + ) + } + return ( + process.env.BTST_API_URL || + process.env.NEXT_PUBLIC_API_URL || + process.env.NEXT_PUBLIC_BASE_URL || + process.env.BASE_URL || + siteOrigin + ) +} + +function getCrossOriginBlogEndpoint(apiOrigin: string, siteOrigin: string) { + if (apiOrigin === siteOrigin) return undefined + return { + api: { + baseURL: apiOrigin, + basePath: "/api/data", + credentials: "include", + }, + } satisfies ClientPluginEndpointOverride +} +``` + +The CLI emits the equivalent Vite helper with +`VITE_PUBLIC_SITE_URL`/`VITE_PUBLIC_API_URL`. The server companion calls +`resolveTrustedClientOrigins()` and fails closed in production when it cannot +resolve a configured site/API origin. `BTST_API_URL` may point to a managed or +custom backend; the browser receives that same trusted snapshot instead of +reconstructing it from `window.location`. + +## lib/stack-client.server.ts shape + +Keep credential forwarding in a server-only helper. The configured API origin +wins even when a reverse proxy or custom frontend domain has a different +request origin. Missing production configuration fails closed; request-derived +fallbacks are accepted only for HTTP(S) loopback development. + +```ts +import { + filterCredentialForwardingHeaders, + resolveTrustedClientOrigins, +} from "@btst/stack/client/server" +import type { QueryClient } from "@tanstack/react-query" +import { createAppClientStack } from "./stack-client" + +function configuredApiOrigin() { + return ( + process.env.BTST_API_URL || + process.env.NEXT_PUBLIC_API_URL || + process.env.NEXT_PUBLIC_BASE_URL || + process.env.BASE_URL + ) +} + +function configuredSiteOrigin() { + return ( + process.env.BTST_SITE_URL || + process.env.NEXT_PUBLIC_SITE_URL || + process.env.NEXT_PUBLIC_BASE_URL || + process.env.BASE_URL + ) +} + +function requestOriginFromHeaders(headers: Headers) { + const host = (headers.get("x-forwarded-host") || headers.get("host")) + ?.split(",")[0] + ?.trim() + if (!host) return undefined + const protocol = + headers.get("x-forwarded-proto")?.split(",")[0]?.trim() || "http" + return `${protocol}://${host}` +} + +export function getServerClientOrigins(requestOrigin?: string) { + return resolveTrustedClientOrigins({ + configuredApiOrigin: configuredApiOrigin(), + configuredSiteOrigin: configuredSiteOrigin(), + requestOrigin, + isProduction: process.env.NODE_ENV === "production", + apiLabel: "BTST_API_URL, NEXT_PUBLIC_API_URL, NEXT_PUBLIC_BASE_URL, or BASE_URL", + siteLabel: "BTST_SITE_URL, NEXT_PUBLIC_SITE_URL, NEXT_PUBLIC_BASE_URL, or BASE_URL", }) } + +export function getServerClientOriginsFromHeaders(headers: HeadersInit) { + const requestHeaders = new Headers(headers) + return getServerClientOrigins(requestOriginFromHeaders(requestHeaders)) +} + +export function getStackClientForRequest( + queryClient: QueryClient, + options: { headers: HeadersInit; requestOrigin?: string }, +) { + const requestHeaders = new Headers(options.headers) + const origins = options.requestOrigin + ? getServerClientOrigins(options.requestOrigin) + : getServerClientOriginsFromHeaders(requestHeaders) + const headers = filterCredentialForwardingHeaders(requestHeaders) + return createAppClientStack(queryClient, { ...origins, headers }) +} ``` +Generated Vite helpers use the corresponding `VITE_PUBLIC_*` variables. Never +serialize request headers or a resolved server stack into a provider. +`NEXT_PUBLIC_BASE_URL` (or `VITE_BASE_URL`) remains a narrow +migration-compatible same-origin fallback; new deployments should prefer the +separate site/API variables above. + **Shared client stack fields:** | Field | Required | Description | @@ -221,10 +401,9 @@ export const getStackClient = ( | `site` | Yes | Site base URL and pages path | | `queryClient` | Yes | The QueryClient for this request | -Blog and new v3 client definitions receive only plugin-specific options such -as `seo`, `hooks`, and `pageComponents`. Unmigrated first-party plugins may -temporarily retain shared runtime fields until their migration tickets land; -do not use that compatibility shape for new definitions. +Client definitions receive only plugin-specific options such as `seo`, +`hooks`, and `pageComponents`. Shared API/site/QueryClient runtime belongs only +on `createClientStack()`. --- @@ -252,7 +431,7 @@ const clientAuth = createClientAuth({ ``` Create the backend adapter with `createServerAuth({ authorization, getIdentity })` -and pass it to `stack({ auth: serverAuth })`. Operations derive trusted facts and +and pass it to `createBackendStack({ auth: serverAuth })`. Operations derive trusted facts and evaluate exact permission descriptors before lifecycle hooks. Do not use hooks for routine authorization. Do not pass `currentUserId`, `loginHref`, request headers, API paths, or navigation functions to public @@ -263,23 +442,31 @@ are not plugin options, provider overrides, or component identity props. --- -## StackProvider — pages layout +## StackProvider — shared client layout -The pages layout must be `"use client"` and wrap `QueryClientProvider` then `StackProvider`. +The shared provider must be `"use client"` and wrap `QueryClientProvider` then +`StackProvider`. It receives only the trusted, serializable client origins from +the server wrapper. ```tsx -// Next.js: app/pages/layout.tsx +// Next.js: app/pages/client-layout.tsx "use client" -import { useState } from "react" +import { useMemo } from "react" import { QueryClientProvider } from "@tanstack/react-query" import { StackProvider } from "@btst/stack/context" import { nextRouter } from "@btst/stack/next" import { getOrCreateQueryClient } from "@/lib/query-client" -import { getStackClient } from "@/lib/stack-client" - -export default function PagesLayout({ children }: { children: React.ReactNode }) { - const [queryClient] = useState(() => getOrCreateQueryClient()) - const clientStack = getStackClient(queryClient) +import { getStackClient, type StackClientOptions } from "@/lib/stack-client" + +export default function PagesClientLayout({ children, clientOrigins }: { + children: React.ReactNode + clientOrigins: StackClientOptions +}) { + const queryClient = getOrCreateQueryClient() + const clientStack = useMemo( + () => getStackClient(queryClient, clientOrigins), + [clientOrigins.apiOrigin, clientOrigins.siteOrigin, queryClient], + ) return ( @@ -302,6 +489,71 @@ export default function PagesLayout({ children }: { children: React.ReactNode }) } ``` +The request wrapper at `app/(request)/pages/layout.tsx` calls +`getServerClientOriginsFromHeaders(await headers())`. The header-free wrapper at +`app/(static)/pages/layout.tsx` calls `getServerClientOrigins()` for SSG/ISR. +Both groups publish the same `/pages/*` URLs; never serialize the resolved +request stack or request headers into the client layout. + +React Router serializes the same snapshot from its layout loader and reuses it +for the provider: + +```tsx +import { StackProvider } from "@btst/stack/context" +import { reactRouter } from "@btst/stack/react-router" +import { QueryClientProvider } from "@tanstack/react-query" +import { useMemo } from "react" +import { + Outlet, + useLoaderData, + type LoaderFunctionArgs, +} from "react-router" +import { getOrCreateQueryClient } from "~/lib/query-client" +import { getStackClient } from "~/lib/stack-client" +import { getServerClientOrigins } from "~/lib/stack-client.server" + +export function loader({ request }: LoaderFunctionArgs) { + return getServerClientOrigins(new URL(request.url).origin) +} + +export default function BtstPagesLayout() { + const queryClient = getOrCreateQueryClient() + const { apiOrigin, siteOrigin } = useLoaderData() + const clientStack = useMemo( + () => getStackClient(queryClient, { apiOrigin, siteOrigin }), + [apiOrigin, queryClient, siteOrigin], + ) + return ( + + + + + + ) +} +``` + +TanStack Start exposes that server snapshot through a server function so both +initial hydration and later client navigation keep a managed API origin: + +```ts +// src/lib/stack-client.origins.ts +import { createServerFn } from "@tanstack/react-start" +import { getRequest } from "@tanstack/react-start/server" +import { getServerClientOrigins } from "./stack-client.server" + +export const getTrustedClientOrigins = createServerFn({ method: "GET" }) + .handler(() => { + const request = getRequest() + return getServerClientOrigins(new URL(request.url).origin) + }) +``` + +The `/pages` route loader returns `getTrustedClientOrigins()` and its provider +calls `getStackClient(queryClient, { apiOrigin, siteOrigin })`, exactly like the +React Router layout above. The TanStack catch-all route also calls the server +function during client navigation, as shown in the route example earlier. + ### StackProvider props | Prop | Required | Description | diff --git a/.agents/skills/btst-integration/SKILL.md b/.agents/skills/btst-integration/SKILL.md index 2dce1b10f..572be84a3 100644 --- a/.agents/skills/btst-integration/SKILL.md +++ b/.agents/skills/btst-integration/SKILL.md @@ -35,24 +35,24 @@ See [REFERENCE.md](REFERENCE.md) for full code shapes for every file. - Pass hooks and config in the backend plugin options object (e.g. `blogBackendPlugin({ hooks })`). - Use camelCase keys for plugins that have compound names: `aiChat`, `formBuilder`, `uiBuilder`. - **Client** (`lib/stack-client.tsx`): import `{plugin}ClientPlugin` from `@btst/stack/plugins/{plugin}/client`. - - Blog and new v3 client definitions receive only plugin-specific options such as `seo`, hooks, or page components. Configure `api`, `site`, and `queryClient` once on `createClientStack()`; its resolver binds that runtime to every definition. + - Client definitions receive only plugin-specific options such as `seo`, hooks, or page components. Configure `api`, `site`, and `queryClient` once on `createClientStack()`; its resolver binds that runtime to every definition. - Pass incoming SSR request headers through the top-level `createClientStack({ api: { headers } })` configuration. - - Some unmigrated first-party plugins temporarily still accept shared runtime fields. That compatibility shape lasts only until their migration tickets land; do not copy it into new definitions. + - Resolve one trusted API/site origin snapshot on the server and hydrate it into the provider. A managed backend may use a distinct `BTST_API_URL`; never reconstruct it from `window.location` after hydration. ### 3) Configure backend stack -- Call `stack({ basePath: "/api/data", plugins: { ... }, adapter: (db) => createXxxAdapter(..., db, {}), auth: serverAuth })` when application authorization is enabled. +- Call `createBackendStack({ basePath: "/api/data", plugins: { ... }, adapter: (db) => createXxxAdapter(..., db, {}), auth: serverAuth })` when application authorization is enabled. - Create `serverAuth` with `createServerAuth({ authorization, getIdentity })`. Omitting `auth` intentionally preserves permissive no-authorization compatibility; plugin labels such as AI Chat's `access: "authorized"` are not an enforcement boundary by themselves. - Export `handler` and `dbSchema`. - **Memory adapter + Next.js**: pin to `globalThis` to avoid two instances in the same process: ```ts - function createStack() { - return stack({ ... }) + function createAppStack() { + return createBackendStack({ ... }) } - type AppStack = ReturnType + type AppStack = ReturnType const g = globalThis as typeof globalThis & { __btst__?: AppStack } - export const myStack = g.__btst__ ??= createStack() + export const myStack = g.__btst__ ??= createAppStack() export const { handler, dbSchema } = myStack ``` @@ -104,9 +104,9 @@ Do not duplicate — the patcher and manual edits must both be idempotent. ## Validation checklist - `stack.ts` exports both `handler` and `dbSchema`. -- Protected applications pass `createServerAuth(...)` to `stack({ auth })`; omitted auth is intentionally permissive. +- Protected applications pass `createServerAuth(...)` to `createBackendStack({ auth })`; omitted auth is intentionally permissive. - Every plugin is registered on both backend and client sides. -- API `basePath` and `stack({ basePath })` match exactly. +- API `basePath` and `createBackendStack({ basePath })` match exactly. - API and page catch-all routes use the framework entry factories. - Pages layout is `"use client"` and wraps `QueryClientProvider` then `StackProvider`. - The resolved client stack's `site.basePath` matches the `/pages` catch-all route prefix. diff --git a/.agents/skills/btst-plugin-ssg/REFERENCE.md b/.agents/skills/btst-plugin-ssg/REFERENCE.md index 4570b2ddf..b0c27ef15 100644 --- a/.agents/skills/btst-plugin-ssg/REFERENCE.md +++ b/.agents/skills/btst-plugin-ssg/REFERENCE.md @@ -91,7 +91,7 @@ raw: (adapter) => ({ Static page that bypasses `route.loader()` and seeds the cache directly: ```tsx -// app/pages/my-plugin/page.tsx +// app/(static)/pages/my-plugin/page.tsx import { notFound } from "next/navigation" import { HydrationBoundary, dehydrate } from "@tanstack/react-query" import type { Metadata } from "next" @@ -130,6 +130,11 @@ export default async function Page() { } ``` +Keep the request catch-all in `app/(request)/pages/[[...all]]/page.tsx` and +static pages such as this one in `app/(static)/pages`. Both route groups retain +the `/pages/*` URL. Each group layout should wrap the shared client provider in +`app/pages/client-layout.tsx`; only the request layout may read request headers. + --- ## query-keys.ts — import from query-key-defs.ts diff --git a/.agents/skills/btst-plugin-ssg/SKILL.md b/.agents/skills/btst-plugin-ssg/SKILL.md index 9089d0b5a..c3d0b1360 100644 --- a/.agents/skills/btst-plugin-ssg/SKILL.md +++ b/.agents/skills/btst-plugin-ssg/SKILL.md @@ -27,7 +27,8 @@ description: Patterns for adding SSG (static site generation) support to BTST pl - **`useInfiniteQuery` lists** require `{ pages: [...], pageParams: [...] }` shape in `setQueryData`. Flat arrays break hydration. - **Share key builders** via `api/query-key-defs.ts` — never hardcode key shapes in two places. - **One-time init steps** (e.g. CMS `ensureSynced`) — call once at the top of `prefetchForRoute`; it's idempotent and safe for concurrent SSG. -- Place shared `StackProvider` layout at `app/pages/layout.tsx` (not inside `[[...all]]/`) so it applies to both SSG pages and the catch-all. +- In Next.js, keep request-aware routes under `app/(request)/pages` and SSG/ISR routes under `app/(static)/pages`. Both groups still publish `/pages/*` URLs. +- Put the reusable client `StackProvider` shell in `app/pages/client-layout.tsx`. Wrap it from a request layout that resolves origins from trusted request headers and a header-free static layout so SSG does not become dynamic. ## Plugins with SSG support diff --git a/.github/workflows/init.yml b/.github/workflows/init.yml index 12bc3172a..5feb97837 100644 --- a/.github/workflows/init.yml +++ b/.github/workflows/init.yml @@ -21,6 +21,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 - name: Setup pnpm uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 @@ -40,6 +42,9 @@ jobs: - name: Build @btst/codegen run: pnpm --filter @btst/codegen build + - name: Verify historical Next.js migration hashes + run: pnpm --filter @btst/codegen legacy-next-hashes:check + - name: Run init harness working-directory: packages/cli run: bash scripts/test-init.sh diff --git a/.github/workflows/registry.yml b/.github/workflows/registry.yml index a45e6be2b..737bc16ce 100644 --- a/.github/workflows/registry.yml +++ b/.github/workflows/registry.yml @@ -8,6 +8,7 @@ on: - 'packages/stack/scripts/build-registry.ts' - 'packages/stack/scripts/schema.ts' - 'packages/stack/scripts/test-registry.sh' + - 'packages/stack/scripts/fixtures/registry/**' - 'packages/ui/src/components/**' - 'packages/ui/src/hooks/**' - 'packages/ui/src/lib/**' diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7ee2728c4..f369e4ed8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -685,7 +685,7 @@ Then register it in the codegen project overlay files: **`scripts/codegen/files/nextjs/lib/stack-client.tsx`** — add the client plugin registration. -**`scripts/codegen/files/nextjs/app/pages/layout.tsx`** — add the `StackProvider` override entry. +**`scripts/codegen/files/nextjs/app/pages/client-layout.tsx`** — add the shared client `StackProvider` override entry. Keep trusted request-origin hydration in `app/(request)/pages/layout.tsx` and the header-free SSG/ISR wrapper in `app/(static)/pages/layout.tsx`. Add the plugin CSS to `app/globals.css` if it ships styles: @@ -912,7 +912,7 @@ Before opening a pull request for a new plugin, verify every item: - [ ] `packages/cli/src/utils/constants.ts` — `PLUGINS` array updated with new plugin entry - [ ] `scripts/codegen/files/nextjs/lib/stack.ts` — backend plugin registered - [ ] `scripts/codegen/files/nextjs/lib/stack-client.tsx` — client plugin registered -- [ ] `scripts/codegen/files/nextjs/app/pages/layout.tsx` — StackProvider overrides added +- [ ] `scripts/codegen/files/nextjs/app/pages/client-layout.tsx` — StackProvider overrides added; request/static wrappers remain origin-safe - [ ] Codegen project rebuilt and E2E passes: `bash scripts/codegen/setup-nextjs.sh && pnpm -F e2e codegen:e2e:nextjs` **Tests** diff --git a/README.md b/README.md index 1ab1c19c1..2f8616bee 100644 --- a/README.md +++ b/README.md @@ -64,35 +64,88 @@ You keep your codebase, database, and deployment. ## Minimal setup (Next.js) ```ts title="lib/stack.ts" -import { stack } from "@btst/stack" +import { createBackendStack } from "@btst/stack/api" import { blogBackendPlugin } from "@btst/stack/plugins/blog/api" import { createMemoryAdapter } from "@btst/adapter-memory" -export const { handler, dbSchema } = stack({ - basePath: "/api/data", - plugins: { - blog: blogBackendPlugin() - }, - adapter: (db) => createMemoryAdapter(db)({}) -}) +function createAppStack() { + return createBackendStack({ + basePath: "/api/data", + plugins: { + blog: blogBackendPlugin() + }, + adapter: (db) => createMemoryAdapter(db)({}) + }) +} + +type AppStack = ReturnType +const globalForStack = globalThis as typeof globalThis & { + __btst_stack__?: AppStack +} +export const myStack = globalForStack.__btst_stack__ ??= createAppStack() +export const { handler, dbSchema } = myStack ``` ```tsx title="lib/stack-client.tsx" -import { createClientStack } from "@btst/stack/client" +import { + createClientStack, + type ClientPluginEndpointOverride, +} from "@btst/stack/client" import { blogClientPlugin } from "@btst/stack/plugins/blog/client" -import { QueryClient } from "@tanstack/react-query" - -export const getStackClient = (queryClient: QueryClient) => - createClientStack({ - api: { baseURL: "http://localhost:3000", basePath: "/api/data" }, - site: { baseURL: "http://localhost:3000", basePath: "/pages" }, +import type { QueryClient } from "@tanstack/react-query" + +export interface StackClientOptions { + apiOrigin: string + siteOrigin: string +} + +export function createAppClientStack( + queryClient: QueryClient, + options: StackClientOptions & { headers?: HeadersInit }, +) { + const { apiOrigin, siteOrigin } = options + const crossOriginBlogEndpoint = apiOrigin === siteOrigin + ? undefined + : { + api: { + baseURL: apiOrigin, + basePath: "/api/data", + credentials: "include", + }, + } satisfies ClientPluginEndpointOverride + + return createClientStack({ + api: { + baseURL: apiOrigin, + basePath: "/api/data", + ...(options.headers ? { headers: options.headers } : {}), + }, + site: { baseURL: siteOrigin, basePath: "/pages" }, queryClient, plugins: { blog: blogClientPlugin() - } + }, + ...(crossOriginBlogEndpoint + ? { endpoints: { blog: crossOriginBlogEndpoint } } + : {}), }) +} + +export function getStackClient( + queryClient: QueryClient, + options: StackClientOptions, +) { + return createAppClientStack(queryClient, options) +} ``` +The generated `lib/stack-client.server.ts` resolves these origins from trusted +deployment configuration (`BTST_API_URL` and `BTST_SITE_URL`), defaults the API +to the trusted site origin, and forwards filtered credentials only to that API. +It fails closed in production if no trusted origin is available. +Existing same-origin Next.js installs may keep `NEXT_PUBLIC_BASE_URL` while +migrating; new deployments should prefer the separate site/API variables. + Use the v3 framework entry factories for the two catch-all routes: ```ts title="app/api/data/[[...all]]/route.ts" @@ -103,32 +156,59 @@ export const { GET, POST, PUT, PATCH, DELETE } = toNextRouteHandlers(handler) ``` -```tsx title="app/pages/[[...all]]/page.tsx" +```tsx title="app/(request)/pages/[[...all]]/page.tsx" import { createNextPage } from "@btst/stack/next" -import { getStackClient } from "@/lib/stack-client" +import { headers } from "next/headers" +import { getStackClientForRequest } from "@/lib/stack-client.server" import { getOrCreateQueryClient } from "@/lib/query-client" +export const dynamic = "force-dynamic" + const page = createNextPage({ - getStackClient, + getStackClient: async (queryClient) => + getStackClientForRequest(queryClient, { + headers: new Headers(await headers()), + }), getQueryClient: getOrCreateQueryClient, }) export default page.Page export const generateMetadata = page.generateMetadata ``` +The request layout at `app/(request)/pages/layout.tsx` hydrates trusted client +origins into the shared provider in `app/pages/client-layout.tsx`. Put SSG/ISR +routes under `app/(static)/pages` with a header-free layout; route groups do not +change the public `/pages/*` URLs. + Wrap the pages subtree with one `StackProvider`: ```tsx -const clientStack = getStackClient(queryClient) - - - {children} - +// app/pages/client-layout.tsx +"use client" + +function PagesClientLayout({ children, clientOrigins }: { + children: React.ReactNode + clientOrigins: StackClientOptions +}) { + const queryClient = getOrCreateQueryClient() + const clientStack = useMemo( + () => getStackClient(queryClient, clientOrigins), + [clientOrigins.apiOrigin, clientOrigins.siteOrigin, queryClient], + ) + + return ( + + + {children} + + + ) +} ``` API, site, and QueryClient runtime belong on the resolved client stack; router diff --git a/biome.json b/biome.json index dfa490efa..bd2eeb002 100644 --- a/biome.json +++ b/biome.json @@ -64,6 +64,8 @@ "!**/.btst-stack-src/**", "!**/.btst-stack-ui/**", "!**/packages/stack/registry/**", + "!**/packages/stack/scripts/fixtures/registry/**", + "!**/packages/cli/scripts/fixtures/legacy-next/**", "!**/codegen-projects/**", "!**/playwright-report-codegen/trace/**" ] diff --git a/docs/content/docs/auth.mdx b/docs/content/docs/auth.mdx index 3e4168bdf..937d6fc3b 100644 --- a/docs/content/docs/auth.mdx +++ b/docs/content/docs/auth.mdx @@ -286,15 +286,24 @@ layout through the server-only framework entry: import { StackProvider } from "@btst/stack/context"; import { clientAuth } from "@/lib/authorization.client"; +import { getOrCreateQueryClient } from "@/lib/query-client"; +import { getStackClient, type StackClientOrigins } from "@/lib/stack-client"; import type { ReactNode } from "react"; +import { useMemo } from "react"; -export function BtstPagesClientLayout({ children, initialIdentity }: { +export function BtstPagesClientLayout({ children, clientOrigins, initialIdentity }: { children?: ReactNode; + clientOrigins?: StackClientOrigins; initialIdentity?: Awaited>; }) { + const queryClient = getOrCreateQueryClient(); + const stack = useMemo( + () => getStackClient(queryClient, clientOrigins), + [clientOrigins?.apiOrigin, clientOrigins?.siteOrigin, queryClient], + ); return ( @@ -308,18 +317,21 @@ export function BtstPagesClientLayout({ children, initialIdentity }: { import { createNextLayout } from "@btst/stack/next/server"; import { serverAuth } from "@/lib/authorization.server"; import { BtstPagesClientLayout } from "@/app/pages/client-layout"; +import { getRequestClientOrigins } from "@/lib/stack-client.server"; export const dynamic = "force-dynamic"; const layout = createNextLayout({ auth: serverAuth, ClientLayout: BtstPagesClientLayout, + resolveClientOrigins: getRequestClientOrigins, }); export default layout.Layout; ``` -Only the schema-validated identity crosses the Server Component boundary. +Only the schema-validated identity and deployment-trusted API/site origins +cross the Server Component boundary. `next/headers`, the session provider, and database dependencies stay in the server graph. Because the layout reads request headers, Next.js must render its subtree per request. If the application also has SSG/ISR pages, put them in a @@ -330,18 +342,24 @@ subtree. ```tsx title="app/(static)/pages/layout.tsx" import { BtstPagesClientLayout } from "@/app/pages/client-layout"; +import { getServerClientOrigins } from "@/lib/stack-client.server"; import type { ReactNode } from "react"; export default function StaticPagesLayout({ children }: { children?: ReactNode }) { - // Omitting the prop preserves `undefined`: pending, then browser resolution. - return {children}; + // Identity stays undefined; trusted origins are embedded in static output. + return ( + + {children} + + ); } ``` Route-group folders do not change URLs: both subtrees still render under `/pages`. True static output cannot contain a per-request identity; choosing the static group intentionally chooses the `undefined` branch of the tri-state -contract. +contract. Both route groups still hydrate the same server-resolved API/site +snapshot, including when `BTST_API_URL` points at a managed backend. This integration targets conventional Next.js route-segment caching with `cacheComponents` disabled. Cache Components ignores `dynamic` segment config @@ -356,23 +374,40 @@ subtree: ```tsx title="app/routes/pages/_layout.tsx" import { StackProvider } from "@btst/stack/context"; import { createReactRouterLayout } from "@btst/stack/react-router"; -import { Outlet, useLoaderData } from "react-router"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { useMemo } from "react"; +import { Outlet, useLoaderData, type LoaderFunctionArgs } from "react-router"; import { clientAuth } from "~/lib/authorization.ui"; import { serverAuth } from "~/lib/authorization.server"; +import { getOrCreateQueryClient } from "~/lib/query-client"; +import { getStackClient } from "~/lib/stack-client"; +import { getRequestClientOrigins } from "~/lib/stack-client.server"; const layout = createReactRouterLayout({ auth: serverAuth }); -export const loader = layout.loader; +export async function loader(args: LoaderFunctionArgs) { + return { + ...(await layout.loader(args)), + ...getRequestClientOrigins(args.request), + }; +} export default function BtstPagesLayout() { - const { initialIdentity } = useLoaderData(); + const { apiOrigin, initialIdentity, siteOrigin } = useLoaderData(); + const queryClient = getOrCreateQueryClient(); + const stack = useMemo( + () => getStackClient(queryClient, { apiOrigin, siteOrigin }), + [apiOrigin, queryClient, siteOrigin], + ); return ( - - - + + + + + ); } ``` @@ -388,21 +423,31 @@ import { createServerFn } from "@tanstack/react-start"; import { getRequest } from "@tanstack/react-start/server"; import { resolveTanStackInitialIdentity } from "@btst/stack/tanstack/server"; import { serverAuth } from "./authorization.server"; +import { getRequestClientOrigins } from "./stack-client.server"; export const getInitialIdentity = createServerFn({ method: "GET" }).handler( - () => resolveTanStackInitialIdentity({ - auth: serverAuth, - request: getRequest(), - }), + async () => { + const request = getRequest(); + return { + ...(await resolveTanStackInitialIdentity({ + auth: serverAuth, + request, + })), + ...getRequestClientOrigins(request), + }; + }, ); ``` ```tsx title="src/routes/pages/route.tsx" import { StackProvider } from "@btst/stack/context"; import { createTanStackLayout } from "@btst/stack/tanstack"; +import { QueryClientProvider } from "@tanstack/react-query"; import { Outlet, createFileRoute } from "@tanstack/react-router"; +import { useMemo } from "react"; import { clientAuth } from "../../lib/authorization.ui"; import { getInitialIdentity } from "../../lib/authorization.identity"; +import { getStackClient } from "../../lib/stack-client"; const layout = createTanStackLayout({ getInitialIdentity }); @@ -412,21 +457,37 @@ export const Route = createFileRoute("/pages")({ }); function BtstPagesLayout() { - const { initialIdentity } = Route.useLoaderData(); + const { queryClient } = Route.useRouteContext(); + const { apiOrigin, initialIdentity, siteOrigin } = Route.useLoaderData(); + const stack = useMemo( + () => getStackClient(queryClient, { apiOrigin, siteOrigin }), + [apiOrigin, queryClient, siteOrigin], + ); return ( - - - + + + + + ); } ``` -This is identity hydration only. BTST does not cache authorization results or -require framework middleware. +Only the validated identity and deployment-trusted API/site origins cross the +server boundary. Raw `Host`/forwarding values, request headers, and the resolved +server stack stay server-only. Configure `BTST_API_URL` and `BTST_SITE_URL`, or +use `BASE_URL` when both are same-origin; production fails closed without a +trusted site origin. BTST does not cache authorization results or require +framework middleware. + +TanStack catch-all loaders should also call `getInitialIdentity()` from their +client navigation branch and pass its `apiOrigin`/`siteOrigin` to +`getStackClient`. That server function keeps later navigations on the same +trusted managed API selected during SSR. ## Operation lifecycle ordering diff --git a/docs/content/docs/breaking-changes.mdx b/docs/content/docs/breaking-changes.mdx index bdde3711a..8e26535e2 100644 --- a/docs/content/docs/breaking-changes.mdx +++ b/docs/content/docs/breaking-changes.mdx @@ -89,10 +89,10 @@ QueryClient once in a shared factory: ```ts title="lib/stack-client.ts" export const getStackClient = ( queryClient: QueryClient, - options?: { headers?: HeadersInit }, + options: { apiOrigin: string; siteOrigin: string }, ) => createClientStack({ - api: { baseURL, basePath: "/api/data", headers: options?.headers }, - site: { baseURL, basePath: "/pages" }, + api: { baseURL: options.apiOrigin, basePath: "/api/data" }, + site: { baseURL: options.siteOrigin, basePath: "/pages" }, queryClient, plugins: { blog: blogClientPlugin() }, }) @@ -100,11 +100,16 @@ export const getStackClient = ( Create a request-specific stack for server loaders and metadata: -```ts title="app/pages/[[...all]]/page.tsx" +```ts title="app/(request)/pages/[[...all]]/page.tsx" +import { headers } from "next/headers" +import { getStackClientForRequest } from "@/lib/stack-client.server" + const page = createNextPage({ getQueryClient: getOrCreateQueryClient, getStackClient: async (queryClient) => - getStackClient(queryClient, { headers: await headers() }), + getStackClientForRequest(queryClient, { + headers: new Headers(await headers()), + }), }) ``` @@ -112,14 +117,14 @@ Create a separate, stable browser stack inside the Client Component that owns the provider. Do not pass or serialize the request stack; it contains functions and server-only request headers. -```tsx title="app/pages/layout.tsx" +```tsx title="app/pages/client-layout.tsx" "use client" -export default function PagesLayout({ children }) { +export default function PagesClientLayout({ children, clientOrigins }) { const [queryClient] = useState(() => getOrCreateQueryClient()) const clientStack = useMemo( - () => getStackClient(queryClient), - [queryClient], + () => getStackClient(queryClient, clientOrigins), + [clientOrigins.apiOrigin, clientOrigins.siteOrigin, queryClient], ) return ( @@ -137,6 +142,11 @@ export default function PagesLayout({ children }) { } ``` +Wrap this client provider from `app/(request)/pages/layout.tsx` using +`getServerClientOriginsFromHeaders(await headers())`. Put SSG/ISR routes under +`app/(static)/pages` and use header-free `getServerClientOrigins()` there. Both +route groups keep the `/pages/*` URL. + For Blog, `apiBaseURL`, `apiBasePath`, site fields, `queryClient`, and request headers are no longer plugin options. Its SSR loaders, metadata, browser hooks, and mutations use the same resolved runtime. Some other first-party plugins diff --git a/docs/content/docs/installation.mdx b/docs/content/docs/installation.mdx index bce1e2511..d3407a7ac 100644 --- a/docs/content/docs/installation.mdx +++ b/docs/content/docs/installation.mdx @@ -460,23 +460,55 @@ In order to use BTST, your application must meet the following requirements: Create a client instance that routes requests to plugin pages, prefetches their data on the server, and renders them with instant hydration on the client: ```tsx title="lib/stack-client.tsx" - import { createClientStack } from "@btst/stack/client" + import { + createClientStack, + type ClientPluginEndpointOverride, + } from "@btst/stack/client" import { blogClientPlugin } from "@btst/stack/plugins/blog/client" - import { QueryClient } from "@tanstack/react-query" + import type { QueryClient } from "@tanstack/react-query" - export const getStackClient = (queryClient: QueryClient) => { - const baseURL = - typeof window === "undefined" + interface StackClientOptions { + apiOrigin?: string + siteOrigin?: string + } + + export const getStackClient = ( + queryClient: QueryClient, + options?: StackClientOptions, + ) => { + const siteOrigin = + options?.siteOrigin || + process.env.NEXT_PUBLIC_SITE_URL || + process.env.NEXT_PUBLIC_BASE_URL || + (typeof window === "undefined" ? process.env.BASE_URL || "http://localhost:3000" - : window.location.origin + : window.location.origin) + const apiOrigin = + options?.apiOrigin || + process.env.NEXT_PUBLIC_API_URL || + process.env.NEXT_PUBLIC_BASE_URL || + siteOrigin + const crossOriginApiEndpoint = + apiOrigin === siteOrigin + ? undefined + : ({ + api: { + baseURL: apiOrigin, + basePath: "/api/data", + credentials: "include", + }, + } satisfies ClientPluginEndpointOverride) return createClientStack({ - api: { baseURL, basePath: "/api/data" }, - site: { baseURL, basePath: "/pages" }, + api: { baseURL: apiOrigin, basePath: "/api/data" }, + site: { baseURL: siteOrigin, basePath: "/pages" }, queryClient, plugins: { blog: blogClientPlugin(), - } + }, + ...(crossOriginApiEndpoint + ? { endpoints: { blog: crossOriginApiEndpoint } } + : {}), }) } ``` @@ -490,6 +522,26 @@ In order to use BTST, your application must meet the following requirements: runtime. Do not copy those services into plugin configuration or provider overrides. + + + Credentialed SSR stacks must resolve their API destination from + deployment configuration, never from `Host`, forwarding headers, or + `request.url`. Set `BTST_API_URL` for a managed/custom API and + `BTST_SITE_URL` for the public site. For a same-origin deployment, + `BASE_URL` can provide both. Generated `stack-client.server.ts` uses + `resolveTrustedClientOrigins` from `@btst/stack/client/server`, fails + closed when production configuration is missing, and removes routing and + hop-by-hop headers before forwarding the remaining request credentials. + The browser stack above separately opts each API-owning plugin into + `credentials: "include"` only when the trusted API and site origins differ. + A managed backend that uses cookies must allow the public site origin and + credentialed requests in its CORS policy. + + Existing Next.js scaffolds may keep `NEXT_PUBLIC_BASE_URL` as a narrow + same-origin migration fallback when rerunning `btst init`. Prefer + `NEXT_PUBLIC_SITE_URL` plus `NEXT_PUBLIC_API_URL` (or server-only + `BTST_SITE_URL`/`BTST_API_URL`) for new deployment configuration. + @@ -563,20 +615,26 @@ In order to use BTST, your application must meet the following requirements: - ```tsx title="app/pages/layout.tsx" + ```tsx title="app/pages/client-layout.tsx" "use client" - import { useState } from "react" + import { useMemo, useState } from "react" import { QueryClientProvider } from "@tanstack/react-query" import { StackProvider } from "@btst/stack/context" import { nextRouter } from "@btst/stack/next" import { getOrCreateQueryClient } from "@/lib/query-client" - import { getStackClient } from "@/lib/stack-client" + import { getStackClient, type StackClientOptions } from "@/lib/stack-client" import { uploadImage } from "@/lib/uploads" - export default function PagesLayout({ children }) { + export function PagesClientLayout({ children, clientOrigins }: { + children: React.ReactNode + clientOrigins: StackClientOptions + }) { const [queryClient] = useState(() => getOrCreateQueryClient()) - const clientStack = getStackClient(queryClient) + const clientStack = useMemo( + () => getStackClient(queryClient, clientOrigins), + [clientOrigins.apiOrigin, clientOrigins.siteOrigin, queryClient], + ) return ( @@ -591,22 +649,51 @@ In order to use BTST, your application must meet the following requirements: ) } ``` + + ```tsx title="app/(request)/pages/layout.tsx" + import { headers } from "next/headers" + import { PagesClientLayout } from "../../pages/client-layout" + import { getServerClientOriginsFromHeaders } from "@/lib/stack-client.server" + + export default async function PagesLayout({ children }) { + const clientOrigins = getServerClientOriginsFromHeaders(await headers()) + return ( + + {children} + + ) + } + ``` + + Put SSG/ISR routes under `app/(static)/pages` and give that group a + header-free layout using `getServerClientOrigins()`. Both route groups + keep the `/pages/*` URL; the split prevents request headers from making + static routes dynamic. ```tsx title="app/routes/pages/_layout.tsx" - import { useState } from "react" - import { Outlet } from "react-router" + import { useMemo, useState } from "react" + import { Outlet, useLoaderData, type LoaderFunctionArgs } from "react-router" import { QueryClientProvider } from "@tanstack/react-query" import { StackProvider } from "@btst/stack/context" import { reactRouter } from "@btst/stack/react-router" import { getOrCreateQueryClient } from "~/lib/query-client" import { getStackClient } from "~/lib/stack-client" + import { getServerClientOrigins } from "~/lib/stack-client.server" import { uploadImage } from "~/lib/uploads" + export function loader({ request }: LoaderFunctionArgs) { + return getServerClientOrigins(new URL(request.url).origin) + } + export default function PagesLayout() { const [queryClient] = useState(() => getOrCreateQueryClient()) - const clientStack = getStackClient(queryClient) + const { apiOrigin, siteOrigin } = useLoaderData() + const clientStack = useMemo( + () => getStackClient(queryClient, { apiOrigin, siteOrigin }), + [apiOrigin, queryClient, siteOrigin], + ) return ( @@ -629,16 +716,23 @@ In order to use BTST, your application must meet the following requirements: import { QueryClientProvider } from "@tanstack/react-query" import { StackProvider } from "@btst/stack/context" import { tanstackRouter } from "@btst/stack/tanstack" + import { useMemo } from "react" import { getStackClient } from "@/lib/stack-client" + import { getTrustedClientOrigins } from "@/lib/stack-client.origins" import { uploadImage } from "@/lib/uploads" export const Route = createFileRoute("/pages")({ + loader: async () => getTrustedClientOrigins(), component: PagesLayout, }) function PagesLayout() { const { queryClient } = Route.useRouteContext() - const clientStack = getStackClient(queryClient) + const { apiOrigin, siteOrigin } = Route.useLoaderData() + const clientStack = useMemo( + () => getStackClient(queryClient, { apiOrigin, siteOrigin }), + [apiOrigin, queryClient, siteOrigin], + ) return ( @@ -658,7 +752,12 @@ In order to use BTST, your application must meet the following requirements: Add `auth`, `notify`, or `i18n` beside `stack` and `router` when your application needs them. See the [auth provider guide](/auth). Never copy - framework routing, API paths, or identity props into a plugin override. + framework routing, API paths, or identity props into a plugin override. On + Vite SSR, serialize only the trusted API/site origins into the parent loader + so the server provider and hydrated browser stack resolve the same endpoints. + TanStack client-navigation loaders should call the same generated + `getTrustedClientOrigins()` server function. Keep request headers and server + stack instances out of loader data. @@ -668,14 +767,21 @@ In order to use BTST, your application must meet the following requirements: - ```tsx title="app/pages/[[...all]]/page.tsx" + ```tsx title="app/(request)/pages/[[...all]]/page.tsx" import { createNextPage } from "@btst/stack/next" + import { headers } from "next/headers" import { getOrCreateQueryClient } from "@/lib/query-client" - import { getStackClient } from "@/lib/stack-client" + import { getStackClientForRequest } from "@/lib/stack-client.server" export const dynamic = "force-dynamic" - const page = createNextPage({ getStackClient, getQueryClient: getOrCreateQueryClient }) + const page = createNextPage({ + getStackClient: async (queryClient) => + getStackClientForRequest(queryClient, { + headers: new Headers(await headers()), + }), + getQueryClient: getOrCreateQueryClient, + }) export default page.Page export const generateMetadata = page.generateMetadata ``` @@ -738,7 +844,7 @@ In order to use BTST, your application must meet the following requirements: import { headers } from "next/headers" import { createNextPage } from "@btst/stack/next" import { getOrCreateQueryClient } from "@/lib/query-client" - import { getStackClientForRequest } from "@/lib/stack-client" + import { getStackClientForRequest } from "@/lib/stack-client.server" const page = createNextPage({ getQueryClient: getOrCreateQueryClient, @@ -776,6 +882,7 @@ In order to use BTST, your application must meet the following requirements: async (queryClient, { request, context, params }) => getStackClientForRequest(queryClient, { headers: request.headers, + requestOrigin: new URL(request.url).origin, context, params, }), diff --git a/docs/content/docs/plugins/ai-chat.mdx b/docs/content/docs/plugins/ai-chat.mdx index 2c48d1bba..c2b0e77e2 100644 --- a/docs/content/docs/plugins/ai-chat.mdx +++ b/docs/content/docs/plugins/ai-chat.mdx @@ -209,18 +209,24 @@ and AI Chat-specific values on the provider: - ```tsx title="app/pages/layout.tsx" + ```tsx title="app/pages/client-layout.tsx" "use client" - import { useState } from "react" + import { useMemo } from "react" import { StackProvider } from "@btst/stack/context" import { nextRouter } from "@btst/stack/next" import { QueryClientProvider } from "@tanstack/react-query" import { getOrCreateQueryClient } from "@/lib/query-client" - import { getStackClient } from "@/lib/stack-client" - - export default function Layout({ children }) { - const [queryClient] = useState(() => getOrCreateQueryClient()) - const stack = getStackClient(queryClient) + import { getStackClient, type StackClientOptions } from "@/lib/stack-client" + + export default function Layout({ children, clientOrigins }: { + children: React.ReactNode + clientOrigins: StackClientOptions + }) { + const queryClient = getOrCreateQueryClient() + const stack = useMemo( + () => getStackClient(queryClient, clientOrigins), + [clientOrigins.apiOrigin, clientOrigins.siteOrigin, queryClient], + ) return ( @@ -919,7 +925,7 @@ The default `ToolCallDisplay` component shows: Provide custom UI components for specific tools using the `toolRenderers` override. Each key should match the tool name from your backend configuration: -```tsx title="app/pages/layout.tsx" +```tsx title="app/pages/client-layout.tsx" import type { ToolCallProps } from "@btst/stack/plugins/ai-chat/client" // Custom weather card component diff --git a/docs/content/docs/plugins/blog.mdx b/docs/content/docs/plugins/blog.mdx index 65a93ca1f..04bcfbdd0 100644 --- a/docs/content/docs/plugins/blog.mdx +++ b/docs/content/docs/plugins/blog.mdx @@ -138,21 +138,24 @@ through a Client Component prop. - ```tsx title="app/pages/[[...all]]/layout.tsx" + ```tsx title="app/pages/client-layout.tsx" "use client" - import { useMemo, useState } from "react" + import { useMemo } from "react" import { QueryClientProvider } from "@tanstack/react-query" import { StackProvider } from "@btst/stack/context" import { nextRouter } from "@btst/stack/next" - import { getStackClient } from "@/lib/stack-client" + import { getStackClient, type StackClientOptions } from "@/lib/stack-client" import { getOrCreateQueryClient } from "@/lib/query-client" - export default function Layout({ children }) { - const [queryClient] = useState(() => getOrCreateQueryClient()) + export default function Layout({ children, clientOrigins }: { + children: React.ReactNode + clientOrigins: StackClientOptions + }) { + const queryClient = getOrCreateQueryClient() const clientStack = useMemo( - () => getStackClient(queryClient), - [queryClient], + () => getStackClient(queryClient, clientOrigins), + [clientOrigins.apiOrigin, clientOrigins.siteOrigin, queryClient], ) return ( diff --git a/docs/content/docs/plugins/cms.mdx b/docs/content/docs/plugins/cms.mdx index 280ec1cc5..9eaf13da2 100644 --- a/docs/content/docs/plugins/cms.mdx +++ b/docs/content/docs/plugins/cms.mdx @@ -188,10 +188,10 @@ deployed backend owns the catalog. Pass the resolved client stack to the provider. Override inference comes from the registered CMS definition, so no manual override map or provider generic is needed: -```tsx title="app/pages/layout.tsx" +```tsx title="app/pages/client-layout.tsx" import { nextRouter } from "@btst/stack/next" -const stack = getStackClient(queryClient) +const stack = getStackClient(queryClient, clientOrigins) defineBackendPlugin({ The consumer creates a dedicated static page outside `[[...all]]/` that calls `prefetchForRoute` instead of `route.loader()`: ```tsx -// app/pages/todos/page.tsx +// app/(static)/pages/todos/page.tsx import { dehydrate, HydrationBoundary } from "@tanstack/react-query" import { notFound } from "next/navigation" import { getOrCreateQueryClient } from "@/lib/query-client" @@ -718,7 +718,12 @@ export default async function TodosPage() { ``` -The shared `StackProvider` layout must live at `app/pages/layout.tsx` (not inside `[[...all]]/layout.tsx`) so it applies to both the catch-all routes and these specific SSG pages. +Keep request-aware routes under `app/(request)/pages` and SSG/ISR routes under +`app/(static)/pages`; both groups still publish `/pages/*` URLs. Put the shared +client `StackProvider` shell in `app/pages/client-layout.tsx`. The request group +layout resolves trusted request origins, while the static group layout calls +the header-free `getServerClientOrigins()` so reading headers cannot make SSG +routes dynamic. #### 5. ISR cache invalidation diff --git a/docs/content/docs/plugins/form-builder.mdx b/docs/content/docs/plugins/form-builder.mdx index 076040b7a..be6c329fa 100644 --- a/docs/content/docs/plugins/form-builder.mdx +++ b/docs/content/docs/plugins/form-builder.mdx @@ -145,7 +145,7 @@ server call site. Add Form Builder overrides to your layout: -```tsx title="app/pages/layout.tsx" +```tsx title="app/pages/client-layout.tsx" import { nextRouter } from "@btst/stack/next" session?.user ?? null, diff --git a/docs/content/docs/plugins/kanban.mdx b/docs/content/docs/plugins/kanban.mdx index a496235ac..cef7a8adb 100644 --- a/docs/content/docs/plugins/kanban.mdx +++ b/docs/content/docs/plugins/kanban.mdx @@ -143,7 +143,7 @@ Configure top-level framework wiring and kanban-specific overrides in your `Stac - ```tsx title="app/pages/[[...all]]/layout.tsx" + ```tsx title="app/pages/client-layout.tsx" import { StackProvider } from "@btst/stack/context" import { nextRouter } from "@btst/stack/next" import { resolveUser, searchUsers } from "@/lib/users" // Your user resolver @@ -401,31 +401,33 @@ export const serverAuth = createServerAuth({ ``` Pass `serverAuth` to `stack({ auth: serverAuth, ... })`. Create the client -binding from the same browser-safe definition. In your framework page helper, -resolve the request identity once, pass its headers and identity to -`getStackClient`, and hydrate that exact identity at the provider boundary: +binding from the same browser-safe definition. Resolve the request identity and +trusted client origins in the request layout, then serialize only those plain +values to the client provider. Request headers and the resolved request stack +stay server-only: -```tsx title="app/pages/layout.tsx" -const requestHeaders = new Headers(await frameworkHeaders()) +```tsx title="app/(request)/pages/layout.tsx" +import { headers } from "next/headers" +import { getServerClientOriginsFromHeaders } from "@/lib/stack-client.server" + +const requestHeaders = await headers() const initialIdentity = await serverAuth.getIdentityFromHeaders({ headers: requestHeaders, }) -const stackClient = getStackClient(queryClient, { - headers: requestHeaders, - identity: initialIdentity ?? undefined, -}) +const clientOrigins = getServerClientOriginsFromHeaders(requestHeaders) -const clientAuth = createClientAuth({ - authorization, - getIdentity: () => session?.user ?? null, - loginPath: "/sign-in", -}) - - + {children} - + ``` +Inside `app/pages/client-layout.tsx`, create the browser stack from +`clientOrigins` and pass `initialIdentity` to `StackProvider` with the +browser-safe `clientAuth` binding. + Omitting stack authorization leaves request operations permissive. When authorization is configured, ordinary anonymous denials are 401 responses, authenticated denials are 403 responses, and identity, fact, diff --git a/docs/content/docs/plugins/media.mdx b/docs/content/docs/plugins/media.mdx index 8f84d76dd..6023609f1 100644 --- a/docs/content/docs/plugins/media.mdx +++ b/docs/content/docs/plugins/media.mdx @@ -156,15 +156,22 @@ required for the built-in experience: ```tsx title="app/pages/client-layout.tsx" "use client" -import { useMemo, useState } from "react" -import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { useMemo } from "react" +import { QueryClientProvider } from "@tanstack/react-query" import { StackProvider } from "@btst/stack/context" import { nextRouter } from "@btst/stack/next" -import { getStackClient } from "@/lib/stack-client" +import { getOrCreateQueryClient } from "@/lib/query-client" +import { getStackClient, type StackClientOptions } from "@/lib/stack-client" -export function ClientLayout({ children }) { - const [queryClient] = useState(() => new QueryClient()) - const stack = useMemo(() => getStackClient(queryClient), [queryClient]) +export function ClientLayout({ children, clientOrigins }: { + children: React.ReactNode + clientOrigins: StackClientOptions +}) { + const queryClient = getOrCreateQueryClient() + const stack = useMemo( + () => getStackClient(queryClient, clientOrigins), + [clientOrigins.apiOrigin, clientOrigins.siteOrigin, queryClient], + ) return ( @@ -241,7 +248,7 @@ Your media plugin is now configured and ready to use. Here is a quick reference When you need to upload an image outside React hooks, bind `uploadAsset()` to the Media runtime already resolved by your client stack: -```tsx title="app/pages/layout.tsx" +```tsx title="app/pages/client-layout.tsx" import { createMediaUploadConfig, uploadAsset, 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/plugins/ui-builder.mdx b/docs/content/docs/plugins/ui-builder.mdx index 4828efbdb..5a730af7c 100644 --- a/docs/content/docs/plugins/ui-builder.mdx +++ b/docs/content/docs/plugins/ui-builder.mdx @@ -144,10 +144,10 @@ site location. Pass the resolved stack to the provider. The component registry is a `uiBuilderClientPlugin()` concern, so `StackProvider` needs no duplicate override: -```tsx title="app/pages/layout.tsx" +```tsx title="app/pages/client-layout.tsx" import { nextRouter } from "@btst/stack/next" -const stack = getStackClient(queryClient) +const stack = getStackClient(queryClient, clientOrigins) } 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. + ## 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/e2e/tests/smoke.authorization-boundary.spec.ts b/e2e/tests/smoke.authorization-boundary.spec.ts index 565b89cad..1a2cef5ad 100644 --- a/e2e/tests/smoke.authorization-boundary.spec.ts +++ b/e2e/tests/smoke.authorization-boundary.spec.ts @@ -5,13 +5,46 @@ test("the layout hydrates identity before browser authorization renders", async page, }) => { await setMockAuthCookie(page.context(), "olliethedev"); + const serverResponse = await page.request.get( + "/pages/authorization-boundary", + ); + expect(serverResponse.ok()).toBe(true); + const requestOrigin = new URL(serverResponse.url()).origin; + const expectedApiOrigin = + process.env.BTST_EXPECTED_API_ORIGIN ?? requestOrigin; + expect(await serverResponse.text()).toContain( + `data-testid="stack-runtime-origin">${expectedApiOrigin}`, + ); + await page.goto("/pages/authorization-boundary"); + await expect(page.getByTestId("stack-runtime-origin")).toHaveText( + expectedApiOrigin, + ); await expect(page.getByTestId("hydrated-identity")).toHaveText("olliethedev"); await expect(page.getByText("Allowed", { exact: true })).toBeVisible(); await expect(page.getByText("Denied", { exact: true })).not.toBeVisible(); }); +test("TanStack keeps the trusted API origin during client navigation", async ({ + page, +}, testInfo) => { + test.skip(!testInfo.project.name.startsWith("tanstack")); + await page.goto("/pages/authorization-boundary"); + const expectedApiOrigin = + process.env.BTST_EXPECTED_API_ORIGIN ?? new URL(page.url()).origin; + + await expect(page.getByTestId("stack-runtime-origin")).toHaveText( + expectedApiOrigin, + ); + await page.getByRole("link", { name: "Available Routes" }).click(); + await expect(page).toHaveURL(/\/pages\/route-docs$/); + await page.goBack(); + await expect(page.getByTestId("stack-runtime-origin")).toHaveText( + expectedApiOrigin, + ); +}); + test("the primary Blog API enforces the same request session", async ({ request, }) => { @@ -34,3 +67,26 @@ test("the primary Blog API enforces the same request session", async ({ }); expect(nonAdminPublish.status()).toBe(403); }); + +test("credentialed SSR ignores hostile forwarding origins", async ({ + request, +}) => { + const response = await request.get("/pages/authorization-boundary", { + headers: { + ...mockAuthHeaders("olliethedev"), + forwarded: "host=credentials.example.net;proto=https", + "x-forwarded-host": "credentials.example.net", + "x-forwarded-port": "443", + "x-forwarded-proto": "https", + }, + }); + + expect(response.ok()).toBe(true); + const html = await response.text(); + const expectedApiOrigin = + process.env.BTST_EXPECTED_API_ORIGIN ?? new URL(response.url()).origin; + expect(html).toContain( + `data-testid="stack-runtime-origin">${expectedApiOrigin}`, + ); + expect(html).not.toContain("credentials.example.net"); +}); diff --git a/packages/cli/package.json b/packages/cli/package.json index 3b3e04f15..8b0fcdba0 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -65,6 +65,8 @@ "build": "unbuild --clean", "stub": "unbuild --stub", "dev": "tsx src/index.ts", + "legacy-next-hashes:check": "node scripts/generate-legacy-next-render-hashes.mjs --check", + "legacy-next-hashes:generate": "node scripts/generate-legacy-next-render-hashes.mjs", "typecheck": "tsc --project tsconfig.json", "test": "vitest run", "test:init": "bash scripts/test-init.sh" diff --git a/packages/cli/scripts/fixtures/legacy-next/README.md b/packages/cli/scripts/fixtures/legacy-next/README.md new file mode 100644 index 000000000..d9a4d9be6 --- /dev/null +++ b/packages/cli/scripts/fixtures/legacy-next/README.md @@ -0,0 +1,31 @@ +# Legacy Next.js scaffold fixtures + +These are exact `buildScaffoldPlan()` outputs from the `v3.0.0-rc.2` tag and +the pre-issue-223 `e9ff9448` v3 base. The main fixture cohort uses the memory +adapter, the maintained Blog/AI Chat/CMS/UI Builder/Kanban/Comments/Media/Route +Docs/OpenAPI plugins, the `@/` alias, and `app/globals.css`. The Form Builder +page is generated separately with the Drizzle adapter. + +The snapshots were captured from the returned `FileWritePlanItem.content` +without passing through an editor. In particular, the historical +`renderTemplate()` contract is `trimEnd() + "\\n"`, so every fixture ends with +exactly one line-feed byte. The migration test records hashes produced by +executing `buildScaffoldPlan()` at each source ref and checks that newline +contract independently of the runtime allowlist. + +`legacy-next-render-hashes.ts` records the exact bytes from every supported +plugin-selection and `@/`, `~/`, or `./` import-alias combination at both +historical refs, including the exact LF and CRLF checkout variants. Regenerate +it with `pnpm legacy-next-hashes:generate`; CI runs the corresponding +`legacy-next-hashes:check` verifier against the immutable historical renderers. +Do not normalize those bytes: even a one-character consumer edit must remain +outside the allowlist and fail closed. + +The `variants/` files are also direct historical renderer outputs. They cover +conditional plugin selections and non-default aliases without weakening the +fail-closed check for consumer edits. + +`legacy-next-scaffold.ts` allowlists the SHA-256 hashes of these files. Do not +edit a snapshot without intentionally updating the matching hash and migration +regressions. Any consumer customization must fail closed instead of being +deleted by `btst init --yes`. diff --git a/packages/cli/scripts/fixtures/legacy-next/e9ff9448/app/pages/[[...all]]/page.tsx b/packages/cli/scripts/fixtures/legacy-next/e9ff9448/app/pages/[[...all]]/page.tsx new file mode 100644 index 000000000..d1845cd09 --- /dev/null +++ b/packages/cli/scripts/fixtures/legacy-next/e9ff9448/app/pages/[[...all]]/page.tsx @@ -0,0 +1,12 @@ +import { createNextPage } from "@btst/stack/next" +import { getOrCreateQueryClient } from "@/lib/query-client" +import { getStackClient } from "@/lib/stack-client" + +export const dynamic = "force-dynamic" + +const page = createNextPage({ + getStackClient: (queryClient) => getStackClient(queryClient), + getQueryClient: getOrCreateQueryClient, +}) +export default page.Page +export const generateMetadata = page.generateMetadata diff --git a/packages/cli/scripts/fixtures/legacy-next/e9ff9448/app/pages/layout.tsx b/packages/cli/scripts/fixtures/legacy-next/e9ff9448/app/pages/layout.tsx new file mode 100644 index 000000000..46ba9fd6c --- /dev/null +++ b/packages/cli/scripts/fixtures/legacy-next/e9ff9448/app/pages/layout.tsx @@ -0,0 +1,63 @@ +"use client" + +import { StackProvider } from "@btst/stack/context" +import { nextRouter } from "@btst/stack/next" +import { ChatLayout } from "@btst/stack/plugins/ai-chat/client" +import { QueryClientProvider } from "@tanstack/react-query" +import { usePathname } from "next/navigation" +import { getOrCreateQueryClient } from "@/lib/query-client" +import { getStackClient } from "@/lib/stack-client" + +export default function BtstPagesLayout({ + children, +}: { + children: React.ReactNode +}) { + const queryClient = getOrCreateQueryClient() + const stack = getStackClient(queryClient) + const hasApiKey = typeof process !== "undefined" && !!process.env.NEXT_PUBLIC_HAS_OPENAI_KEY + const pathname = usePathname() + const showChatWidget = !pathname.startsWith("/pages/chat") + return ( + + { + throw new Error("TODO: implement blog.uploadImage override in app/pages/layout.tsx") + }, + }, + "kanban": { + uploadImage: async () => { + throw new Error("TODO: implement kanban.uploadImage override in app/pages/layout.tsx") + }, + resolveUser: async () => null, + searchUsers: async () => [], + }, + } + } + > + {!hasApiKey && ( +
+ Add OPENAI_API_KEY to{" "} + .env.local to enable AI chat. +
+ )} + {children} + {/* Floating AI chat widget — hidden on /pages/chat/* where the full UI is shown */} + {showChatWidget && ( +
+ +
+ )} +
+
+ ) +} diff --git a/packages/cli/scripts/fixtures/legacy-next/e9ff9448/app/pages/ssg-blog/[slug]/page.tsx b/packages/cli/scripts/fixtures/legacy-next/e9ff9448/app/pages/ssg-blog/[slug]/page.tsx new file mode 100644 index 000000000..52fcb5ad7 --- /dev/null +++ b/packages/cli/scripts/fixtures/legacy-next/e9ff9448/app/pages/ssg-blog/[slug]/page.tsx @@ -0,0 +1,53 @@ +/** + * SSG Blog post page with ISR + on-demand revalidation. + * + * New slugs are rendered on-demand (dynamicParams: true). + * Call `revalidatePath("/pages/ssg-blog/${slug}")` in your blog backend + * plugin hooks (lib/stack.ts) to purge the cache when a post changes. + */ +import { dehydrate, HydrationBoundary } from "@tanstack/react-query" +import { notFound } from "next/navigation" +import { getOrCreateQueryClient } from "@/lib/query-client" +import { getStackClient } from "@/lib/stack-client" +import { myStack } from "@/lib/stack" +import { normalizePath, metaElementsToObject } from "@btst/stack/client" +import type { Metadata } from "next" + +export async function generateStaticParams() { + const result = await myStack.trusted.blog.listPosts({ published: true }) + return result.items.map((post: { slug: string }) => ({ slug: post.slug })) +} + +export const revalidate = 3600 + +export async function generateMetadata({ + params, +}: { + params: Promise<{ slug: string }> +}): Promise { + const { slug } = await params + const queryClient = getOrCreateQueryClient() + const stackClient = getStackClient(queryClient) + const route = stackClient.router.getRoute(normalizePath(["blog", slug])) + if (!route) return { title: slug } + await myStack.raw.blog.prefetchForRoute("post", queryClient, { slug }) + return metaElementsToObject(route.meta?.() ?? []) satisfies Metadata +} + +export default async function SsgBlogPostPage({ + params, +}: { + params: Promise<{ slug: string }> +}) { + const { slug } = await params + const queryClient = getOrCreateQueryClient() + const stackClient = getStackClient(queryClient) + const route = stackClient.router.getRoute(normalizePath(["blog", slug])) + if (!route) notFound() + await myStack.raw.blog.prefetchForRoute("post", queryClient, { slug }) + return ( + + {route.PageComponent && } + + ) +} diff --git a/packages/cli/scripts/fixtures/legacy-next/e9ff9448/app/pages/ssg-blog/page.tsx b/packages/cli/scripts/fixtures/legacy-next/e9ff9448/app/pages/ssg-blog/page.tsx new file mode 100644 index 000000000..64f09628b --- /dev/null +++ b/packages/cli/scripts/fixtures/legacy-next/e9ff9448/app/pages/ssg-blog/page.tsx @@ -0,0 +1,42 @@ +/** + * SSG Blog list page with ISR + on-demand revalidation. + * + * Uses `prefetchForRoute` (direct DB access) so data is available at build time. + * Call `revalidatePath("/pages/ssg-blog")` in your blog backend plugin hooks + * (lib/stack.ts) to purge the cache when posts change. + */ +import { dehydrate, HydrationBoundary } from "@tanstack/react-query" +import { notFound } from "next/navigation" +import { getOrCreateQueryClient } from "@/lib/query-client" +import { getStackClient } from "@/lib/stack-client" +import { myStack } from "@/lib/stack" +import { metaElementsToObject, normalizePath } from "@btst/stack/client" +import type { Metadata } from "next" + +export async function generateStaticParams() { + return [{}] +} + +export const revalidate = 3600 // ISR: regenerate at most once per hour + +export async function generateMetadata(): Promise { + const queryClient = getOrCreateQueryClient() + const stackClient = getStackClient(queryClient) + const route = stackClient.router.getRoute(normalizePath(["blog"])) + if (!route) return { title: "Blog" } + await myStack.raw.blog.prefetchForRoute("posts", queryClient) + return metaElementsToObject(route.meta?.() ?? []) satisfies Metadata +} + +export default async function SsgBlogListPage() { + const queryClient = getOrCreateQueryClient() + const stackClient = getStackClient(queryClient) + const route = stackClient.router.getRoute(normalizePath(["blog"])) + if (!route) notFound() + await myStack.raw.blog.prefetchForRoute("posts", queryClient) + return ( + + {route.PageComponent && } + + ) +} diff --git a/packages/cli/scripts/fixtures/legacy-next/e9ff9448/app/pages/ssg-cms/[typeSlug]/page.tsx b/packages/cli/scripts/fixtures/legacy-next/e9ff9448/app/pages/ssg-cms/[typeSlug]/page.tsx new file mode 100644 index 000000000..4d4541555 --- /dev/null +++ b/packages/cli/scripts/fixtures/legacy-next/e9ff9448/app/pages/ssg-cms/[typeSlug]/page.tsx @@ -0,0 +1,53 @@ +/** + * SSG CMS content list page with ISR. + * + * Generates a static page for each registered content type at build time. + * Call `revalidatePath("/pages/ssg-cms/${typeSlug}", "page")` in your CMS + * backend plugin hooks (lib/stack.ts) to purge the cache when content changes. + */ +import { dehydrate, HydrationBoundary } from "@tanstack/react-query" +import { notFound } from "next/navigation" +import { getOrCreateQueryClient } from "@/lib/query-client" +import { getStackClient } from "@/lib/stack-client" +import { myStack } from "@/lib/stack" +import { metaElementsToObject, normalizePath } from "@btst/stack/client" +import type { Metadata } from "next" + +export async function generateStaticParams() { + const contentTypes = await myStack.trusted.cms.listContentTypes({}) + return contentTypes.map((ct: { slug: string }) => ({ typeSlug: ct.slug })) +} + +export const revalidate = 3600 + +export async function generateMetadata({ + params, +}: { + params: Promise<{ typeSlug: string }> +}): Promise { + const { typeSlug } = await params + const queryClient = getOrCreateQueryClient() + const stackClient = getStackClient(queryClient) + const route = stackClient.router.getRoute(normalizePath(["cms", typeSlug])) + if (!route) return { title: typeSlug } + await myStack.raw.cms.prefetchForRoute("contentList", queryClient, { typeSlug }) + return metaElementsToObject(route.meta?.() ?? []) satisfies Metadata +} + +export default async function SsgCmsContentListPage({ + params, +}: { + params: Promise<{ typeSlug: string }> +}) { + const { typeSlug } = await params + const queryClient = getOrCreateQueryClient() + const stackClient = getStackClient(queryClient) + const route = stackClient.router.getRoute(normalizePath(["cms", typeSlug])) + if (!route) notFound() + await myStack.raw.cms.prefetchForRoute("contentList", queryClient, { typeSlug }) + return ( + + {route.PageComponent && } + + ) +} diff --git a/packages/cli/scripts/fixtures/legacy-next/e9ff9448/app/pages/ssg-forms/page.tsx b/packages/cli/scripts/fixtures/legacy-next/e9ff9448/app/pages/ssg-forms/page.tsx new file mode 100644 index 000000000..9d89b7edc --- /dev/null +++ b/packages/cli/scripts/fixtures/legacy-next/e9ff9448/app/pages/ssg-forms/page.tsx @@ -0,0 +1,41 @@ +/** + * SSG Forms list page with ISR. + * + * Call `revalidatePath("/pages/ssg-forms", "page")` in your form-builder + * backend plugin hooks (lib/stack.ts) to purge the cache when forms change. + */ +import { dehydrate, HydrationBoundary } from "@tanstack/react-query" +import { notFound } from "next/navigation" +import { getOrCreateQueryClient } from "@/lib/query-client" +import { getStackClient } from "@/lib/stack-client" +import { myStack } from "@/lib/stack" +import { metaElementsToObject, normalizePath } from "@btst/stack/client" +import type { Metadata } from "next" + +export async function generateStaticParams() { + return [{}] +} + +export const revalidate = 3600 + +export async function generateMetadata(): Promise { + const queryClient = getOrCreateQueryClient() + const stackClient = getStackClient(queryClient) + const route = stackClient.router.getRoute(normalizePath(["forms"])) + if (!route) return { title: "Forms" } + await myStack.raw.formBuilder.prefetchForRoute("formList", queryClient) + return metaElementsToObject(route.meta?.() ?? []) satisfies Metadata +} + +export default async function SsgFormsListPage() { + const queryClient = getOrCreateQueryClient() + const stackClient = getStackClient(queryClient) + const route = stackClient.router.getRoute(normalizePath(["forms"])) + if (!route) notFound() + await myStack.raw.formBuilder.prefetchForRoute("formList", queryClient) + return ( + + {route.PageComponent && } + + ) +} diff --git a/packages/cli/scripts/fixtures/legacy-next/e9ff9448/app/pages/ssg-kanban/page.tsx b/packages/cli/scripts/fixtures/legacy-next/e9ff9448/app/pages/ssg-kanban/page.tsx new file mode 100644 index 000000000..8764064b2 --- /dev/null +++ b/packages/cli/scripts/fixtures/legacy-next/e9ff9448/app/pages/ssg-kanban/page.tsx @@ -0,0 +1,41 @@ +/** + * SSG Kanban boards list page with ISR. + * + * Call `revalidatePath("/pages/ssg-kanban", "page")` in your kanban + * backend plugin hooks (lib/stack.ts) to purge the cache when boards change. + */ +import { dehydrate, HydrationBoundary } from "@tanstack/react-query" +import { notFound } from "next/navigation" +import { getOrCreateQueryClient } from "@/lib/query-client" +import { getStackClient } from "@/lib/stack-client" +import { myStack } from "@/lib/stack" +import { metaElementsToObject, normalizePath } from "@btst/stack/client" +import type { Metadata } from "next" + +export async function generateStaticParams() { + return [{}] +} + +export const revalidate = 3600 + +export async function generateMetadata(): Promise { + const queryClient = getOrCreateQueryClient() + const stackClient = getStackClient(queryClient) + const route = stackClient.router.getRoute(normalizePath(["kanban"])) + if (!route) return { title: "Kanban Boards" } + await myStack.raw.kanban.prefetchForRoute("boards", queryClient) + return metaElementsToObject(route.meta?.() ?? []) satisfies Metadata +} + +export default async function SsgKanbanBoardsPage() { + const queryClient = getOrCreateQueryClient() + const stackClient = getStackClient(queryClient) + const route = stackClient.router.getRoute(normalizePath(["kanban"])) + if (!route) notFound() + await myStack.raw.kanban.prefetchForRoute("boards", queryClient) + return ( + + {route.PageComponent && } + + ) +} diff --git a/packages/cli/scripts/fixtures/legacy-next/e9ff9448/variants/no-plugins-tilde-layout.tsx b/packages/cli/scripts/fixtures/legacy-next/e9ff9448/variants/no-plugins-tilde-layout.tsx new file mode 100644 index 000000000..63da7b758 --- /dev/null +++ b/packages/cli/scripts/fixtures/legacy-next/e9ff9448/variants/no-plugins-tilde-layout.tsx @@ -0,0 +1,26 @@ +"use client" + +import { StackProvider } from "@btst/stack/context" +import { nextRouter } from "@btst/stack/next" +import { QueryClientProvider } from "@tanstack/react-query" +import { getOrCreateQueryClient } from "~/lib/query-client" +import { getStackClient } from "~/lib/stack-client" + +export default function BtstPagesLayout({ + children, +}: { + children: React.ReactNode +}) { + const queryClient = getOrCreateQueryClient() + const stack = getStackClient(queryClient) + return ( + + + {children} + + + ) +} diff --git a/packages/cli/scripts/fixtures/legacy-next/v3.0.0-rc.2/app/pages/[[...all]]/page.tsx b/packages/cli/scripts/fixtures/legacy-next/v3.0.0-rc.2/app/pages/[[...all]]/page.tsx new file mode 100644 index 000000000..79e15286a --- /dev/null +++ b/packages/cli/scripts/fixtures/legacy-next/v3.0.0-rc.2/app/pages/[[...all]]/page.tsx @@ -0,0 +1,9 @@ +import { createNextPage } from "@btst/stack/next" +import { getOrCreateQueryClient } from "@/lib/query-client" +import { getStackClient } from "@/lib/stack-client" + +export const dynamic = "force-dynamic" + +const page = createNextPage({ getStackClient, getQueryClient: getOrCreateQueryClient }) +export default page.Page +export const generateMetadata = page.generateMetadata diff --git a/packages/cli/scripts/fixtures/legacy-next/v3.0.0-rc.2/app/pages/layout.tsx b/packages/cli/scripts/fixtures/legacy-next/v3.0.0-rc.2/app/pages/layout.tsx new file mode 100644 index 000000000..5c7d1a590 --- /dev/null +++ b/packages/cli/scripts/fixtures/legacy-next/v3.0.0-rc.2/app/pages/layout.tsx @@ -0,0 +1,82 @@ +"use client" + +import { StackProvider } from "@btst/stack/context" +import { nextRouter } from "@btst/stack/next" +import { ChatLayout } from "@btst/stack/plugins/ai-chat/client" +import { QueryClientProvider } from "@tanstack/react-query" +import { usePathname } from "next/navigation" +import { getOrCreateQueryClient } from "@/lib/query-client" + +function getBaseURL() { + if (typeof window !== "undefined") { + return window.location.origin + } + + if (typeof process !== "undefined") { + return process.env.NEXT_PUBLIC_BASE_URL || process.env.BASE_URL || "http://localhost:3000" + } + + return "http://localhost:3000" +} + +export default function BtstPagesLayout({ + children, +}: { + children: React.ReactNode +}) { + const queryClient = getOrCreateQueryClient() + const hasApiKey = typeof process !== "undefined" && !!process.env.NEXT_PUBLIC_HAS_OPENAI_KEY + const pathname = usePathname() + const showChatWidget = !pathname.startsWith("/pages/chat") + const baseURL = getBaseURL() + + return ( + + { + throw new Error("TODO: implement blog.uploadImage override in app/pages/layout.tsx") + }, + }, + "ai-chat": { + mode: "public" as const, + }, + "kanban": { + uploadImage: async () => { + throw new Error("TODO: implement kanban.uploadImage override in app/pages/layout.tsx") + }, + resolveUser: async () => null, + searchUsers: async () => [], + }, + "media": { + queryClient, + }, + } + } + > + {!hasApiKey && ( +
+ Add OPENAI_API_KEY to{" "} + .env.local to enable AI chat. +
+ )} + {children} + {/* Floating AI chat widget — hidden on /pages/chat/* where the full UI is shown */} + {showChatWidget && ( +
+ +
+ )} +
+
+ ) +} diff --git a/packages/cli/scripts/fixtures/legacy-next/v3.0.0-rc.2/app/pages/ssg-blog/[slug]/page.tsx b/packages/cli/scripts/fixtures/legacy-next/v3.0.0-rc.2/app/pages/ssg-blog/[slug]/page.tsx new file mode 100644 index 000000000..9db2f0473 --- /dev/null +++ b/packages/cli/scripts/fixtures/legacy-next/v3.0.0-rc.2/app/pages/ssg-blog/[slug]/page.tsx @@ -0,0 +1,53 @@ +/** + * SSG Blog post page with ISR + on-demand revalidation. + * + * New slugs are rendered on-demand (dynamicParams: true). + * Call `revalidatePath("/pages/ssg-blog/${slug}")` in your blog backend + * plugin hooks (lib/stack.ts) to purge the cache when a post changes. + */ +import { dehydrate, HydrationBoundary } from "@tanstack/react-query" +import { notFound } from "next/navigation" +import { getOrCreateQueryClient } from "@/lib/query-client" +import { getStackClient } from "@/lib/stack-client" +import { myStack } from "@/lib/stack" +import { normalizePath, metaElementsToObject } from "@btst/stack/client" +import type { Metadata } from "next" + +export async function generateStaticParams() { + const result = await myStack.api.blog.getAllPosts({ published: true }) + return result.items.map((post: { slug: string }) => ({ slug: post.slug })) +} + +export const revalidate = 3600 + +export async function generateMetadata({ + params, +}: { + params: Promise<{ slug: string }> +}): Promise { + const { slug } = await params + const queryClient = getOrCreateQueryClient() + const stackClient = getStackClient(queryClient) + const route = stackClient.router.getRoute(normalizePath(["blog", slug])) + if (!route) return { title: slug } + await myStack.api.blog.prefetchForRoute("post", queryClient, { slug }) + return metaElementsToObject(route.meta?.() ?? []) satisfies Metadata +} + +export default async function SsgBlogPostPage({ + params, +}: { + params: Promise<{ slug: string }> +}) { + const { slug } = await params + const queryClient = getOrCreateQueryClient() + const stackClient = getStackClient(queryClient) + const route = stackClient.router.getRoute(normalizePath(["blog", slug])) + if (!route) notFound() + await myStack.api.blog.prefetchForRoute("post", queryClient, { slug }) + return ( + + {route.PageComponent && } + + ) +} diff --git a/packages/cli/scripts/fixtures/legacy-next/v3.0.0-rc.2/app/pages/ssg-blog/page.tsx b/packages/cli/scripts/fixtures/legacy-next/v3.0.0-rc.2/app/pages/ssg-blog/page.tsx new file mode 100644 index 000000000..3ce73bbfd --- /dev/null +++ b/packages/cli/scripts/fixtures/legacy-next/v3.0.0-rc.2/app/pages/ssg-blog/page.tsx @@ -0,0 +1,42 @@ +/** + * SSG Blog list page with ISR + on-demand revalidation. + * + * Uses `prefetchForRoute` (direct DB access) so data is available at build time. + * Call `revalidatePath("/pages/ssg-blog")` in your blog backend plugin hooks + * (lib/stack.ts) to purge the cache when posts change. + */ +import { dehydrate, HydrationBoundary } from "@tanstack/react-query" +import { notFound } from "next/navigation" +import { getOrCreateQueryClient } from "@/lib/query-client" +import { getStackClient } from "@/lib/stack-client" +import { myStack } from "@/lib/stack" +import { metaElementsToObject, normalizePath } from "@btst/stack/client" +import type { Metadata } from "next" + +export async function generateStaticParams() { + return [{}] +} + +export const revalidate = 3600 // ISR: regenerate at most once per hour + +export async function generateMetadata(): Promise { + const queryClient = getOrCreateQueryClient() + const stackClient = getStackClient(queryClient) + const route = stackClient.router.getRoute(normalizePath(["blog"])) + if (!route) return { title: "Blog" } + await myStack.api.blog.prefetchForRoute("posts", queryClient) + return metaElementsToObject(route.meta?.() ?? []) satisfies Metadata +} + +export default async function SsgBlogListPage() { + const queryClient = getOrCreateQueryClient() + const stackClient = getStackClient(queryClient) + const route = stackClient.router.getRoute(normalizePath(["blog"])) + if (!route) notFound() + await myStack.api.blog.prefetchForRoute("posts", queryClient) + return ( + + {route.PageComponent && } + + ) +} diff --git a/packages/cli/scripts/fixtures/legacy-next/v3.0.0-rc.2/app/pages/ssg-cms/[typeSlug]/page.tsx b/packages/cli/scripts/fixtures/legacy-next/v3.0.0-rc.2/app/pages/ssg-cms/[typeSlug]/page.tsx new file mode 100644 index 000000000..3e65d15a8 --- /dev/null +++ b/packages/cli/scripts/fixtures/legacy-next/v3.0.0-rc.2/app/pages/ssg-cms/[typeSlug]/page.tsx @@ -0,0 +1,53 @@ +/** + * SSG CMS content list page with ISR. + * + * Generates a static page for each registered content type at build time. + * Call `revalidatePath("/pages/ssg-cms/${typeSlug}", "page")` in your CMS + * backend plugin hooks (lib/stack.ts) to purge the cache when content changes. + */ +import { dehydrate, HydrationBoundary } from "@tanstack/react-query" +import { notFound } from "next/navigation" +import { getOrCreateQueryClient } from "@/lib/query-client" +import { getStackClient } from "@/lib/stack-client" +import { myStack } from "@/lib/stack" +import { metaElementsToObject, normalizePath } from "@btst/stack/client" +import type { Metadata } from "next" + +export async function generateStaticParams() { + const contentTypes = await myStack.api.cms.getAllContentTypes() + return contentTypes.map((ct: { slug: string }) => ({ typeSlug: ct.slug })) +} + +export const revalidate = 3600 + +export async function generateMetadata({ + params, +}: { + params: Promise<{ typeSlug: string }> +}): Promise { + const { typeSlug } = await params + const queryClient = getOrCreateQueryClient() + const stackClient = getStackClient(queryClient) + const route = stackClient.router.getRoute(normalizePath(["cms", typeSlug])) + if (!route) return { title: typeSlug } + await myStack.api.cms.prefetchForRoute("contentList", queryClient, { typeSlug }) + return metaElementsToObject(route.meta?.() ?? []) satisfies Metadata +} + +export default async function SsgCmsContentListPage({ + params, +}: { + params: Promise<{ typeSlug: string }> +}) { + const { typeSlug } = await params + const queryClient = getOrCreateQueryClient() + const stackClient = getStackClient(queryClient) + const route = stackClient.router.getRoute(normalizePath(["cms", typeSlug])) + if (!route) notFound() + await myStack.api.cms.prefetchForRoute("contentList", queryClient, { typeSlug }) + return ( + + {route.PageComponent && } + + ) +} diff --git a/packages/cli/scripts/fixtures/legacy-next/v3.0.0-rc.2/app/pages/ssg-forms/page.tsx b/packages/cli/scripts/fixtures/legacy-next/v3.0.0-rc.2/app/pages/ssg-forms/page.tsx new file mode 100644 index 000000000..d9d34ff8e --- /dev/null +++ b/packages/cli/scripts/fixtures/legacy-next/v3.0.0-rc.2/app/pages/ssg-forms/page.tsx @@ -0,0 +1,41 @@ +/** + * SSG Forms list page with ISR. + * + * Call `revalidatePath("/pages/ssg-forms", "page")` in your form-builder + * backend plugin hooks (lib/stack.ts) to purge the cache when forms change. + */ +import { dehydrate, HydrationBoundary } from "@tanstack/react-query" +import { notFound } from "next/navigation" +import { getOrCreateQueryClient } from "@/lib/query-client" +import { getStackClient } from "@/lib/stack-client" +import { myStack } from "@/lib/stack" +import { metaElementsToObject, normalizePath } from "@btst/stack/client" +import type { Metadata } from "next" + +export async function generateStaticParams() { + return [{}] +} + +export const revalidate = 3600 + +export async function generateMetadata(): Promise { + const queryClient = getOrCreateQueryClient() + const stackClient = getStackClient(queryClient) + const route = stackClient.router.getRoute(normalizePath(["forms"])) + if (!route) return { title: "Forms" } + await myStack.api.formBuilder.prefetchForRoute("formList", queryClient) + return metaElementsToObject(route.meta?.() ?? []) satisfies Metadata +} + +export default async function SsgFormsListPage() { + const queryClient = getOrCreateQueryClient() + const stackClient = getStackClient(queryClient) + const route = stackClient.router.getRoute(normalizePath(["forms"])) + if (!route) notFound() + await myStack.api.formBuilder.prefetchForRoute("formList", queryClient) + return ( + + {route.PageComponent && } + + ) +} diff --git a/packages/cli/scripts/fixtures/legacy-next/v3.0.0-rc.2/app/pages/ssg-kanban/page.tsx b/packages/cli/scripts/fixtures/legacy-next/v3.0.0-rc.2/app/pages/ssg-kanban/page.tsx new file mode 100644 index 000000000..7da20d90c --- /dev/null +++ b/packages/cli/scripts/fixtures/legacy-next/v3.0.0-rc.2/app/pages/ssg-kanban/page.tsx @@ -0,0 +1,41 @@ +/** + * SSG Kanban boards list page with ISR. + * + * Call `revalidatePath("/pages/ssg-kanban", "page")` in your kanban + * backend plugin hooks (lib/stack.ts) to purge the cache when boards change. + */ +import { dehydrate, HydrationBoundary } from "@tanstack/react-query" +import { notFound } from "next/navigation" +import { getOrCreateQueryClient } from "@/lib/query-client" +import { getStackClient } from "@/lib/stack-client" +import { myStack } from "@/lib/stack" +import { metaElementsToObject, normalizePath } from "@btst/stack/client" +import type { Metadata } from "next" + +export async function generateStaticParams() { + return [{}] +} + +export const revalidate = 3600 + +export async function generateMetadata(): Promise { + const queryClient = getOrCreateQueryClient() + const stackClient = getStackClient(queryClient) + const route = stackClient.router.getRoute(normalizePath(["kanban"])) + if (!route) return { title: "Kanban Boards" } + await myStack.api.kanban.prefetchForRoute("boards", queryClient) + return metaElementsToObject(route.meta?.() ?? []) satisfies Metadata +} + +export default async function SsgKanbanBoardsPage() { + const queryClient = getOrCreateQueryClient() + const stackClient = getStackClient(queryClient) + const route = stackClient.router.getRoute(normalizePath(["kanban"])) + if (!route) notFound() + await myStack.api.kanban.prefetchForRoute("boards", queryClient) + return ( + + {route.PageComponent && } + + ) +} diff --git a/packages/cli/scripts/fixtures/third-party-plugin.tsx b/packages/cli/scripts/fixtures/third-party-plugin.tsx new file mode 100644 index 000000000..bb318e3ca --- /dev/null +++ b/packages/cli/scripts/fixtures/third-party-plugin.tsx @@ -0,0 +1,61 @@ +import { createMemoryAdapter } from "@btst/adapter-memory"; +import { createBackendStack } from "@btst/stack/api"; +import { createClientStack } from "@btst/stack/client"; +import { StackProvider } from "@btst/stack/context"; +import { + createDbPlugin, + createEndpoint, + defineBackendPlugin, +} from "@btst/stack/plugins/api"; +import { defineClientPlugin, defineRoute } from "@btst/stack/plugins/client"; +import { QueryClient } from "@tanstack/react-query"; + +interface ThirdPartyProbeOverrides { + label: string; +} + +function thirdPartyProbeBackendPlugin() { + return defineBackendPlugin({ + id: "thirdPartyProbe", + dbPlugin: createDbPlugin("third-party-probe", {}), + routes: () => ({ + ping: createEndpoint("/ping", { method: "GET" }, async () => ({ + ok: true, + })), + }), + }); +} + +function thirdPartyProbeClientPlugin() { + return defineClientPlugin()({ + id: "thirdPartyProbe", + resolve: () => ({ + routes: () => ({ + probe: defineRoute("/third-party-probe", { page: () => null }), + }), + }), + }); +} + +export const thirdPartyBackendStack = createBackendStack({ + basePath: "/api/data", + plugins: { thirdPartyProbe: thirdPartyProbeBackendPlugin() }, + adapter: (db) => createMemoryAdapter(db)({}), +}); + +const queryClient = new QueryClient(); +const thirdPartyClientStack = createClientStack({ + api: { baseURL: "http://localhost:3000", basePath: "/api/data" }, + site: { baseURL: "http://localhost:3000", basePath: "/pages" }, + queryClient, + plugins: { thirdPartyProbe: thirdPartyProbeClientPlugin() }, +}); + +export function ThirdPartyPluginFixture() { + return ( + + ); +} diff --git a/packages/cli/scripts/generate-legacy-next-render-hashes.mjs b/packages/cli/scripts/generate-legacy-next-render-hashes.mjs new file mode 100644 index 000000000..41ce67077 --- /dev/null +++ b/packages/cli/scripts/generate-legacy-next-render-hashes.mjs @@ -0,0 +1,166 @@ +#!/usr/bin/env node + +import { execFileSync } from "node:child_process"; +import { mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; + +const scriptDirectory = dirname(fileURLToPath(import.meta.url)); +const cliRoot = resolve(scriptDirectory, ".."); +const repositoryRoot = resolve(cliRoot, "../.."); +const manifestPath = join(cliRoot, "src/utils/legacy-next-render-hashes.ts"); +const tsxCli = createRequire(import.meta.url).resolve("tsx/cli"); + +const aliases = ["@/", "~/", "./"]; +const legacyPaths = [ + "app/pages/[[...all]]/page.tsx", + "app/pages/layout.tsx", + "app/pages/ssg-blog/page.tsx", + "app/pages/ssg-blog/[slug]/page.tsx", + "app/pages/ssg-cms/[typeSlug]/page.tsx", + "app/pages/ssg-forms/page.tsx", + "app/pages/ssg-kanban/page.tsx", +]; + +// These are the only plugin flags read by the immutable historical Next layout +// templates. CMS and Form Builder are included solely to make their conditional +// SSG files available; they do not alter the layout bytes at either ref. +const historicalRenderers = [ + { + ref: "v3.0.0-rc.2", + layoutVaryingPluginKeys: [ + "ai-chat", + "better-auth-ui", + "blog", + "kanban", + "media", + ], + routeProducerPluginKeys: ["cms", "form-builder"], + }, + { + ref: "e9ff9448", + layoutVaryingPluginKeys: ["ai-chat", "blog", "kanban"], + routeProducerPluginKeys: ["cms", "form-builder"], + }, +]; + +async function renderHistoricalHashes(renderer) { + const checkout = await mkdtemp(join(tmpdir(), "btst-legacy-next-render-")); + try { + const archive = execFileSync( + "git", + [ + "archive", + "--format=tar", + renderer.ref, + "packages/cli/src", + "packages/cli/package.json", + ], + { cwd: repositoryRoot, maxBuffer: 32 * 1024 * 1024 }, + ); + execFileSync("tar", ["-xf", "-", "-C", checkout], { input: archive }); + await symlink( + join(cliRoot, "node_modules"), + join(checkout, "packages/cli/node_modules"), + "dir", + ); + + const runnerPath = join(checkout, "render-legacy-next.mjs"); + await writeFile( + runnerPath, + `import { createHash } from "node:crypto" +import { buildScaffoldPlan } from "./packages/cli/src/utils/scaffold-plan.ts" + +const aliases = ${JSON.stringify(aliases)} +const legacyPaths = ${JSON.stringify(legacyPaths)} +const varyingKeys = ${JSON.stringify(renderer.layoutVaryingPluginKeys)} +const routeProducerKeys = ${JSON.stringify(renderer.routeProducerPluginKeys)} +const hashes = new Map(legacyPaths.map((path) => [path, new Set()])) + +for (const alias of aliases) { + for (let mask = 0; mask < 2 ** varyingKeys.length; mask += 1) { + const selectedKeys = varyingKeys.filter((_, index) => mask & (1 << index)) + const plugins = [...new Set([...routeProducerKeys, ...selectedKeys])] + const plan = await buildScaffoldPlan({ + framework: "nextjs", + adapter: "drizzle", + plugins, + alias, + cssFile: "app/globals.css", + }) + for (const path of legacyPaths) { + const file = plan.files.find((candidate) => candidate.path === path) + if (file) { + hashes.get(path).add(createHash("sha256").update(file.content).digest("hex")) + const crlfContent = file.content.replaceAll("\\n", "\\r\\n") + hashes.get(path).add(createHash("sha256").update(crlfContent).digest("hex")) + } + } + } +} + +console.log(JSON.stringify(Object.fromEntries( + [...hashes].map(([path, values]) => [path, [...values].sort()]), +))) +`, + "utf8", + ); + + return JSON.parse( + execFileSync(process.execPath, [tsxCli, runnerPath], { + cwd: checkout, + encoding: "utf8", + maxBuffer: 32 * 1024 * 1024, + }), + ); + } finally { + await rm(checkout, { recursive: true, force: true }); + } +} + +function formatManifest(hashes) { + const lines = [ + "// Generated by scripts/generate-legacy-next-render-hashes.mjs from exact", + "// buildScaffoldPlan() bytes at v3.0.0-rc.2 and e9ff9448 across every", + "// supported plugin selection, import alias, and LF/CRLF checkout. Consumer", + "// edits therefore remain outside the allowlist and fail closed.", + "export const LEGACY_NEXT_RENDER_HASHES = {", + ]; + for (const path of legacyPaths) { + lines.push(`\t${JSON.stringify(path)}: [`); + for (const hash of hashes[path]) lines.push(`\t\t${JSON.stringify(hash)},`); + lines.push("\t],"); + } + lines.push("} as const;", ""); + return lines.join("\n"); +} + +const combinedHashes = Object.fromEntries( + legacyPaths.map((path) => [path, new Set()]), +); +for (const renderer of historicalRenderers) { + const renderedHashes = await renderHistoricalHashes(renderer); + for (const path of legacyPaths) { + for (const hash of renderedHashes[path]) combinedHashes[path].add(hash); + } +} + +const output = formatManifest( + Object.fromEntries( + legacyPaths.map((path) => [path, [...combinedHashes[path]].sort()]), + ), +); + +if (process.argv.includes("--check")) { + const current = await readFile(manifestPath, "utf8"); + if (current !== output) { + console.error( + "Legacy Next render hashes drifted. Run `pnpm legacy-next-hashes:generate` and review the historical byte changes.", + ); + process.exitCode = 1; + } +} else { + await writeFile(manifestPath, output, "utf8"); +} diff --git a/packages/cli/scripts/test-init.sh b/packages/cli/scripts/test-init.sh index 97337e647..16a3d4823 100644 --- a/packages/cli/scripts/test-init.sh +++ b/packages/cli/scripts/test-init.sh @@ -171,17 +171,52 @@ success "Ran @btst/cli@2.2.3 without adding it to the consumer graph" step "Asserting generated files and patches" test -f "lib/stack.ts" test -f "lib/stack-client.tsx" +test -f "lib/stack-client.server.ts" test -f "lib/query-client.ts" test -f "app/api/data/[[...all]]/route.ts" -test -f "app/pages/[[...all]]/page.tsx" -test -f "app/pages/layout.tsx" -node -e 'const fs=require("fs");const s=fs.readFileSync("lib/stack.ts","utf8");process.exit(s.includes("import { stack } from \"@btst/stack\"")?0:1)' -node -e 'const fs=require("fs");const s=fs.readFileSync("lib/stack.ts","utf8");process.exit(s.includes("mediaBackendPlugin({ storageAdapter: undefined as any })")?0:1)' +test -f "app/(request)/pages/[[...all]]/page.tsx" +test -f "app/(request)/pages/layout.tsx" +test -f "app/(static)/pages/layout.tsx" +test -f "app/pages/client-layout.tsx" +node -e 'const fs=require("fs");const s=fs.readFileSync("lib/stack.ts","utf8");process.exit(s.includes("import { createBackendStack } from \"@btst/stack/api\"")?0:1)' +node -e 'const fs=require("fs");const s=fs.readFileSync("lib/stack.ts","utf8");process.exit(s.includes("mediaBackendPlugin({ storageAdapter: localAdapter() })")?0:1)' +node -e 'const fs=require("fs");const s=fs.readFileSync("lib/stack-client.tsx","utf8");process.exit(s.includes("createClientStack")&&s.includes("NEXT_PUBLIC_BASE_URL")&&!s.includes("getStackClientForRequest")?0:1)' +node -e 'const fs=require("fs");const s=fs.readFileSync("lib/stack-client.server.ts","utf8");process.exit(s.includes("getStackClientForRequest")&&s.includes("resolveTrustedClientOrigins")&&s.includes("filterCredentialForwardingHeaders")&&s.includes("NEXT_PUBLIC_BASE_URL")?0:1)' +node -e 'const fs=require("fs");const request=fs.readFileSync("app/(request)/pages/layout.tsx","utf8"),staticLayout=fs.readFileSync("app/(static)/pages/layout.tsx","utf8"),client=fs.readFileSync("app/pages/client-layout.tsx","utf8");process.exit(request.includes("getServerClientOriginsFromHeaders(await headers())")&&staticLayout.includes("getServerClientOrigins()")&&!staticLayout.includes("next/headers")&&client.includes("getStackClient(queryClient, clientOrigins)")?0:1)' node -e 'const fs=require("fs");const s=fs.readFileSync("app/globals.css","utf8");process.exit(s.includes("@btst/stack/plugins/ui-builder/css")?0:1)' node -e 'const fs=require("fs"),path=require("path");const roots=["app","lib","package.json"];const retired=["@btst","better-auth-ui"].join("/");const read=(p)=>fs.statSync(p).isDirectory()?fs.readdirSync(p).flatMap((n)=>read(path.join(p,n))):[fs.readFileSync(p,"utf8")];process.exit(roots.flatMap(read).some((s)=>s.includes(retired))?1:0)' success "Generation + patch checks passed" -step "Idempotency check (second pass)" +step "Adding third-party public extension fixture" +mkdir -p lib/fixtures +cp "$PACKAGE_DIR/scripts/fixtures/third-party-plugin.tsx" lib/fixtures/third-party-plugin.tsx +success "Third-party fixture uses public plugin definitions and inferred overrides" + +step "Migrating the previous Next.js scaffold on rerun" +rm -r "app/(request)/pages" "app/(static)/pages" +rm "app/pages/client-layout.tsx" +cp -R "$PACKAGE_DIR/scripts/fixtures/legacy-next/e9ff9448/app/pages/." "app/pages/" + +npx @btst/codegen init --yes --framework nextjs --adapter memory --plugins "$MEMORY_PLUGIN_LIST" --skip-install > "$TEST_DIR/init-migration.log" 2>&1 + +test ! -e "app/pages/[[...all]]/page.tsx" +test ! -e "app/pages/layout.tsx" +test ! -e "app/pages/ssg-blog/page.tsx" +test ! -e "app/pages/ssg-blog/[slug]/page.tsx" +test ! -e "app/pages/ssg-cms/[typeSlug]/page.tsx" +test ! -e "app/pages/ssg-forms/page.tsx" +test ! -e "app/pages/ssg-kanban/page.tsx" +test -f "app/(request)/pages/[[...all]]/page.tsx" +test -f "app/(request)/pages/layout.tsx" +test -f "app/(static)/pages/layout.tsx" +test -f "app/(static)/pages/ssg-blog/page.tsx" +test -f "app/(static)/pages/ssg-blog/[slug]/page.tsx" +test -f "app/(static)/pages/ssg-cms/[typeSlug]/page.tsx" +test -f "app/(static)/pages/ssg-kanban/page.tsx" +grep -q "Legacy Next.js files removed: 7" "$TEST_DIR/init-migration.log" +success "Previous scaffold migrated without duplicate /pages routes" + +step "Idempotency check after migration" write_project_hash "$TEST_DIR/init-before.hash" npx @btst/codegen init --yes --framework nextjs --adapter memory --plugins "$MEMORY_PLUGIN_LIST" --skip-install > "$TEST_DIR/init-second.log" 2>&1 @@ -197,7 +232,7 @@ step "Verifying compile on the compatible memory scaffold" success "Keeping generated BTST CSS imports from the selected plugins" step "Compiling fixture project" -npm run build +NEXT_PUBLIC_BASE_URL=http://localhost:3000 npm run build success "Fixture build succeeded" TEST_PASSED=true diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 398ce7fc2..0f4365853 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -36,6 +36,7 @@ import { } from "../utils/passthrough"; import { buildScaffoldPlan } from "../utils/scaffold-plan"; import { collectPrerequisiteWarnings } from "../utils/validate-prerequisites"; +import { migrateLegacyNextScaffold } from "../utils/legacy-next-scaffold"; import type { Adapter, Framework, InitOptions, PluginKey } from "../types"; type InitCliOptions = InitOptions; @@ -255,6 +256,10 @@ export function createInitCommand() { alias, cssFile: finalCssFile, }); + const removedLegacyFiles = + framework === "nextjs" + ? await migrateLegacyNextScaffold(cwd, plan.files, conflictPolicy) + : []; const writeResult = await writePlannedFiles( cwd, @@ -344,7 +349,7 @@ export function createInitCommand() { const layoutStatus = framework === "nextjs" - ? `yes (generated ${plan.pagesLayoutPath ?? "app/pages/layout.tsx"})` + ? `yes (generated ${plan.pagesLayoutPath ?? "app/(request)/pages/layout.tsx"})` : layoutPatch.updated ? "yes" : layoutPatch.warning @@ -362,6 +367,7 @@ export function createInitCommand() { outro(`BTST init complete. Files written: ${writeResult.written.length} Files skipped: ${writeResult.skipped.length} +Legacy Next.js files removed: ${removedLegacyFiles.length} CSS updated: ${cssPatch.updated ? "yes" : "no"} Layout patched: ${layoutStatus} ${routesList} diff --git a/packages/cli/src/templates/nextjs/form-demo-client.tsx.hbs b/packages/cli/src/templates/nextjs/form-demo-client.tsx.hbs new file mode 100644 index 000000000..182dd4492 --- /dev/null +++ b/packages/cli/src/templates/nextjs/form-demo-client.tsx.hbs @@ -0,0 +1,84 @@ +"use client" + +import { useMemo, useState } from "react" +import { QueryClientProvider } from "@tanstack/react-query" +import { StackProvider } from "@btst/stack/context" +import { nextRouter } from "@btst/stack/next" +import { FormRenderer } from "@btst/stack/plugins/form-builder/client/components" +import { getOrCreateQueryClient } from "{{alias}}lib/query-client" +import { getStackClient, type StackClientOptions } from "{{alias}}lib/stack-client" +import { Loader2, AlertCircle } from "lucide-react" + +interface FormDemoPageClientProps { + slug: string + clientOrigins: StackClientOptions +} + +export default function FormDemoPageClient({ + slug, + clientOrigins, +}: FormDemoPageClientProps) { + const [queryClient] = useState(() => getOrCreateQueryClient()) + const browserStack = useMemo( + () => getStackClient(queryClient, clientOrigins), + [clientOrigins.apiOrigin, clientOrigins.siteOrigin, queryClient], + ) + + return ( + + +
+
+
+ { + console.log("Form submitted:", submission) + }} + onError={(error) => { + console.error("Form error:", error) + }} + LoadingComponent={FormLoadingState} + ErrorComponent={FormErrorState} + className="space-y-6" + /> +
+
+
+
+
+ ) +} + +function FormLoadingState() { + return ( +
+ +

Loading form...

+
+ ) +} + +function FormErrorState({ error }: { error: Error }) { + return ( +
+ +
+

Form not found

+

+ {error.message || "The form you're looking for doesn't exist or is no longer available."} +

+
+
+ ) +} diff --git a/packages/cli/src/templates/nextjs/form-demo-page.tsx.hbs b/packages/cli/src/templates/nextjs/form-demo-page.tsx.hbs index 25408f290..a1137dde4 100644 --- a/packages/cli/src/templates/nextjs/form-demo-page.tsx.hbs +++ b/packages/cli/src/templates/nextjs/form-demo-page.tsx.hbs @@ -1,83 +1,17 @@ -"use client" - -import { useState } from "react" -import { useParams } from "next/navigation" -import { QueryClientProvider } from "@tanstack/react-query" -import { StackProvider } from "@btst/stack/context" -import { nextRouter } from "@btst/stack/next" -import { FormRenderer } from "@btst/stack/plugins/form-builder/client/components" -import type { FormBuilderPluginOverrides } from "@btst/stack/plugins/form-builder/client" -import { getOrCreateQueryClient } from "{{alias}}lib/query-client" -import { Loader2, AlertCircle } from "lucide-react" - -const getBaseURL = () => - typeof window !== "undefined" - ? window.location.origin - : process.env.{{publicSiteURLVar}} || process.env.BASE_URL || "http://localhost:3000" - -type PluginOverrides = { - formBuilder: FormBuilderPluginOverrides -} +import { headers } from "next/headers" +import { getServerClientOriginsFromHeaders } from "{{alias}}lib/stack-client.server" +import FormDemoPageClient from "./client" /** * Public form demo page — renders any published form by slug. * Access at: /form-demo/ */ -export default function FormDemoPage() { - const params = useParams() - const slug = params.slug as string - const [queryClient] = useState(() => getOrCreateQueryClient()) - const baseURL = getBaseURL() - - return ( - - - basePath="" - router={nextRouter()} - api={{{providerApiLiteral}}} - > -
-
-
- { - console.log("Form submitted:", submission) - }} - onError={(error) => { - console.error("Form error:", error) - }} - LoadingComponent={FormLoadingState} - ErrorComponent={FormErrorState} - className="space-y-6" - /> -
-
-
-
-
- ) -} - -function FormLoadingState() { - return ( -
- -

Loading form...

-
- ) -} - -function FormErrorState({ error }: { error: Error }) { - return ( -
- -
-

Form not found

-

- {error.message || "The form you're looking for doesn't exist or is no longer available."} -

-
-
- ) +export default async function FormDemoPage({ + params, +}: { + params: Promise<{ slug: string }> +}) { + const [{ slug }, requestHeaders] = await Promise.all([params, headers()]) + const clientOrigins = getServerClientOriginsFromHeaders(requestHeaders) + return } diff --git a/packages/cli/src/templates/nextjs/pages-client-layout.tsx.hbs b/packages/cli/src/templates/nextjs/pages-client-layout.tsx.hbs new file mode 100644 index 000000000..fe0b27a62 --- /dev/null +++ b/packages/cli/src/templates/nextjs/pages-client-layout.tsx.hbs @@ -0,0 +1,70 @@ +"use client" + +import { StackProvider } from "@btst/stack/context" +import { nextRouter } from "@btst/stack/next" +{{#if hasAiChat}} +import { ChatLayout } from "@btst/stack/plugins/ai-chat/client" +{{/if}} +import { QueryClientProvider } from "@tanstack/react-query" +import { useMemo } from "react" +{{#if hasAiChat}} +import { usePathname } from "next/navigation" +{{/if}} +import { getOrCreateQueryClient } from "{{alias}}lib/query-client" +import { getStackClient, type StackClientOptions } from "{{alias}}lib/stack-client" + +export function BtstPagesClientLayout({ + children, + clientOrigins, +}: { + children: React.ReactNode + clientOrigins: StackClientOptions +}) { + const queryClient = getOrCreateQueryClient() + const browserStack = useMemo( + () => getStackClient(queryClient, clientOrigins), + [clientOrigins.apiOrigin, clientOrigins.siteOrigin, queryClient], + ) +{{#if hasAiChat}} + const hasApiKey = typeof process !== "undefined" && !!process.env.NEXT_PUBLIC_HAS_OPENAI_KEY + const pathname = usePathname() + const showChatWidget = !pathname.startsWith("/pages/chat") +{{/if}} + return ( + + +{{#if hasAiChat}} + {!hasApiKey && ( +
+ Add OPENAI_API_KEY to{" "} + .env.local to enable AI chat. +
+ )} +{{/if}} + {children} +{{#if hasAiChat}} + {/* Floating AI chat widget — hidden on /pages/chat/* where the full UI is shown */} + {showChatWidget && ( +
+ +
+ )} +{{/if}} +
+
+ ) +} diff --git a/packages/cli/src/templates/nextjs/pages-layout.tsx.hbs b/packages/cli/src/templates/nextjs/pages-layout.tsx.hbs index e2bb469e0..9c27f4e71 100644 --- a/packages/cli/src/templates/nextjs/pages-layout.tsx.hbs +++ b/packages/cli/src/templates/nextjs/pages-layout.tsx.hbs @@ -1,64 +1,17 @@ -"use client" +import type { ReactNode } from "react" +import { headers } from "next/headers" +import { BtstPagesClientLayout } from "../../pages/client-layout" +import { getServerClientOriginsFromHeaders } from "{{alias}}lib/stack-client.server" -import { StackProvider } from "@btst/stack/context" -import { nextRouter } from "@btst/stack/next" -{{#if hasAiChat}} -import { ChatLayout } from "@btst/stack/plugins/ai-chat/client" -{{/if}} -import { QueryClientProvider } from "@tanstack/react-query" -{{#if hasAiChat}} -import { usePathname } from "next/navigation" -{{/if}} -import { getOrCreateQueryClient } from "{{alias}}lib/query-client" -import { getStackClient } from "{{alias}}lib/stack-client" - -export default function BtstPagesLayout({ +export default async function BtstPagesLayout({ children, }: { - children: React.ReactNode + children: ReactNode }) { - const queryClient = getOrCreateQueryClient() - const stack = getStackClient(queryClient) -{{#if hasAiChat}} - const hasApiKey = typeof process !== "undefined" && !!process.env.NEXT_PUBLIC_HAS_OPENAI_KEY - const pathname = usePathname() - const showChatWidget = !pathname.startsWith("/pages/chat") -{{/if}} + const clientOrigins = getServerClientOriginsFromHeaders(await headers()) return ( - - -{{#if hasAiChat}} - {!hasApiKey && ( -
- Add OPENAI_API_KEY to{" "} - .env.local to enable AI chat. -
- )} -{{/if}} - {children} -{{#if hasAiChat}} - {/* Floating AI chat widget — hidden on /pages/chat/* where the full UI is shown */} - {showChatWidget && ( -
- -
- )} -{{/if}} -
-
+ + {children} + ) } diff --git a/packages/cli/src/templates/nextjs/pages-route.tsx.hbs b/packages/cli/src/templates/nextjs/pages-route.tsx.hbs index ea316fe4d..32a13a039 100644 --- a/packages/cli/src/templates/nextjs/pages-route.tsx.hbs +++ b/packages/cli/src/templates/nextjs/pages-route.tsx.hbs @@ -1,11 +1,15 @@ import { createNextPage } from "@btst/stack/next" +import { headers } from "next/headers" import { getOrCreateQueryClient } from "{{alias}}lib/query-client" -import { getStackClient } from "{{alias}}lib/stack-client" +import { getStackClientForRequest } from "{{alias}}lib/stack-client.server" export const dynamic = "force-dynamic" const page = createNextPage({ - getStackClient: (queryClient) => getStackClient(queryClient), + getStackClient: async (queryClient) => + getStackClientForRequest(queryClient, { + headers: new Headers(await headers()), + }), getQueryClient: getOrCreateQueryClient, }) export default page.Page diff --git a/packages/cli/src/templates/nextjs/pages-static-layout.tsx.hbs b/packages/cli/src/templates/nextjs/pages-static-layout.tsx.hbs new file mode 100644 index 000000000..64bd91b3b --- /dev/null +++ b/packages/cli/src/templates/nextjs/pages-static-layout.tsx.hbs @@ -0,0 +1,16 @@ +import type { ReactNode } from "react" +import { BtstPagesClientLayout } from "../../pages/client-layout" +import { getServerClientOrigins } from "{{alias}}lib/stack-client.server" + +export default function BtstStaticPagesLayout({ + children, +}: { + children: ReactNode +}) { + const clientOrigins = getServerClientOrigins() + return ( + + {children} + + ) +} diff --git a/packages/cli/src/templates/nextjs/preview-client.tsx.hbs b/packages/cli/src/templates/nextjs/preview-client.tsx.hbs index 4f72216aa..08a7f8db2 100644 --- a/packages/cli/src/templates/nextjs/preview-client.tsx.hbs +++ b/packages/cli/src/templates/nextjs/preview-client.tsx.hbs @@ -1,36 +1,43 @@ "use client" -import { useState } from "react" +import { useMemo, useState } from "react" import Link from "next/link" import { QueryClientProvider } from "@tanstack/react-query" import { StackProvider } from "@btst/stack/context" import { nextRouter } from "@btst/stack/next" import { getOrCreateQueryClient } from "{{alias}}lib/query-client" -import { getStackClient } from "{{alias}}lib/stack-client" +import { getStackClient, type StackClientOptions } from "{{alias}}lib/stack-client" import { PageRenderer } from "@btst/stack/plugins/ui-builder/client" interface PreviewPageClientProps { slug: string + clientOrigins: StackClientOptions } /** * Renders a published UI Builder page by slug. * Access at: /preview/ */ -export default function PreviewPageClient({ slug }: PreviewPageClientProps) { +export default function PreviewPageClient({ + slug, + clientOrigins, +}: PreviewPageClientProps) { const [queryClient] = useState(() => getOrCreateQueryClient()) - const stack = getStackClient(queryClient) + const browserStack = useMemo( + () => getStackClient(queryClient, clientOrigins), + [clientOrigins.apiOrigin, clientOrigins.siteOrigin, queryClient], + ) return ( diff --git a/packages/cli/src/templates/nextjs/preview-page.tsx.hbs b/packages/cli/src/templates/nextjs/preview-page.tsx.hbs index fe68660b0..d06262cd9 100644 --- a/packages/cli/src/templates/nextjs/preview-page.tsx.hbs +++ b/packages/cli/src/templates/nextjs/preview-page.tsx.hbs @@ -1,3 +1,5 @@ +import { headers } from "next/headers" +import { getServerClientOriginsFromHeaders } from "{{alias}}lib/stack-client.server" import PreviewPageClient from "./client" /** @@ -9,6 +11,7 @@ export default async function PreviewPage({ }: { params: Promise<{ slug: string }> }) { - const { slug } = await params - return + const [{ slug }, requestHeaders] = await Promise.all([params, headers()]) + const clientOrigins = getServerClientOriginsFromHeaders(requestHeaders) + return } diff --git a/packages/cli/src/templates/nextjs/public-chat-client.tsx.hbs b/packages/cli/src/templates/nextjs/public-chat-client.tsx.hbs new file mode 100644 index 000000000..220b80911 --- /dev/null +++ b/packages/cli/src/templates/nextjs/public-chat-client.tsx.hbs @@ -0,0 +1,43 @@ +"use client" + +import { ChatLayout } from "@btst/stack/plugins/ai-chat/client" +import { StackProvider } from "@btst/stack/context" +import { nextRouter } from "@btst/stack/next" +import { QueryClientProvider } from "@tanstack/react-query" +import { useMemo } from "react" +import { getOrCreateQueryClient } from "{{alias}}lib/query-client" +import { getStackClient, type StackClientOptions } from "{{alias}}lib/stack-client" + +export default function PublicChatPageClient({ + clientOrigins, +}: { + clientOrigins: StackClientOptions +}) { + const queryClient = getOrCreateQueryClient() + const browserStack = useMemo( + () => getStackClient(queryClient, clientOrigins), + [clientOrigins.apiOrigin, clientOrigins.siteOrigin, queryClient], + ) + + return ( + + +
+
+ +
+
+
+
+ ) +} diff --git a/packages/cli/src/templates/nextjs/public-chat-page.tsx.hbs b/packages/cli/src/templates/nextjs/public-chat-page.tsx.hbs index 8b99deb5f..672f67cfc 100644 --- a/packages/cli/src/templates/nextjs/public-chat-page.tsx.hbs +++ b/packages/cli/src/templates/nextjs/public-chat-page.tsx.hbs @@ -1,42 +1,12 @@ -"use client" - -import { createClientStack } from "@btst/stack/client" -import { aiChatClientPlugin, ChatLayout } from "@btst/stack/plugins/ai-chat/client" -import { StackProvider } from "@btst/stack/context" -import { nextRouter } from "@btst/stack/next" -import { QueryClientProvider } from "@tanstack/react-query" -import { getOrCreateQueryClient } from "{{alias}}lib/query-client" - -const getBaseURL = () => - typeof window !== "undefined" - ? window.location.origin - : process.env.{{publicSiteURLVar}} || process.env.BASE_URL || "http://localhost:3000" +import { headers } from "next/headers" +import { getServerClientOriginsFromHeaders } from "{{alias}}lib/stack-client.server" +import PublicChatPageClient from "./client" /** * Public Chat Page — AI chat in public mode (no login required). * Renders a full-screen chat interface using the ai-chat plugin. */ -export default function PublicChatPage() { - const queryClient = getOrCreateQueryClient() - const baseURL = getBaseURL() - const stack = createClientStack({ - api: { baseURL, basePath: "/api/data" }, - site: { baseURL, basePath: "/" }, - queryClient, - plugins: { - aiChat: aiChatClientPlugin({ mode: "public" }), - }, - }) - - return ( - - -
-
- -
-
-
-
- ) +export default async function PublicChatPage() { + const clientOrigins = getServerClientOriginsFromHeaders(await headers()) + return } diff --git a/packages/cli/src/templates/nextjs/sitemap.ts.hbs b/packages/cli/src/templates/nextjs/sitemap.ts.hbs index 14c330782..f43861102 100644 --- a/packages/cli/src/templates/nextjs/sitemap.ts.hbs +++ b/packages/cli/src/templates/nextjs/sitemap.ts.hbs @@ -1,14 +1,17 @@ import type { MetadataRoute } from "next" +import { headers } from "next/headers" import { QueryClient } from "@tanstack/react-query" -import { getStackClient } from "{{alias}}lib/stack-client" +import { getStackClientForRequest } from "{{alias}}lib/stack-client.server" // Force dynamic rendering so the sitemap is always fresh export const dynamic = "force-dynamic" export default async function sitemap(): Promise { const queryClient = new QueryClient() - const lib = getStackClient(queryClient) - const entries = await lib.generateSitemap() + const stack = getStackClientForRequest(queryClient, { + headers: new Headers(await headers()), + }) + const entries = await stack.generateSitemap() return entries.map((e) => ({ url: e.url, lastModified: e.lastModified, diff --git a/packages/cli/src/templates/react-router/form-demo-route.tsx.hbs b/packages/cli/src/templates/react-router/form-demo-route.tsx.hbs index 7e0a998e2..b0e478f18 100644 --- a/packages/cli/src/templates/react-router/form-demo-route.tsx.hbs +++ b/packages/cli/src/templates/react-router/form-demo-route.tsx.hbs @@ -1,20 +1,16 @@ -import { useState } from "react" -import { useParams } from "react-router" +import { useMemo, useState } from "react" +import { useLoaderData, useParams, type LoaderFunctionArgs } from "react-router" import { QueryClientProvider } from "@tanstack/react-query" import { StackProvider } from "@btst/stack/context" import { reactRouter } from "@btst/stack/react-router" import { FormRenderer } from "@btst/stack/plugins/form-builder/client/components" -import type { FormBuilderPluginOverrides } from "@btst/stack/plugins/form-builder/client" import { getOrCreateQueryClient } from "{{alias}}lib/query-client" +import { getStackClient } from "{{alias}}lib/stack-client" +import { getServerClientOrigins } from "{{alias}}lib/stack-client.server" import { Loader2, AlertCircle } from "lucide-react" -const getBaseURL = () => - typeof window !== "undefined" - ? window.location.origin - : process.env.{{publicSiteURLVar}} || process.env.BASE_URL || "http://localhost:5173" - -type PluginOverrides = { - formBuilder: FormBuilderPluginOverrides +export function loader({ request }: LoaderFunctionArgs) { + return getServerClientOrigins(new URL(request.url).origin) } /** @@ -26,15 +22,25 @@ type PluginOverrides = { */ export default function FormDemoPage() { const { slug } = useParams() + const { apiOrigin, siteOrigin } = useLoaderData() const [queryClient] = useState(() => getOrCreateQueryClient()) - const baseURL = getBaseURL() + const browserStack = useMemo( + () => getStackClient(queryClient, { apiOrigin, siteOrigin }), + [apiOrigin, queryClient, siteOrigin], + ) return ( - - basePath="" +
diff --git a/packages/cli/src/templates/react-router/pages-layout.tsx.hbs b/packages/cli/src/templates/react-router/pages-layout.tsx.hbs index 4168affe3..9ce2e25e1 100644 --- a/packages/cli/src/templates/react-router/pages-layout.tsx.hbs +++ b/packages/cli/src/templates/react-router/pages-layout.tsx.hbs @@ -4,13 +4,23 @@ import { reactRouter } from "@btst/stack/react-router" import { ChatLayout } from "@btst/stack/plugins/ai-chat/client" {{/if}} import { QueryClientProvider } from "@tanstack/react-query" -import { Outlet{{#if hasAiChat}}, useLocation{{/if}} } from "react-router" +import { useMemo } from "react" +import { Outlet{{#if hasAiChat}}, useLocation{{/if}}, useLoaderData, type LoaderFunctionArgs } from "react-router" import { getOrCreateQueryClient } from "{{alias}}lib/query-client" import { getStackClient } from "{{alias}}lib/stack-client" +import { getServerClientOrigins } from "{{alias}}lib/stack-client.server" + +export function loader({ request }: LoaderFunctionArgs) { + return getServerClientOrigins(new URL(request.url).origin) +} export default function BtstPagesLayout() { const queryClient = getOrCreateQueryClient() - const stack = getStackClient(queryClient) + const { apiOrigin, siteOrigin } = useLoaderData() + const browserStack = useMemo( + () => getStackClient(queryClient, { apiOrigin, siteOrigin }), + [apiOrigin, queryClient, siteOrigin], + ) {{#if hasAiChat}} const hasApiKey = !!import.meta.env.VITE_HAS_OPENAI_KEY const location = useLocation() @@ -19,13 +29,13 @@ export default function BtstPagesLayout() { return ( diff --git a/packages/cli/src/templates/react-router/pages-route.tsx.hbs b/packages/cli/src/templates/react-router/pages-route.tsx.hbs index bc46b43b2..8cb66341b 100644 --- a/packages/cli/src/templates/react-router/pages-route.tsx.hbs +++ b/packages/cli/src/templates/react-router/pages-route.tsx.hbs @@ -1,9 +1,15 @@ import { createReactRouterPage } from "@btst/stack/react-router" import { getOrCreateQueryClient } from "{{alias}}lib/query-client" import { getStackClient } from "{{alias}}lib/stack-client" +import { getStackClientForRequest } from "{{alias}}lib/stack-client.server" const page = createReactRouterPage({ getStackClient, getQueryClient: getOrCreateQueryClient }) -export const loader = page.loader +export const loader = page.createLoader((queryClient, { request }) => + getStackClientForRequest(queryClient, { + headers: request.headers, + requestOrigin: new URL(request.url).origin, + }), +) export const meta = page.meta export const ErrorBoundary = page.ErrorBoundary export default page.Component diff --git a/packages/cli/src/templates/react-router/preview-route.tsx.hbs b/packages/cli/src/templates/react-router/preview-route.tsx.hbs index 6ac227215..f3a0945f6 100644 --- a/packages/cli/src/templates/react-router/preview-route.tsx.hbs +++ b/packages/cli/src/templates/react-router/preview-route.tsx.hbs @@ -1,11 +1,16 @@ -import { useState } from "react" -import { Link, useParams } from "react-router" +import { useMemo, useState } from "react" +import { Link, useLoaderData, useParams, type LoaderFunctionArgs } from "react-router" import { QueryClientProvider } from "@tanstack/react-query" import { StackProvider } from "@btst/stack/context" import { reactRouter } from "@btst/stack/react-router" import { PageRenderer } from "@btst/stack/plugins/ui-builder/client" import { getOrCreateQueryClient } from "{{alias}}lib/query-client" import { getStackClient } from "{{alias}}lib/stack-client" +import { getServerClientOrigins } from "{{alias}}lib/stack-client.server" + +export function loader({ request }: LoaderFunctionArgs) { + return getServerClientOrigins(new URL(request.url).origin) +} /** * Renders a published UI Builder page by slug. @@ -16,19 +21,23 @@ import { getStackClient } from "{{alias}}lib/stack-client" */ export default function PreviewPage() { const { slug = "" } = useParams() + const { apiOrigin, siteOrigin } = useLoaderData() const [queryClient] = useState(() => getOrCreateQueryClient()) - const stack = getStackClient(queryClient) + const browserStack = useMemo( + () => getStackClient(queryClient, { apiOrigin, siteOrigin }), + [apiOrigin, queryClient, siteOrigin], + ) return ( diff --git a/packages/cli/src/templates/react-router/public-chat-route.tsx.hbs b/packages/cli/src/templates/react-router/public-chat-route.tsx.hbs index 2e6588a02..627dd103f 100644 --- a/packages/cli/src/templates/react-router/public-chat-route.tsx.hbs +++ b/packages/cli/src/templates/react-router/public-chat-route.tsx.hbs @@ -1,16 +1,18 @@ "use client" -import { createClientStack } from "@btst/stack/client" -import { aiChatClientPlugin, ChatLayout } from "@btst/stack/plugins/ai-chat/client" +import { ChatLayout } from "@btst/stack/plugins/ai-chat/client" import { StackProvider } from "@btst/stack/context" import { reactRouter } from "@btst/stack/react-router" import { QueryClientProvider } from "@tanstack/react-query" +import { useMemo } from "react" +import { useLoaderData, type LoaderFunctionArgs } from "react-router" import { getOrCreateQueryClient } from "{{alias}}lib/query-client" +import { getStackClient } from "{{alias}}lib/stack-client" +import { getServerClientOrigins } from "{{alias}}lib/stack-client.server" -const getBaseURL = () => - typeof window !== "undefined" - ? window.location.origin - : process.env.{{publicSiteURLVar}} || process.env.BASE_URL || "http://localhost:5173" +export function loader({ request }: LoaderFunctionArgs) { + return getServerClientOrigins(new URL(request.url).origin) +} /** * Public Chat Page — AI chat in public mode (no login required). @@ -20,19 +22,25 @@ const getBaseURL = () => */ export default function PublicChatPage() { const queryClient = getOrCreateQueryClient() - const baseURL = getBaseURL() - const stack = createClientStack({ - api: { baseURL, basePath: "/api/data" }, - site: { baseURL, basePath: "/" }, - queryClient, - plugins: { - aiChat: aiChatClientPlugin({ mode: "public" }), - }, - }) + const { apiOrigin, siteOrigin } = useLoaderData() + const browserStack = useMemo( + () => getStackClient(queryClient, { apiOrigin, siteOrigin }), + [apiOrigin, queryClient, siteOrigin], + ) return ( - +
diff --git a/packages/cli/src/templates/react-router/sitemap.xml.ts.hbs b/packages/cli/src/templates/react-router/sitemap.xml.ts.hbs index 4b14d5559..b8bee7a2a 100644 --- a/packages/cli/src/templates/react-router/sitemap.xml.ts.hbs +++ b/packages/cli/src/templates/react-router/sitemap.xml.ts.hbs @@ -1,12 +1,15 @@ import type { Route } from "./+types/sitemap.xml" import { QueryClient } from "@tanstack/react-query" -import { getStackClient } from "{{alias}}lib/stack-client" +import { getStackClientForRequest } from "{{alias}}lib/stack-client.server" import { sitemapEntryToXmlString } from "@btst/stack/client" -export async function loader({}: Route.LoaderArgs) { +export async function loader({ request }: Route.LoaderArgs) { const queryClient = new QueryClient() - const lib = getStackClient(queryClient) - const entries = await lib.generateSitemap() + const stack = getStackClientForRequest(queryClient, { + headers: request.headers, + requestOrigin: new URL(request.url).origin, + }) + const entries = await stack.generateSitemap() const xml = sitemapEntryToXmlString(entries) return new Response(xml, { headers: { diff --git a/packages/cli/src/templates/shared/lib/stack-client.server.ts.hbs b/packages/cli/src/templates/shared/lib/stack-client.server.ts.hbs new file mode 100644 index 000000000..01521c76c --- /dev/null +++ b/packages/cli/src/templates/shared/lib/stack-client.server.ts.hbs @@ -0,0 +1,75 @@ +import { + filterCredentialForwardingHeaders, + resolveTrustedClientOrigins, +} from "@btst/stack/client/server" +import type { QueryClient } from "@tanstack/react-query" +import { createAppClientStack } from "./stack-client" + +const requestBoundaryMarker = "BTST_REQUEST_HEADERS_SERVER_MARKER" + +interface RequestStackClientOptions { + headers: HeadersInit + requestOrigin?: string +} + +function configuredApiOrigin() { + return ( + process.env.BTST_API_URL || + {{{browserApiURLExpression}}} || + {{{migrationServerBaseURLExpression}}} || + process.env.BASE_URL + ) +} + +function configuredSiteOrigin() { + return ( + process.env.BTST_SITE_URL || + {{{browserSiteURLExpression}}} || + {{{migrationServerBaseURLExpression}}} || + process.env.BASE_URL + ) +} + +function requestOriginFromHeaders(headers: Headers) { + const host = (headers.get("x-forwarded-host") || headers.get("host")) + ?.split(",")[0] + ?.trim() + if (!host) return undefined + const protocol = + headers.get("x-forwarded-proto")?.split(",")[0]?.trim() || "http" + return `${protocol}://${host}` +} + +export function getServerClientOrigins(requestOrigin?: string) { + return resolveTrustedClientOrigins({ + configuredApiOrigin: configuredApiOrigin(), + configuredSiteOrigin: configuredSiteOrigin(), + requestOrigin, + isProduction: process.env.NODE_ENV === "production", + apiLabel: "BTST_API_URL, {{{publicApiURLVar}}}, {{{migrationBaseURLVar}}}, or BASE_URL", + siteLabel: "BTST_SITE_URL, {{{publicSiteURLVar}}}, {{{migrationBaseURLVar}}}, or BASE_URL", + }) +} + +export function getServerClientOriginsFromHeaders(headers: HeadersInit) { + const requestHeaders = new Headers(headers) + return getServerClientOrigins(requestOriginFromHeaders(requestHeaders)) +} + +/** Creates a fresh credentialed server stack for one trusted API origin. */ +export function getStackClientForRequest( + queryClient: QueryClient, + options: RequestStackClientOptions, +) { + let requestHeaders: Headers + try { + requestHeaders = new Headers(options.headers) + } catch { + throw new TypeError(`${requestBoundaryMarker}: expected request headers`) + } + const origins = options.requestOrigin + ? getServerClientOrigins(options.requestOrigin) + : getServerClientOriginsFromHeaders(requestHeaders) + const headers = filterCredentialForwardingHeaders(requestHeaders) + return createAppClientStack(queryClient, { ...origins, headers }) +} diff --git a/packages/cli/src/templates/shared/lib/stack-client.tsx.hbs b/packages/cli/src/templates/shared/lib/stack-client.tsx.hbs index 597040008..a71412334 100644 --- a/packages/cli/src/templates/shared/lib/stack-client.tsx.hbs +++ b/packages/cli/src/templates/shared/lib/stack-client.tsx.hbs @@ -1,21 +1,36 @@ -import { createStackClient } from "@btst/stack/client" -import { QueryClient } from "@tanstack/react-query" +import { createClientStack } from "@btst/stack/client" +{{#if clientApiEndpointEntries}} +import type { ClientPluginEndpointOverride } from "@btst/stack/client" +{{/if}} +import type { QueryClient } from "@tanstack/react-query" {{#if clientImports}} {{{clientImports}}} {{/if}} -export function getStackClient( +/** Browser-safe origins resolved once by the server layout. */ +export interface StackClientOptions { + /** Trusted destination for browser API requests. */ + apiOrigin?: string + /** Trusted public origin used to build application links. */ + siteOrigin?: string +} + +export function createAppClientStack( queryClient: QueryClient, - options?: { headers?: Headers; origin?: string }, + options?: StackClientOptions & { headers?: HeadersInit }, ) { - const baseURL = getBaseURL(options?.origin) - return createStackClient({ + const siteOrigin = getSiteOrigin(options?.siteOrigin) + const apiOrigin = getApiOrigin(options?.apiOrigin, siteOrigin) +{{#if clientApiEndpointEntries}} + const crossOriginApiEndpoint = getCrossOriginApiEndpoint(apiOrigin, siteOrigin) +{{/if}} + return createClientStack({ api: { - baseURL, + baseURL: apiOrigin, basePath: "/api/data", ...(options?.headers ? { headers: options.headers } : {}), }, - site: { baseURL, basePath: "/pages" }, + site: { baseURL: siteOrigin, basePath: "/pages" }, queryClient, plugins: { {{#if clientEntries}} @@ -24,21 +39,73 @@ export function getStackClient( // Add client plugins here. {{/if}} }, +{{#if clientApiEndpointEntries}} + ...(crossOriginApiEndpoint + ? { + endpoints: { +{{{clientApiEndpointEntries}}} + }, + } + : {}), +{{/if}} }) } -function getBaseURL(serverOrigin?: string) { +/** Creates the header-free stack used by browser providers and static pages. */ +export function getStackClient( + queryClient: QueryClient, + options?: StackClientOptions, +) { + return createAppClientStack(queryClient, options) +} + +function getSiteOrigin(serverOrigin?: string) { + if (serverOrigin) return serverOrigin if (typeof window !== "undefined") { - return {{{browserSiteURLExpression}}} || window.location.origin + return ( + {{{browserSiteURLExpression}}} || + {{{migrationBrowserBaseURLExpression}}} || + window.location.origin + ) } - if (serverOrigin) return serverOrigin // Use literal process.env.XXX so bundlers (Vite define, Next.js, etc.) // can statically replace these at build/transform time. if (process.env.BTST_SITE_URL) return process.env.BTST_SITE_URL if (process.env.{{publicSiteURLVar}}) return process.env.{{publicSiteURLVar}} + if ({{{migrationServerBaseURLExpression}}}) return {{{migrationServerBaseURLExpression}}} if (process.env.BASE_URL) return process.env.BASE_URL - if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}` return "http://localhost:3000" } + +function getApiOrigin(serverOrigin: string | undefined, siteOrigin: string) { + if (serverOrigin) return serverOrigin + if (typeof window !== "undefined") { + return ( + {{{browserApiURLExpression}}} || + {{{migrationBrowserBaseURLExpression}}} || + siteOrigin + ) + } + return ( + process.env.BTST_API_URL || + {{{browserApiURLExpression}}} || + {{{migrationServerBaseURLExpression}}} || + process.env.BASE_URL || + siteOrigin + ) +} + +{{#if clientApiEndpointEntries}} +function getCrossOriginApiEndpoint(apiOrigin: string, siteOrigin: string) { + if (apiOrigin === siteOrigin) return undefined + return { + api: { + baseURL: apiOrigin, + basePath: "/api/data", + credentials: "include", + }, + } satisfies ClientPluginEndpointOverride +} +{{/if}} diff --git a/packages/cli/src/templates/shared/lib/stack.ts.hbs b/packages/cli/src/templates/shared/lib/stack.ts.hbs index c25b762d6..81a8f9858 100644 --- a/packages/cli/src/templates/shared/lib/stack.ts.hbs +++ b/packages/cli/src/templates/shared/lib/stack.ts.hbs @@ -1,4 +1,4 @@ -import { stack } from "@btst/stack" +import { createBackendStack } from "@btst/stack/api" {{{adapterImport}}} {{#if backendImports}} {{{backendImports}}} @@ -10,8 +10,8 @@ import { stack } from "@btst/stack" // Next.js evaluates lib/stack.ts in multiple bundle contexts (API routes + page bundle) // that share the same process. Pin to globalThis so both contexts reference the same // in-memory store. -function createStack() { - const s = stack({ +function createAppStack() { + const appStack = createBackendStack({ basePath: "/api/data", plugins: { {{#if backendEntries}} @@ -23,15 +23,15 @@ function createStack() { {{{adapterStackLine}}} }) - return s + return appStack } -type AppStack = ReturnType +type AppStack = ReturnType const globalForStack = globalThis as typeof globalThis & { __btst_stack__?: AppStack } -export const myStack = globalForStack.__btst_stack__ ??= createStack() +export const myStack = globalForStack.__btst_stack__ ??= createAppStack() {{else}} -export const myStack = stack({ +export const myStack = createBackendStack({ basePath: "/api/data", plugins: { {{#if backendEntries}} diff --git a/packages/cli/src/templates/tanstack/form-demo-route.tsx.hbs b/packages/cli/src/templates/tanstack/form-demo-route.tsx.hbs index 4db2e58c0..625be04cd 100644 --- a/packages/cli/src/templates/tanstack/form-demo-route.tsx.hbs +++ b/packages/cli/src/templates/tanstack/form-demo-route.tsx.hbs @@ -1,41 +1,44 @@ import { createFileRoute } from "@tanstack/react-router" -import { useState } from "react" +import { useMemo, useState } from "react" import { QueryClientProvider } from "@tanstack/react-query" import { StackProvider } from "@btst/stack/context" import { tanstackRouter } from "@btst/stack/tanstack" import { FormRenderer } from "@btst/stack/plugins/form-builder/client/components" -import type { FormBuilderPluginOverrides } from "@btst/stack/plugins/form-builder/client" import { getOrCreateQueryClient } from "{{alias}}lib/query-client" +import { getStackClient } from "{{alias}}lib/stack-client" +import { getTrustedClientOrigins } from "{{alias}}lib/stack-client.origins" import { Loader2, AlertCircle } from "lucide-react" export const Route = createFileRoute("/form-demo/$slug")({ + loader: async () => getTrustedClientOrigins(), component: FormDemoPage, }) -const getBaseURL = () => - typeof window !== "undefined" - ? window.location.origin - : process.env.{{publicSiteURLVar}} || process.env.BASE_URL || "http://localhost:3000" - -type PluginOverrides = { - formBuilder: FormBuilderPluginOverrides -} - /** * Public form demo page — renders any published form by slug. * Access at: /form-demo/ */ function FormDemoPage() { const { slug } = Route.useParams() + const { apiOrigin, siteOrigin } = Route.useLoaderData() const [queryClient] = useState(() => getOrCreateQueryClient()) - const baseURL = getBaseURL() + const browserStack = useMemo( + () => getStackClient(queryClient, { apiOrigin, siteOrigin }), + [apiOrigin, queryClient, siteOrigin], + ) return ( - - basePath="" +
diff --git a/packages/cli/src/templates/tanstack/pages-layout.tsx.hbs b/packages/cli/src/templates/tanstack/pages-layout.tsx.hbs index 808c7b94a..b4977f8ca 100644 --- a/packages/cli/src/templates/tanstack/pages-layout.tsx.hbs +++ b/packages/cli/src/templates/tanstack/pages-layout.tsx.hbs @@ -5,16 +5,23 @@ import { tanstackRouter } from "@btst/stack/tanstack" import { ChatLayout } from "@btst/stack/plugins/ai-chat/client" {{/if}} import { QueryClientProvider } from "@tanstack/react-query" +import { useMemo } from "react" import { getOrCreateQueryClient } from "{{alias}}lib/query-client" import { getStackClient } from "{{alias}}lib/stack-client" +import { getTrustedClientOrigins } from "{{alias}}lib/stack-client.origins" export const Route = createFileRoute("/pages")({ + loader: async () => getTrustedClientOrigins(), component: BtstPagesLayout, }) function BtstPagesLayout() { const queryClient = getOrCreateQueryClient() - const stack = getStackClient(queryClient) + const { apiOrigin, siteOrigin } = Route.useLoaderData() + const browserStack = useMemo( + () => getStackClient(queryClient, { apiOrigin, siteOrigin }), + [apiOrigin, queryClient, siteOrigin], + ) {{#if hasAiChat}} const hasApiKey = !!import.meta.env.VITE_HAS_OPENAI_KEY const location = useLocation() @@ -23,13 +30,13 @@ function BtstPagesLayout() { return ( diff --git a/packages/cli/src/templates/tanstack/pages-route.tsx.hbs b/packages/cli/src/templates/tanstack/pages-route.tsx.hbs index fdd96757f..eded418b8 100644 --- a/packages/cli/src/templates/tanstack/pages-route.tsx.hbs +++ b/packages/cli/src/templates/tanstack/pages-route.tsx.hbs @@ -1,8 +1,35 @@ import { createFileRoute } from "@tanstack/react-router" import { createTanStackPageOptions } from "@btst/stack/tanstack" +import type { QueryClient } from "@tanstack/react-query" +import { createIsomorphicFn } from "@tanstack/react-start" +import { getRequest } from "@tanstack/react-start/server" import { getOrCreateQueryClient } from "{{alias}}lib/query-client" import { getStackClient } from "{{alias}}lib/stack-client" +import { getStackClientForRequest } from "{{alias}}lib/stack-client.server" +import { getTrustedClientOrigins } from "{{alias}}lib/stack-client.origins" + +const getLoaderRequestContext = createIsomorphicFn() + .server(() => { + const request = getRequest() + return { + headers: request.headers, + requestOrigin: new URL(request.url).origin, + } + }) + .client(() => undefined) + +const getNavigationClientStack = async (queryClient: QueryClient) => + getStackClient(queryClient, await getTrustedClientOrigins()) export const Route = createFileRoute("/pages/$")( - createTanStackPageOptions({ getStackClient, getQueryClient: getOrCreateQueryClient }), + createTanStackPageOptions({ + getStackClient, + getLoaderStackClient: async (queryClient) => { + const requestContext = await getLoaderRequestContext() + return requestContext + ? getStackClientForRequest(queryClient, requestContext) + : getNavigationClientStack(queryClient) + }, + getQueryClient: getOrCreateQueryClient, + }), ) diff --git a/packages/cli/src/templates/tanstack/preview-route.tsx.hbs b/packages/cli/src/templates/tanstack/preview-route.tsx.hbs index 94c5f9caf..ad9579fc1 100644 --- a/packages/cli/src/templates/tanstack/preview-route.tsx.hbs +++ b/packages/cli/src/templates/tanstack/preview-route.tsx.hbs @@ -1,13 +1,15 @@ import { createFileRoute, Link } from "@tanstack/react-router" -import { useState } from "react" +import { useMemo, useState } from "react" import { QueryClientProvider } from "@tanstack/react-query" import { StackProvider } from "@btst/stack/context" import { tanstackRouter } from "@btst/stack/tanstack" import { PageRenderer } from "@btst/stack/plugins/ui-builder/client" import { getOrCreateQueryClient } from "{{alias}}lib/query-client" import { getStackClient } from "{{alias}}lib/stack-client" +import { getTrustedClientOrigins } from "{{alias}}lib/stack-client.origins" export const Route = createFileRoute("/preview/$slug")({ + loader: async () => getTrustedClientOrigins(), component: PreviewPage, }) @@ -17,19 +19,23 @@ export const Route = createFileRoute("/preview/$slug")({ */ function PreviewPage() { const { slug } = Route.useParams() + const { apiOrigin, siteOrigin } = Route.useLoaderData() const [queryClient] = useState(() => getOrCreateQueryClient()) - const stack = getStackClient(queryClient) + const browserStack = useMemo( + () => getStackClient(queryClient, { apiOrigin, siteOrigin }), + [apiOrigin, queryClient, siteOrigin], + ) return ( diff --git a/packages/cli/src/templates/tanstack/public-chat-route.tsx.hbs b/packages/cli/src/templates/tanstack/public-chat-route.tsx.hbs index 79e5843ac..d02bd6e3e 100644 --- a/packages/cli/src/templates/tanstack/public-chat-route.tsx.hbs +++ b/packages/cli/src/templates/tanstack/public-chat-route.tsx.hbs @@ -1,38 +1,42 @@ import { createFileRoute } from "@tanstack/react-router" -import { createClientStack } from "@btst/stack/client" -import { aiChatClientPlugin, ChatLayout } from "@btst/stack/plugins/ai-chat/client" +import { ChatLayout } from "@btst/stack/plugins/ai-chat/client" import { StackProvider } from "@btst/stack/context" import { tanstackRouter } from "@btst/stack/tanstack" import { QueryClientProvider } from "@tanstack/react-query" +import { useMemo } from "react" import { getOrCreateQueryClient } from "{{alias}}lib/query-client" +import { getStackClient } from "{{alias}}lib/stack-client" +import { getTrustedClientOrigins } from "{{alias}}lib/stack-client.origins" export const Route = createFileRoute("/public-chat")({ + loader: async () => getTrustedClientOrigins(), component: PublicChatPage, }) -const getBaseURL = () => - typeof window !== "undefined" - ? window.location.origin - : process.env.{{publicSiteURLVar}} || process.env.BASE_URL || "http://localhost:3000" - /** * Public Chat Page — AI chat in public mode (no login required). */ function PublicChatPage() { const queryClient = getOrCreateQueryClient() - const baseURL = getBaseURL() - const stack = createClientStack({ - api: { baseURL, basePath: "/api/data" }, - site: { baseURL, basePath: "/" }, - queryClient, - plugins: { - aiChat: aiChatClientPlugin({ mode: "public" }), - }, - }) + const { apiOrigin, siteOrigin } = Route.useLoaderData() + const browserStack = useMemo( + () => getStackClient(queryClient, { apiOrigin, siteOrigin }), + [apiOrigin, queryClient, siteOrigin], + ) return ( - +
diff --git a/packages/cli/src/templates/tanstack/sitemap.xml.ts.hbs b/packages/cli/src/templates/tanstack/sitemap.xml.ts.hbs index ab75ad258..b269de21d 100644 --- a/packages/cli/src/templates/tanstack/sitemap.xml.ts.hbs +++ b/packages/cli/src/templates/tanstack/sitemap.xml.ts.hbs @@ -1,15 +1,18 @@ import { createFileRoute } from "@tanstack/react-router" import { QueryClient } from "@tanstack/react-query" -import { getStackClient } from "{{alias}}lib/stack-client" +import { getStackClientForRequest } from "{{alias}}lib/stack-client.server" import { sitemapEntryToXmlString } from "@btst/stack/client" export const Route = createFileRoute("/sitemap.xml")({ server: { handlers: { - GET: async () => { + GET: async ({ request }) => { const queryClient = new QueryClient() - const lib = getStackClient(queryClient) - const entries = await lib.generateSitemap() + const stack = getStackClientForRequest(queryClient, { + headers: request.headers, + requestOrigin: new URL(request.url).origin, + }) + const entries = await stack.generateSitemap() const xml = sitemapEntryToXmlString(entries) return new Response(xml, { headers: { diff --git a/packages/cli/src/templates/tanstack/stack-client.origins.ts.hbs b/packages/cli/src/templates/tanstack/stack-client.origins.ts.hbs new file mode 100644 index 000000000..278f38983 --- /dev/null +++ b/packages/cli/src/templates/tanstack/stack-client.origins.ts.hbs @@ -0,0 +1,11 @@ +import { createServerFn } from "@tanstack/react-start" +import { getRequest } from "@tanstack/react-start/server" +import { getServerClientOrigins } from "./stack-client.server" + +/** Returns one deployment-trusted origin snapshot on initial load and navigation. */ +export const getTrustedClientOrigins = createServerFn({ method: "GET" }).handler( + () => { + const request = getRequest() + return getServerClientOrigins(new URL(request.url).origin) + }, +) diff --git a/packages/cli/src/utils/__tests__/legacy-next-scaffold.test.ts b/packages/cli/src/utils/__tests__/legacy-next-scaffold.test.ts new file mode 100644 index 000000000..2606ec09e --- /dev/null +++ b/packages/cli/src/utils/__tests__/legacy-next-scaffold.test.ts @@ -0,0 +1,315 @@ +import { createHash } from "node:crypto"; +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; +import { migrateLegacyNextScaffold } from "../legacy-next-scaffold"; +import { LEGACY_NEXT_RENDER_HASHES } from "../legacy-next-render-hashes"; +import type { FileWritePlanItem } from "../../types"; + +const fixtureRoots: string[] = []; +const legacyFixtureRoot = fileURLToPath( + new URL("../../../scripts/fixtures/legacy-next", import.meta.url), +); + +async function createFixture() { + const cwd = join( + process.env.TMPDIR ?? "/tmp", + `btst-legacy-next-${process.pid}-${fixtureRoots.length}`, + ); + fixtureRoots.push(cwd); + await mkdir(cwd, { recursive: true }); + return cwd; +} + +afterEach(async () => { + await Promise.all( + fixtureRoots + .splice(0) + .map((root) => rm(root, { recursive: true, force: true })), + ); +}); + +const currentPlan: FileWritePlanItem[] = [ + { + path: "app/(request)/pages/layout.tsx", + content: "request layout", + description: "request layout", + }, + { + path: "app/(request)/pages/[[...all]]/page.tsx", + content: "request page", + description: "request page", + }, + { + path: "app/(static)/pages/ssg-blog/page.tsx", + content: "static page", + description: "static page", + }, + { + path: "app/(static)/pages/ssg-blog/[slug]/page.tsx", + content: "static post", + description: "static post", + }, + { + path: "app/(static)/pages/ssg-cms/[typeSlug]/page.tsx", + content: "static CMS page", + description: "static CMS page", + }, + { + path: "app/(static)/pages/ssg-forms/page.tsx", + content: "static forms page", + description: "static forms page", + }, + { + path: "app/(static)/pages/ssg-kanban/page.tsx", + content: "static Kanban page", + description: "static Kanban page", + }, +]; + +const legacyPaths = [ + "app/pages/[[...all]]/page.tsx", + "app/pages/layout.tsx", + "app/pages/ssg-blog/page.tsx", + "app/pages/ssg-blog/[slug]/page.tsx", + "app/pages/ssg-cms/[typeSlug]/page.tsx", + "app/pages/ssg-forms/page.tsx", + "app/pages/ssg-kanban/page.tsx", +] as const; + +// Recorded by executing buildScaffoldPlan() at each source ref with the +// fixture configuration documented in scripts/fixtures/legacy-next/README.md. +// These values are intentionally independent of the migration allowlist. +const historicalRenderedHashes = { + "v3.0.0-rc.2": { + "app/pages/[[...all]]/page.tsx": + "38abcd08846a16815c207c7367aabf7f79f4675c7965dd0309658ef5a4c3027f", + "app/pages/layout.tsx": + "4706db333fcae7432b87e6dfc4b5a83a12396cd707f358a659ce90c5c3e01caa", + "app/pages/ssg-blog/page.tsx": + "4ef38357ea2ed3a7541ad2b10c35b1a8574b8d0496c9be6656d96d40f9b48439", + "app/pages/ssg-blog/[slug]/page.tsx": + "cac8a2fbc2f94444e39bd3690bbcf0cbc34320bf711d74c9b5a3d01a991987a2", + "app/pages/ssg-cms/[typeSlug]/page.tsx": + "15eedc602de124a00594b4f7794fdd702fd9c9f9fb84a127f0cf9549ea252d6f", + "app/pages/ssg-forms/page.tsx": + "4861ae658a70ccb056f5dc7d6c3e114c95b817c444213aa6ebf3f9acb63aa13a", + "app/pages/ssg-kanban/page.tsx": + "4e0badc3dc8ed42559a498939346f7ea14c132199c9a1205320fb3195079359c", + }, + e9ff9448: { + "variants/no-plugins-tilde-layout.tsx": + "61aa2a94e1130be15baf740b91a9b6c51cdf9a35a781d91aa7fdcfde2b6202b6", + "app/pages/[[...all]]/page.tsx": + "db349b60eeb54c73f8cce795823574612a7da3fdf15396517e6216c800bfe021", + "app/pages/layout.tsx": + "798a8e0d3f9fe76d53503f1428d23ba876e577a8165e0c9f0d7217c0fe182fd9", + "app/pages/ssg-blog/page.tsx": + "0a00499fa4978b192dea04a7b19053b101bed8724389bc88707aba87323d85d1", + "app/pages/ssg-blog/[slug]/page.tsx": + "1a31a3817d8bd95857f324f7ee1dc39152cc644bc34772edcdfacb315852f630", + "app/pages/ssg-cms/[typeSlug]/page.tsx": + "b396d4a8fd2648858ba25cb1fdf651061fd5a3fc7c27b10918fa8ef5cfc6ea96", + "app/pages/ssg-forms/page.tsx": + "da127e6104f8e9c0dcf7cee82adbeafbd6c34527b82b1ff5ea113ef09735cbce", + "app/pages/ssg-kanban/page.tsx": + "49efbf31b982bad7f1b4e874eeb2454abaeeaf6cb2ab6b3ff767eba5dec8359a", + }, +} as const; + +async function writeFixture(cwd: string, path: string, content: string) { + await mkdir(dirname(join(cwd, path)), { recursive: true }); + await writeFile(join(cwd, path), content, "utf8"); +} + +describe("legacy Next.js scaffold migration", () => { + it("covers the historical plugin-selection and alias matrix", () => { + expect(LEGACY_NEXT_RENDER_HASHES["app/pages/layout.tsx"]).toHaveLength(240); + for (const path of legacyPaths.filter( + (path) => path !== "app/pages/layout.tsx", + )) { + expect(LEGACY_NEXT_RENDER_HASHES[path]).toHaveLength(12); + } + }); + + it.each( + Object.entries(historicalRenderedHashes).flatMap(([version, hashes]) => + Object.entries(hashes).map(([path, hash]) => [version, path, hash]), + ), + )( + "matches the historical %s renderer output for %s", + async (version, path, hash) => { + const content = await readFile( + join(legacyFixtureRoot, version, path), + "utf8", + ); + expect(content).toBe(`${content.trimEnd()}\n`); + expect(createHash("sha256").update(content).digest("hex")).toBe(hash); + }, + ); + + it.each(["v3.0.0-rc.2", "e9ff9448"])( + "removes the exact %s scaffold routes when overwriting", + async (version) => { + const cwd = await createFixture(); + for (const path of legacyPaths) { + await writeFixture( + cwd, + path, + await readFile(join(legacyFixtureRoot, version, path), "utf8"), + ); + } + + await expect( + migrateLegacyNextScaffold(cwd, currentPlan, "overwrite"), + ).resolves.toEqual(legacyPaths); + await expect( + readFile(join(cwd, "app/pages/layout.tsx"), "utf8"), + ).rejects.toMatchObject({ code: "ENOENT" }); + }, + ); + + it("removes recognized routes for plugins deselected on rerun", async () => { + const cwd = await createFixture(); + for (const path of legacyPaths) { + await writeFixture( + cwd, + path, + await readFile(join(legacyFixtureRoot, "e9ff9448", path), "utf8"), + ); + } + + await expect( + migrateLegacyNextScaffold(cwd, currentPlan.slice(0, 2), "overwrite"), + ).resolves.toEqual(legacyPaths); + await Promise.all( + legacyPaths.map((path) => + expect(readFile(join(cwd, path), "utf8")).rejects.toMatchObject({ + code: "ENOENT", + }), + ), + ); + }); + + it("recognizes every legacy route rendered with a supported alias", async () => { + const cwd = await createFixture(); + for (const path of legacyPaths) { + const content = await readFile( + join(legacyFixtureRoot, "e9ff9448", path), + "utf8", + ); + await writeFixture( + cwd, + path, + content.replaceAll('from "@/lib/', 'from "~/lib/'), + ); + } + + await expect( + migrateLegacyNextScaffold(cwd, currentPlan, "overwrite"), + ).resolves.toEqual(legacyPaths); + }); + + it("recognizes an untouched legacy scaffold checked out with CRLF", async () => { + const cwd = await createFixture(); + for (const path of legacyPaths) { + const content = await readFile( + join(legacyFixtureRoot, "e9ff9448", path), + "utf8", + ); + await writeFixture(cwd, path, content.replaceAll("\n", "\r\n")); + } + + await expect( + migrateLegacyNextScaffold(cwd, currentPlan, "overwrite"), + ).resolves.toEqual(legacyPaths); + }); + + it("recognizes a historical conditional layout variant", async () => { + const cwd = await createFixture(); + await writeFixture( + cwd, + "app/pages/layout.tsx", + await readFile( + join( + legacyFixtureRoot, + "e9ff9448/variants/no-plugins-tilde-layout.tsx", + ), + "utf8", + ), + ); + + await expect( + migrateLegacyNextScaffold(cwd, currentPlan, "overwrite"), + ).resolves.toEqual(["app/pages/layout.tsx"]); + }); + + it("fails before deleting when a legacy route retains markers but was customized", async () => { + const cwd = await createFixture(); + const layout = await readFile( + join(legacyFixtureRoot, "e9ff9448/app/pages/layout.tsx"), + "utf8", + ); + await writeFixture( + cwd, + "app/pages/layout.tsx", + `${layout}\n// Keep my custom StackProvider behavior.\n`, + ); + await writeFixture( + cwd, + "app/pages/[[...all]]/page.tsx", + await readFile( + join(legacyFixtureRoot, "e9ff9448/app/pages/[[...all]]/page.tsx"), + "utf8", + ), + ); + + await expect( + migrateLegacyNextScaffold(cwd, currentPlan, "overwrite"), + ).rejects.toThrow("Refusing to remove customized legacy"); + await expect( + readFile(join(cwd, "app/pages/layout.tsx"), "utf8"), + ).resolves.toContain("Keep my custom StackProvider behavior"); + }); + + it("keeps the legacy layout when consumer-authored child routes remain", async () => { + const cwd = await createFixture(); + const layout = await readFile( + join(legacyFixtureRoot, "e9ff9448/app/pages/layout.tsx"), + "utf8", + ); + await writeFixture(cwd, "app/pages/layout.tsx", layout); + await writeFixture( + cwd, + "app/pages/custom/page.tsx", + "export default function CustomPage() { return null }\n", + ); + + await expect( + migrateLegacyNextScaffold(cwd, currentPlan, "overwrite"), + ).rejects.toThrow("consumer-owned routes remain"); + await expect( + readFile(join(cwd, "app/pages/layout.tsx"), "utf8"), + ).resolves.toBe(layout); + await expect( + readFile(join(cwd, "app/pages/custom/page.tsx"), "utf8"), + ).resolves.toContain("CustomPage"); + }); + + it("fails closed when overwrite was not selected", async () => { + const cwd = await createFixture(); + await writeFixture( + cwd, + "app/pages/layout.tsx", + await readFile( + join(legacyFixtureRoot, "e9ff9448/app/pages/layout.tsx"), + "utf8", + ), + ); + + await expect( + migrateLegacyNextScaffold(cwd, currentPlan, "skip"), + ).rejects.toThrow("conflict with the current request/static route groups"); + }); +}); diff --git a/packages/cli/src/utils/__tests__/scaffold-plan.test.ts b/packages/cli/src/utils/__tests__/scaffold-plan.test.ts index 33aac5527..c8dd592b0 100644 --- a/packages/cli/src/utils/__tests__/scaffold-plan.test.ts +++ b/packages/cli/src/utils/__tests__/scaffold-plan.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { readFile } from "node:fs/promises"; import { buildScaffoldPlan } from "../scaffold-plan"; import { PLUGINS } from "../constants"; @@ -132,8 +133,9 @@ describe("scaffold plan", () => { "lib/stack-client.tsx", "lib/query-client.ts", "app/api/data/[[...all]]/route.ts", - "app/pages/[[...all]]/page.tsx", - "app/pages/layout.tsx", + "app/(request)/pages/[[...all]]/page.tsx", + "app/(request)/pages/layout.tsx", + "app/(static)/pages/layout.tsx", ]), ); // Navbar + mode-toggle generated for all frameworks @@ -141,13 +143,19 @@ describe("scaffold plan", () => { expect(paths).toContain("components/mode-toggle.tsx"); // Blog triggers sitemap + SSG pages expect(paths).toContain("app/sitemap.ts"); - expect(paths).toContain("app/pages/ssg-blog/page.tsx"); - expect(paths).toContain("app/pages/ssg-blog/[slug]/page.tsx"); + expect(paths).toContain("app/(static)/pages/ssg-blog/page.tsx"); + expect(paths).toContain("app/(static)/pages/ssg-blog/[slug]/page.tsx"); const stackFile = plan.files.find((f) => f.path === "lib/stack.ts"); expect(stackFile?.content).toContain("blogBackendPlugin()"); expect(stackFile?.content).toContain( - "type AppStack = ReturnType", + "type AppStack = ReturnType", + ); + expect(stackFile?.content).toContain( + 'import { createBackendStack } from "@btst/stack/api"', + ); + expect(stackFile?.content).not.toContain( + 'import { stack } from "@btst/stack"', ); expect(stackFile?.content).not.toContain( "__btst_stack__?: ReturnType", @@ -156,40 +164,65 @@ describe("scaffold plan", () => { (f) => f.path === "lib/stack-client.tsx", ); expect(stackClientFile?.content).toContain("blogClientPlugin"); + expect(stackClientFile?.content).toContain("createClientStack({"); + expect(stackClientFile?.content).not.toContain("createStackClient"); + expect(stackClientFile?.content).toContain("options?: StackClientOptions"); + expect(stackClientFile?.content).not.toContain("getStackClientForRequest"); expect(stackClientFile?.content).toContain( - "const baseURL = getBaseURL(options?.origin)", + 'site: { baseURL: siteOrigin, basePath: "/pages" }', ); - expect(stackClientFile?.content).toContain( - "...(options?.headers ? { headers: options.headers } : {})", + expect(stackClientFile?.content).toContain("blog: blogClientPlugin(),"); + expect(stackClientFile?.content).not.toContain("apiBaseURL:"); + const stackClientServerFile = plan.files.find( + (f) => f.path === "lib/stack-client.server.ts", + ); + expect(stackClientServerFile?.content).toContain( + "resolveTrustedClientOrigins", + ); + expect(stackClientServerFile?.content).toContain( + "export function getStackClientForRequest(", + ); + expect(stackClientServerFile?.content).toContain( + "BTST_REQUEST_HEADERS_SERVER_MARKER", + ); + expect(stackClientServerFile?.content).not.toContain("VERCEL_URL"); + expect(stackClientFile?.content).not.toContain("VERCEL_URL"); + expect(stackClientServerFile?.content).toContain( + "process.env.NEXT_PUBLIC_BASE_URL", ); expect(stackClientFile?.content).toContain( - 'site: { baseURL, basePath: "/pages" }', + "process.env.NEXT_PUBLIC_BASE_URL", ); - expect(stackClientFile?.content).toContain("blog: blogClientPlugin(),"); - expect(stackClientFile?.content).not.toContain("apiBaseURL:"); const pagesLayoutFile = plan.files.find( - (f) => f.path === "app/pages/layout.tsx", + (f) => f.path === "app/(request)/pages/layout.tsx", + ); + const pagesClientLayoutFile = plan.files.find( + (f) => f.path === "app/pages/client-layout.tsx", ); + expect(pagesLayoutFile?.content).toContain('from "next/headers"'); expect(pagesLayoutFile?.content).toContain( + "getServerClientOriginsFromHeaders(await headers())", + ); + expect(pagesLayoutFile?.content).not.toContain("force-dynamic"); + expect(pagesClientLayoutFile?.content).toContain( 'import { StackProvider } from "@btst/stack/context"', ); - expect(pagesLayoutFile?.content).toContain( + expect(pagesClientLayoutFile?.content).toContain( 'import { nextRouter } from "@btst/stack/next"', ); - expect(pagesLayoutFile?.content).toContain("router={nextRouter()}"); - expect(pagesLayoutFile?.content).toContain("stack={stack}"); - expect(pagesLayoutFile?.content).not.toContain("navigate: (path"); - expect(pagesLayoutFile?.content).not.toContain("Link: ("); - expect(pagesLayoutFile?.content).not.toContain("apiBaseURL:"); - expect(pagesLayoutFile?.content).not.toContain("apiBasePath:"); - expect(pagesLayoutFile?.content).not.toContain("as never"); - expect(plan.pagesLayoutPath).toBe("app/pages/layout.tsx"); + expect(pagesClientLayoutFile?.content).toContain("router={nextRouter()}"); + expect(pagesClientLayoutFile?.content).toContain("stack={browserStack}"); + expect(pagesClientLayoutFile?.content).not.toContain("navigate: (path"); + expect(pagesClientLayoutFile?.content).not.toContain("Link: ("); + expect(pagesClientLayoutFile?.content).not.toContain("apiBaseURL:"); + expect(pagesClientLayoutFile?.content).not.toContain("apiBasePath:"); + expect(pagesClientLayoutFile?.content).not.toContain("as never"); + expect(plan.pagesLayoutPath).toBe("app/(request)/pages/layout.tsx"); const pagesRouteFile = plan.files.find( - (f) => f.path === "app/pages/[[...all]]/page.tsx", - ); - expect(pagesRouteFile?.content).toContain( - "getStackClient: (queryClient) => getStackClient(queryClient)", + (f) => f.path === "app/(request)/pages/[[...all]]/page.tsx", ); + expect(pagesRouteFile?.content).toContain("getStackClientForRequest"); + expect(pagesRouteFile?.content).toContain("new Headers(await headers())"); }); it("resolves src-prefixed Next.js pages layout path", async () => { @@ -201,7 +234,36 @@ describe("scaffold plan", () => { cssFile: "src/app/globals.css", }); - expect(plan.pagesLayoutPath).toBe("src/app/pages/layout.tsx"); + expect(plan.pagesLayoutPath).toBe("src/app/(request)/pages/layout.tsx"); + }); + + it("hydrates a non-3000 Next.js request origin without binding SSG to headers", async () => { + const plan = await buildScaffoldPlan({ + framework: "nextjs", + adapter: "memory", + plugins: ["blog"], + alias: "@/", + cssFile: "app/globals.css", + }); + const requestLayout = plan.files.find( + (file) => file.path === "app/(request)/pages/layout.tsx", + ); + const requestPage = plan.files.find( + (file) => file.path === "app/(request)/pages/[[...all]]/page.tsx", + ); + const staticLayout = plan.files.find( + (file) => file.path === "app/(static)/pages/layout.tsx", + ); + + expect(requestLayout?.content).toContain( + "getServerClientOriginsFromHeaders(await headers())", + ); + expect(requestPage?.content).toContain("new Headers(await headers())"); + expect(staticLayout?.content).toContain("getServerClientOrigins()"); + expect(staticLayout?.content).not.toContain('from "next/headers"'); + expect(plan.files.map((file) => file.path)).toContain( + "app/(static)/pages/ssg-blog/page.tsx", + ); }); it.each(["nextjs", "react-router", "tanstack"] as const)( @@ -225,18 +287,26 @@ describe("scaffold plan", () => { expect(stackClientFile?.content).not.toContain( 'const baseURL = "http://localhost:3000"', ); + expect(stackClientFile?.content).not.toContain( + "getCrossOriginApiEndpoint", + ); const layoutSuffix = framework === "nextjs" - ? "app/pages/layout.tsx" + ? "app/(request)/pages/layout.tsx" : framework === "react-router" ? "routes/pages/_layout.tsx" : "routes/pages/route.tsx"; const pagesLayoutFile = plan.files.find((file) => - file.path.endsWith(layoutSuffix), + framework === "nextjs" + ? file.path.endsWith("app/pages/client-layout.tsx") + : file.path.endsWith(layoutSuffix), ); expect(pagesLayoutFile?.content).toBeDefined(); expect(pagesLayoutFile?.content).toContain("StackProvider"); - expect(pagesLayoutFile?.content).toContain("stack={stack}"); + expect(pagesLayoutFile?.content).toContain("stack={browserStack}"); + expect(pagesLayoutFile?.content).not.toContain( + "getStackClientForRequest", + ); const routerFactory = framework === "nextjs" ? "nextRouter()" @@ -264,19 +334,29 @@ describe("scaffold plan", () => { ); expect(stackClientFile?.content).toBeDefined(); expect(stackClientFile?.content).toContain( - "const baseURL = getBaseURL(options?.origin)", + "const siteOrigin = getSiteOrigin(options?.siteOrigin)", ); expect(stackClientFile?.content).toContain( 'if (typeof window !== "undefined")', ); expect(stackClientFile?.content).toContain( - 'site: { baseURL, basePath: "/pages" }', + 'site: { baseURL: siteOrigin, basePath: "/pages" }', ); expect(stackClientFile?.content).toContain("queryClient,"); expect(stackClientFile?.content).toContain("blog: blogClientPlugin(),"); expect(stackClientFile?.content).toContain( "comments: commentsClientPlugin(),", ); + expect(stackClientFile?.content).toContain( + "const crossOriginApiEndpoint = getCrossOriginApiEndpoint(", + ); + expect(stackClientFile?.content).toContain( + "blog: crossOriginApiEndpoint,", + ); + expect(stackClientFile?.content).toContain( + "comments: crossOriginApiEndpoint,", + ); + expect(stackClientFile?.content).toContain('credentials: "include"'); expect(stackClientFile?.content).not.toContain("apiBaseURL:"); const pagesLayoutFile = plan.files.find((file) => @@ -286,6 +366,162 @@ describe("scaffold plan", () => { }, ); + it.each(["nextjs", "react-router", "tanstack"] as const)( + "keeps request headers in request-scoped route stacks for %s", + async (framework) => { + const plan = await buildScaffoldPlan({ + framework, + adapter: "memory", + plugins: ["blog"], + alias: "@/", + cssFile: + framework === "nextjs" ? "app/globals.css" : "src/styles/app.css", + }); + const pageRoute = plan.files.find( + (file) => + file.path.endsWith("app/(request)/pages/[[...all]]/page.tsx") || + file.path.endsWith("routes/pages/$.tsx"), + ); + const layout = plan.files.find((file) => + file.content.includes(" + file.path.endsWith("stack-client.server.ts"), + ); + + expect(pageRoute?.content).toContain("getStackClientForRequest"); + expect(pageRoute?.content).toContain("headers:"); + expect(pageRoute?.content).toContain("stack-client.server"); + expect(requestStack?.content).toContain("resolveTrustedClientOrigins"); + expect(requestStack?.content).toContain( + "configuredApiOrigin: configuredApiOrigin()", + ); + expect(requestStack?.content).toContain( + 'isProduction: process.env.NODE_ENV === "production"', + ); + expect(requestStack?.content).not.toContain( + "baseURL: new URL(request.url).origin", + ); + if (framework === "nextjs") { + expect(layout?.content).toContain( + "getStackClient(queryClient, clientOrigins)", + ); + } else { + expect(layout?.content).toContain("apiOrigin"); + expect(layout?.content).toContain("siteOrigin"); + expect(layout?.content).toContain( + "getStackClient(queryClient, { apiOrigin, siteOrigin })", + ); + } + expect(layout?.content).not.toContain("getStackClientForRequest"); + expect(layout?.content).not.toContain("request.headers"); + + if (framework === "nextjs") { + expect(pageRoute?.content).toContain('from "next/headers"'); + expect(pageRoute?.content).toContain("new Headers(await headers())"); + } else if (framework === "react-router") { + expect(pageRoute?.content).toContain("page.createLoader"); + expect(pageRoute?.content).toContain("request.headers"); + expect(pageRoute?.content).toContain("new URL(request.url).origin"); + } else { + expect(pageRoute?.content).toContain("createIsomorphicFn"); + expect(pageRoute?.content).toContain("getRequest()"); + expect(pageRoute?.content).toContain( + "getStackClient(queryClient, await getTrustedClientOrigins())", + ); + } + }, + ); + + it("preserves backend-only and client-only plugin registrations", async () => { + const plan = await buildScaffoldPlan({ + framework: "nextjs", + adapter: "memory", + plugins: ["open-api", "route-docs"], + alias: "@/", + cssFile: "app/globals.css", + }); + const backend = plan.files.find((file) => file.path === "lib/stack.ts"); + const client = plan.files.find( + (file) => file.path === "lib/stack-client.tsx", + ); + + expect(backend?.content).toContain("openApi: openApiBackendPlugin()"); + expect(backend?.content).not.toContain("routeDocs:"); + expect(client?.content).toContain("routeDocs: routeDocsClientPlugin()"); + expect(client?.content).not.toContain("openApi:"); + }); + + it("inherits managed API credentials only through the owning CMS runtime", async () => { + const plan = await buildScaffoldPlan({ + framework: "nextjs", + adapter: "memory", + plugins: ["cms", "ui-builder", "route-docs"], + alias: "@/", + cssFile: "app/globals.css", + }); + const stackClientFile = plan.files.find( + (file) => file.path === "lib/stack-client.tsx", + ); + + expect(stackClientFile?.content).toContain("cms: crossOriginApiEndpoint,"); + expect(stackClientFile?.content).not.toContain( + "uiBuilder: crossOriginApiEndpoint,", + ); + expect(stackClientFile?.content).not.toContain( + "routeDocs: crossOriginApiEndpoint,", + ); + }); + + it("keeps generated static work on explicit trusted and raw surfaces", async () => { + const plan = await buildScaffoldPlan({ + framework: "nextjs", + adapter: "prisma", + plugins: ["blog", "cms", "form-builder", "kanban"], + alias: "@/", + cssFile: "app/globals.css", + }); + const staticPages = plan.files.filter((file) => + file.path.includes("/ssg-"), + ); + const staticSource = staticPages.map((file) => file.content).join("\n"); + + expect(staticPages.length).toBeGreaterThan(0); + expect(staticSource).toContain("myStack.trusted.blog.listPosts"); + expect(staticSource).toContain("myStack.trusted.cms.listContentTypes"); + expect(staticSource).toContain("myStack.raw.blog.prefetchForRoute"); + expect(staticSource).toContain("myStack.raw.cms.prefetchForRoute"); + expect(staticSource).toContain("myStack.raw.formBuilder.prefetchForRoute"); + expect(staticSource).toContain("myStack.raw.kanban.prefetchForRoute"); + expect(staticSource).not.toContain("myStack.api."); + }); + + it("ships a compile fixture that uses only public extension definitions", async () => { + const source = await readFile( + new URL( + "../../../scripts/fixtures/third-party-plugin.tsx", + import.meta.url, + ), + "utf8", + ); + + expect(source).toContain('from "@btst/stack/plugins/api"'); + expect(source).toContain('from "@btst/stack/plugins/client"'); + expect(source).toContain("function thirdPartyProbeBackendPlugin()"); + expect(source).toContain("function thirdPartyProbeClientPlugin()"); + expect(source).toContain("defineBackendPlugin({"); + expect(source).toContain("defineClientPlugin()"); + expect(source).toContain("thirdPartyProbe: thirdPartyProbeBackendPlugin()"); + expect(source).toContain("thirdPartyProbe: thirdPartyProbeClientPlugin()"); + expect(source).toContain("createBackendStack({"); + expect(source).toContain("createClientStack({"); + expect(source).toContain( + 'overrides={{ thirdPartyProbe: { label: "Third-party probe" } }}', + ); + expect(source).not.toContain("/src/"); + expect(source).not.toContain("StackProvider<"); + }); + it("does not register ui-builder as a backend plugin entry", async () => { const plan = await buildScaffoldPlan({ framework: "nextjs", @@ -364,7 +600,7 @@ describe("scaffold plan", () => { file.path.endsWith("stack-client.tsx"), ); expect(stackClientFile?.content).toContain( - 'aiChat: aiChatClientPlugin({ mode: "public" as const }),', + 'aiChat: aiChatClientPlugin({ mode: "public" }),', ); expect(stackClientFile?.content).toContain('basePath: "/api/data"'); expect(stackClientFile?.content).toContain('basePath: "/pages"'); @@ -398,14 +634,14 @@ describe("scaffold plan", () => { const stackFile = plan.files.find((file) => file.path.endsWith("stack.ts")); expect(stackFile?.content).toContain( - 'aiChat: aiChatBackendPlugin({ model: openai("gpt-4o-mini"), access: "public" as const }),', + 'aiChat: aiChatBackendPlugin({ model: openai("gpt-4o-mini"), access: "public" }),', ); expect(stackFile?.content).toContain( 'import { openai } from "@ai-sdk/openai"', ); const pagesLayoutFile = plan.files.find((file) => - file.path.endsWith("app/pages/layout.tsx"), + file.path.endsWith("app/pages/client-layout.tsx"), ); // PageAIContextProvider belongs in the root layout, not the pages layout expect(pagesLayoutFile?.content).not.toContain("PageAIContextProvider"); @@ -462,7 +698,7 @@ describe("scaffold plan", () => { ); }); - it("renders media backend plugin with compile-safe placeholder config", async () => { + it("renders Media with the local storage adapter instead of an unsafe placeholder", async () => { const plan = await buildScaffoldPlan({ framework: "nextjs", adapter: "memory", @@ -473,8 +709,12 @@ describe("scaffold plan", () => { const stackFile = plan.files.find((file) => file.path.endsWith("stack.ts")); expect(stackFile?.content).toContain( - "media: mediaBackendPlugin({ storageAdapter: undefined as any }),", + "media: mediaBackendPlugin({ storageAdapter: localAdapter() }),", ); + expect(stackFile?.content).toContain( + 'import { localAdapter } from "@btst/stack/plugins/media/api/adapters/local"', + ); + expect(stackFile?.content).not.toContain("undefined as any"); }); it.each(["nextjs", "react-router", "tanstack"] as const)( @@ -484,6 +724,14 @@ describe("scaffold plan", () => { framework === "nextjs" ? "process.env.NEXT_PUBLIC_SITE_URL" : "import.meta.env.VITE_PUBLIC_SITE_URL"; + const migrationBrowserBaseURLExpression = + framework === "nextjs" + ? "process.env.NEXT_PUBLIC_BASE_URL" + : "import.meta.env.VITE_BASE_URL"; + const migrationServerBaseURLExpression = + framework === "nextjs" + ? "process.env.NEXT_PUBLIC_BASE_URL" + : "import.meta.env.VITE_BASE_URL"; const plan = await buildScaffoldPlan({ framework, adapter: "memory", @@ -495,6 +743,9 @@ describe("scaffold plan", () => { const stackClientFile = plan.files.find((file) => file.path.endsWith("stack-client.tsx"), ); + const stackClientServerFile = plan.files.find((file) => + file.path.endsWith("stack-client.server.ts"), + ); const pagesLayoutFile = plan.files.find((file) => file.content.includes(" { ); expect(stackClientFile?.content).not.toContain("apiBaseURL:"); expect(stackClientFile?.content).not.toContain("siteBasePath:"); + expect(stackClientFile?.content).toContain(browserSiteURLExpression); expect(stackClientFile?.content).toContain( - `return ${browserSiteURLExpression} || window.location.origin`, + migrationBrowserBaseURLExpression, + ); + expect(stackClientServerFile?.content).toContain( + migrationServerBaseURLExpression, ); + if (framework !== "nextjs") { + expect(stackClientFile?.content).not.toContain("VITE_PUBLIC_BASE_URL"); + expect(stackClientServerFile?.content).not.toContain( + "VITE_PUBLIC_BASE_URL", + ); + } expect(stackClientFile?.content).toContain( - "const baseURL = getBaseURL(options?.origin)", + "const siteOrigin = getSiteOrigin(options?.siteOrigin)", ); expect(stackClientFile?.content).toContain( "if (serverOrigin) return serverOrigin", @@ -519,7 +780,7 @@ describe("scaffold plan", () => { ); expect(pagesLayoutFile?.content).not.toContain("as never"); expect(pagesLayoutFile?.content).not.toContain('"media": {'); - expect(pagesLayoutFile?.content).not.toContain("queryClient,"); + expect(pagesLayoutFile?.content).not.toContain("queryClient:"); }, ); @@ -632,7 +893,7 @@ describe("scaffold plan", () => { 'import { reactRouter } from "@btst/stack/react-router"', ); expect(layoutFile?.content).toContain("router={reactRouter()}"); - expect(layoutFile?.content).toContain("stack={stack}"); + expect(layoutFile?.content).toContain("stack={browserStack}"); expect(layoutFile?.content).not.toContain("navigate: (path"); expect(layoutFile?.content).not.toContain("RouterLink"); expect(layoutFile?.content).not.toContain("router.push"); @@ -675,7 +936,7 @@ describe("scaffold plan", () => { 'import { tanstackRouter } from "@btst/stack/tanstack"', ); expect(layoutFile?.content).toContain("router={tanstackRouter()}"); - expect(layoutFile?.content).toContain("stack={stack}"); + expect(layoutFile?.content).toContain("stack={browserStack}"); expect(layoutFile?.content).not.toContain("navigate: (path"); expect(layoutFile?.content).not.toContain("RouterLink"); expect(layoutFile?.content).toContain('createFileRoute("/pages")'); @@ -734,7 +995,7 @@ describe("scaffold plan", () => { const pageRoute = plan.files.find( (file) => file.path.includes("routes/pages/$.tsx") || - file.path.includes("app/pages/[[...all]]/page.tsx"), + file.path.includes("app/(request)/pages/[[...all]]/page.tsx"), ); const apiRoute = plan.files.find( (file) => @@ -751,9 +1012,9 @@ describe("scaffold plan", () => { expect(providerFiles.length).toBeGreaterThan(0); for (const file of providerFiles) { expect(file.content, file.path).toContain(`router={${routerFactory}}`); - expect(file.content, file.path).toMatch( - /stack=\{stack\}|api=\{\{ baseURL, basePath: "\/api\/data" \}\}/, - ); + expect(file.content, file.path).toContain("stack={browserStack}"); + expect(file.content, file.path).not.toContain("StackProvider<"); + expect(file.content, file.path).not.toContain("as never"); expect(file.content, file.path).not.toContain("apiBaseURL: baseURL"); expect(file.content, file.path).not.toContain( 'apiBasePath: "/api/data"', @@ -809,7 +1070,8 @@ describe("scaffold plan", () => { const paths = plan.files.map((f) => f.path); expect(paths).toContain("app/sitemap.ts"); const sitemap = plan.files.find((f) => f.path === "app/sitemap.ts"); - expect(sitemap?.content).toContain("lib.generateSitemap()"); + expect(sitemap?.content).toContain("stack.generateSitemap()"); + expect(sitemap?.content).toContain("getStackClientForRequest"); expect(sitemap?.content).toContain("MetadataRoute.Sitemap"); }); @@ -943,7 +1205,7 @@ describe("scaffold plan", () => { cssFile: "app/globals.css", }); const pagesRoute = plan.files.find( - (f) => f.path === "app/pages/[[...all]]/page.tsx", + (f) => f.path === "app/(request)/pages/[[...all]]/page.tsx", ); expect(pagesRoute?.content).toContain("generateMetadata"); expect(pagesRoute?.content).toContain("createNextPage"); @@ -958,11 +1220,11 @@ describe("scaffold plan", () => { cssFile: "app/globals.css", }); const paths = plan.files.map((f) => f.path); - expect(paths).toContain("app/pages/ssg-blog/page.tsx"); - expect(paths).toContain("app/pages/ssg-blog/[slug]/page.tsx"); + expect(paths).toContain("app/(static)/pages/ssg-blog/page.tsx"); + expect(paths).toContain("app/(static)/pages/ssg-blog/[slug]/page.tsx"); const blogList = plan.files.find( - (f) => f.path === "app/pages/ssg-blog/page.tsx", + (f) => f.path === "app/(static)/pages/ssg-blog/page.tsx", ); expect(blogList?.content).toContain("generateStaticParams"); expect(blogList?.content).toContain("prefetchForRoute"); @@ -978,8 +1240,8 @@ describe("scaffold plan", () => { cssFile: "app/globals.css", }); const paths = plan.files.map((f) => f.path); - expect(paths).toContain("app/pages/ssg-cms/[typeSlug]/page.tsx"); - expect(paths).not.toContain("app/pages/ssg-blog/page.tsx"); + expect(paths).toContain("app/(static)/pages/ssg-cms/[typeSlug]/page.tsx"); + expect(paths).not.toContain("app/(static)/pages/ssg-blog/page.tsx"); }); it("emits SSG forms page for nextjs when form-builder selected", async () => { @@ -991,7 +1253,7 @@ describe("scaffold plan", () => { cssFile: "app/globals.css", }); expect(plan.files.map((f) => f.path)).toContain( - "app/pages/ssg-forms/page.tsx", + "app/(static)/pages/ssg-forms/page.tsx", ); }); @@ -1004,7 +1266,7 @@ describe("scaffold plan", () => { cssFile: "app/globals.css", }); expect(plan.files.map((f) => f.path)).toContain( - "app/pages/ssg-kanban/page.tsx", + "app/(static)/pages/ssg-kanban/page.tsx", ); }); @@ -1032,16 +1294,22 @@ describe("scaffold plan", () => { }); const paths = plan.files.map((f) => f.path); expect(paths).toContain("app/public-chat/page.tsx"); + expect(paths).toContain("app/public-chat/client.tsx"); const page = plan.files.find((f) => f.path === "app/public-chat/page.tsx"); - expect(page?.content).toContain("ChatLayout"); - expect(page?.content).toContain( - 'aiChat: aiChatClientPlugin({ mode: "public" }),', + const client = plan.files.find( + (f) => f.path === "app/public-chat/client.tsx", + ); + expect(page?.content).toContain("getServerClientOriginsFromHeaders"); + expect(page?.content).toContain("clientOrigins={clientOrigins}"); + expect(client?.content).toContain("ChatLayout"); + expect(client?.content).toContain( + "getStackClient(queryClient, clientOrigins)", ); - expect(page?.content).toContain('site: { baseURL, basePath: "/" },'); - expect(page?.content).toContain(" { @@ -1058,10 +1326,12 @@ describe("scaffold plan", () => { (f) => f.path === "app/routes/public-chat.tsx", ); expect(route?.content).toContain("ChatLayout"); + expect(route?.content).toContain("getServerClientOrigins"); expect(route?.content).toContain( - 'aiChat: aiChatClientPlugin({ mode: "public" }),', + "getStackClient(queryClient, { apiOrigin, siteOrigin })", ); - expect(route?.content).toContain('site: { baseURL, basePath: "/" },'); + expect(route?.content).toContain("stack={browserStack}"); + expect(route?.content).not.toContain("createClientStack"); expect(route?.content).not.toContain(' { ); expect(route?.content).toContain("createFileRoute"); expect(route?.content).toContain("ChatLayout"); + expect(route?.content).toContain("getTrustedClientOrigins"); expect(route?.content).toContain( - 'aiChat: aiChatClientPlugin({ mode: "public" }),', + "getStackClient(queryClient, { apiOrigin, siteOrigin })", ); - expect(route?.content).toContain('site: { baseURL, basePath: "/" },'); + expect(route?.content).toContain("stack={browserStack}"); + expect(route?.content).not.toContain("createClientStack"); expect(route?.content).not.toContain(' { }); const paths = plan.files.map((f) => f.path); expect(paths).toContain("app/form-demo/[slug]/page.tsx"); + expect(paths).toContain("app/form-demo/[slug]/client.tsx"); const page = plan.files.find( (f) => f.path === "app/form-demo/[slug]/page.tsx", ); - expect(page?.content).toContain("FormRenderer"); + const client = plan.files.find( + (f) => f.path === "app/form-demo/[slug]/client.tsx", + ); + expect(page?.content).toContain("getServerClientOriginsFromHeaders"); + expect(page?.content).toContain("clientOrigins={clientOrigins}"); + expect(client?.content).toContain("FormRenderer"); + expect(client?.content).toContain( + "getStackClient(queryClient, clientOrigins)", + ); }); it("emits form-demo route for react-router when form-builder selected", async () => { @@ -1124,7 +1405,11 @@ describe("scaffold plan", () => { alias: "~/", cssFile: "app/app.css", }); - expect(plan.files.map((f) => f.path)).toContain("app/routes/form-demo.tsx"); + const route = plan.files.find((f) => f.path === "app/routes/form-demo.tsx"); + expect(route?.content).toContain("getServerClientOrigins"); + expect(route?.content).toContain( + "getStackClient(queryClient, { apiOrigin, siteOrigin })", + ); }); it("emits form-demo route for tanstack when form-builder selected", async () => { @@ -1135,8 +1420,12 @@ describe("scaffold plan", () => { alias: "@/", cssFile: "src/styles/globals.css", }); - expect(plan.files.map((f) => f.path)).toContain( - "src/routes/form-demo.$slug.tsx", + const route = plan.files.find( + (f) => f.path === "src/routes/form-demo.$slug.tsx", + ); + expect(route?.content).toContain("getTrustedClientOrigins"); + expect(route?.content).toContain( + "getStackClient(queryClient, { apiOrigin, siteOrigin })", ); }); @@ -1154,13 +1443,18 @@ describe("scaffold plan", () => { const client = plan.files.find( (f) => f.path === "app/preview/[slug]/client.tsx", ); + const page = plan.files.find( + (f) => f.path === "app/preview/[slug]/page.tsx", + ); + expect(page?.content).toContain("getServerClientOriginsFromHeaders"); + expect(page?.content).toContain("clientOrigins={clientOrigins}"); expect(client?.content).toContain("PageRenderer"); expect(client?.content).not.toContain("defaultComponentRegistry"); expect(client?.content).not.toContain("componentRegistry="); expect(client?.content).toContain( - "const stack = getStackClient(queryClient)", + "getStackClient(queryClient, clientOrigins)", ); - expect(client?.content).toContain("stack={stack}"); + expect(client?.content).toContain("stack={browserStack}"); expect(client?.content).not.toContain("StackProvider<"); expect(client?.content).not.toContain('"ui-builder":'); }); @@ -1175,7 +1469,11 @@ describe("scaffold plan", () => { }); expect(plan.files.map((f) => f.path)).toContain("app/routes/preview.tsx"); const preview = plan.files.find((f) => f.path === "app/routes/preview.tsx"); - expect(preview?.content).toContain("stack={stack}"); + expect(preview?.content).toContain("getServerClientOrigins"); + expect(preview?.content).toContain( + "getStackClient(queryClient, { apiOrigin, siteOrigin })", + ); + expect(preview?.content).toContain("stack={browserStack}"); expect(preview?.content).not.toContain("defaultComponentRegistry"); expect(preview?.content).not.toContain("componentRegistry="); expect(preview?.content).not.toContain("StackProvider<"); @@ -1196,7 +1494,11 @@ describe("scaffold plan", () => { const preview = plan.files.find( (f) => f.path === "src/routes/preview.$slug.tsx", ); - expect(preview?.content).toContain("stack={stack}"); + expect(preview?.content).toContain("getTrustedClientOrigins"); + expect(preview?.content).toContain( + "getStackClient(queryClient, { apiOrigin, siteOrigin })", + ); + expect(preview?.content).toContain("stack={browserStack}"); expect(preview?.content).not.toContain("defaultComponentRegistry"); expect(preview?.content).not.toContain("componentRegistry="); expect(preview?.content).not.toContain("StackProvider<"); @@ -1219,7 +1521,7 @@ describe("scaffold plan", () => { }); const preview = plan.files.find((file) => file.path === previewPath); - expect(preview?.content).toContain('"kanban": {'); + expect(preview?.content).toContain("kanban: {"); expect(preview?.content).toContain("resolveUser: async () => null"); expect(preview?.content).toContain("searchUsers: async () => []"); expect(preview?.content).not.toContain("StackProvider<"); diff --git a/packages/cli/src/utils/__tests__/seed-plan.test.ts b/packages/cli/src/utils/__tests__/seed-plan.test.ts index 3f83efd7c..26ef9e3a2 100644 --- a/packages/cli/src/utils/__tests__/seed-plan.test.ts +++ b/packages/cli/src/utils/__tests__/seed-plan.test.ts @@ -67,19 +67,51 @@ describe("seed-plan", () => { }); it.each(["nextjs", "react-router", "tanstack"] as const)( - "keeps %s CMS and UI Builder seeds on the trusted operation surface", + "keeps every %s seed on its intended trusted operation surface", (framework) => { - for (const plugin of ["cms", "ui-builder"] as const) { + const trustSurfaces = [ + ["blog", "blog"], + ["kanban", "kanban"], + ["form-builder", "formBuilder"], + ["cms", "cms"], + ["ui-builder", "cms"], + ] as const; + + for (const [plugin, trustSurface] of trustSurfaces) { const file = buildSeedRouteFile(plugin, framework); - expect(file?.content).toContain("myStack.trusted.cms"); - expect(file?.content).toContain("cms.listContentItems({"); - expect(file?.content).toContain("cms.createContentItem({"); + expect(file?.content).toContain(`myStack.trusted.${trustSurface}`); + expect(file?.content).not.toContain("myStack.adapter"); expect(file?.content).not.toContain("myStack.api"); - expect(file?.content).not.toContain("api.cms"); } }, ); + it("uses canonical operation names and inputs in generated seed bodies", () => { + const blog = buildSeedRouteFile("blog", "nextjs")?.content; + expect(blog).toContain("blog.listPosts({ limit: 1 })"); + expect(blog).toContain("blog.createPost({"); + + const kanban = buildSeedRouteFile("kanban", "nextjs")?.content; + expect(kanban).toContain( + 'kanban.listBoards({ slug: "demo-board", limit: 1 })', + ); + expect(kanban).toContain("kanban.getBoard({ id:"); + expect(kanban).toContain("kanban.createBoard({"); + expect(kanban).toContain("kanban.createColumn({"); + expect(kanban).toContain("kanban.createTask({"); + expect(kanban).not.toContain("@btst/stack/plugins/kanban/api"); + + const formBuilder = buildSeedRouteFile("form-builder", "nextjs")?.content; + expect(formBuilder).toContain("formBuilder.listForms({ limit: 1 })"); + expect(formBuilder).toContain("formBuilder.createForm({"); + + for (const plugin of ["cms", "ui-builder"] as const) { + const file = buildSeedRouteFile(plugin, "nextjs")?.content; + expect(file).toContain("cms.listContentItems({"); + expect(file).toContain("cms.createContentItem({"); + } + }); + // ── buildSeedRouteFiles ────────────────────────────────────────────────── it("filters out plugins with no seed body", () => { diff --git a/packages/cli/src/utils/legacy-next-render-hashes.ts b/packages/cli/src/utils/legacy-next-render-hashes.ts new file mode 100644 index 000000000..54f131acf --- /dev/null +++ b/packages/cli/src/utils/legacy-next-render-hashes.ts @@ -0,0 +1,332 @@ +// Generated by scripts/generate-legacy-next-render-hashes.mjs from exact +// buildScaffoldPlan() bytes at v3.0.0-rc.2 and e9ff9448 across every +// supported plugin selection, import alias, and LF/CRLF checkout. Consumer +// edits therefore remain outside the allowlist and fail closed. +export const LEGACY_NEXT_RENDER_HASHES = { + "app/pages/[[...all]]/page.tsx": [ + "04b440d4de9d54db332e22a2c04406a4555bf6ec84c7297bc7dde0ddcf51133c", + "34a2b88629305943cc0221b832e3d0731036fe07d0b0e038bdd42789da15b9ec", + "38abcd08846a16815c207c7367aabf7f79f4675c7965dd0309658ef5a4c3027f", + "6f783ff9646bd4ed2b3c718a171eb09a945dd05c98d143d7bbf621124cc98963", + "88125a2dca0e78ac3b0850ba257b688ef6d2044c3ab72246e5a8f2de1ad3c9a2", + "8d15ed815d443c76a40aa1672f0296487ca0050a128979802c9eaadc415a6888", + "abdc37809aa934f5ada73cd1e849a8a34471e5441559ed722d95d01adf5d9ef7", + "b8d3032dd349c02e928b8b74b022dc4da97dfdc569888fc3d48654cab2248715", + "c3f628a83c1c3f1690d695a856f78b5611298f6d277cce0677ec09fbbcde1978", + "c69bbc512fa9d04e00427d056dfb731a6e0d3c885b4a8201f0bde38c5f39a6a6", + "db349b60eeb54c73f8cce795823574612a7da3fdf15396517e6216c800bfe021", + "f56ac3e7c6229cc4f56e251f86b5e853f9a1e1c63707ca694b5bf21761d95ed0", + ], + "app/pages/layout.tsx": [ + "0136dcc025b0340dd3d743426b546de7990a1e45ecaac06e7be68a76c208b5be", + "02c7eee0d5f815722a0e6cfb0f653ddea9eea58a3b2284436bbc0e6ee4d7d0aa", + "03206a36be10d8e6cff5d958fd795b2c0e77045766e40b0be1ac930830d53df7", + "03269e9151d0d3de9fc56a7cf511fd8d9cc8eeffe3b8af33ec9797b7e5bfc1d6", + "03bd6b8c6ed8fd534a35161074c4f1a9035e6b3e792aedfa9bf49e255b5cdef0", + "0478211429ca5de37483ca0bf85f91046eff4b22a2aac587a75bfa2ddcbac1b5", + "0556cd42ea0b44032142ab629b567dbc284f59160be64b5456f2f332cc5b3711", + "064d75ea0301d5c3409de2d4f6bf4482be4f53bee7407ae27a540c67f2d65164", + "06b09405a6e172f3fed3a46309284da4191010f907fcfdde2c3c894af6211bbf", + "079a5febdf76865c9651c8cbce0ee4d021b40f35a256a44b39ea8377bad0fe66", + "09399b480e3e5e152f0c05db561f54732bc9d6be8c8c6f83425a4811b6f45d94", + "09a61122295f9911c373ddd5622c152ed54567375857a63b5ea8cca7f2cef4d2", + "0a228c6873f9d15dfc045d19b50e0d58c27a7af0ff97f47e7527170e2675b5a4", + "0a87297be91c5d9eb088454ec4dd90238a0d39ef4dd39e21031e91ab8536e6ae", + "0a98dc2d290762923a70e397bd00c117dcafaa371b06f8068d6e5b198264d317", + "0ab7ef3a636db82ffb4cee00ade818dd6836398242204ebfa511e1d0e0e3072a", + "0f8089e90c5eb01ba1d08aceaa96ff494bf15a47341da09e0d1ed35c6e3f1fe8", + "0ff9703d27277e9ca776f15437df0fb021634e4debd1dadcbe680049d72635f2", + "12070ec3415960ae8ea18acf7e0acb319a76a5690c0cd0b06a484717ff60ec63", + "14be32ec1d73de6f30a07fb192097c490d2ed8ceb104df7a85d44cb34744642b", + "14dc73a5765421b065f01d8251730e3a0efaf2715cd682410758998931e9f5bf", + "15397fed8cbf7def1b12a058b5dd1972bc29602a810b82e63ce82f5417be9cd7", + "15f0f55d8620d14c42b710f339de23f9df1483f7c90717fa3b5596cf67809a8f", + "162fd46205b40ceb5eb9bfb467ebd2fb7d13e92ac32d02ed78130678eeab5f8e", + "163526b9b650009b254dc2afd9b407e578d24fb2bf22ed50df2a9e34587e485b", + "165f694eee5c3029e22d93e7dfa276462ddf607b47180c320f6afa300a47e1da", + "16ebad5709bc41f8c6226858c936d3c50d34aa69fc1ce7454d6b1312813ca9e2", + "17bb8b88b6c68985efd87955b47f09a8e5516134e6a4c6b96ca55c77f1ef73ce", + "18811250487dfc3f671ca5f92e43c1724d5d63fd301d979a969e82a72d00a50a", + "18939873138820cf2da14672bff94779459ebcce5f9854cf38c86a3258126a42", + "1b06e0f27efb3adbaf0beeb16745ecc2586264bc675e74f6f0901e9988e1ffa4", + "1b82f33d8397636f6cbbdeedd8998499abbe03a0450f5c64a13496dd9dd2f0e4", + "1b919ffe2541ec24168efd6aeca80f01be47837332b7d4bc89881355d363a7ff", + "1bba4084c4dbe50fe91a3026ac52a487f2e2ef620e24759aa0bbac9c931d8cb7", + "1e23c381612e0006e60047526083d35b31d612ed2c098ab360eb0705b036bedc", + "1e416c8fc41d9c1fe04b99d38c8203d580d51fba9a38e597a6d5dba3d915626d", + "1f07f4a4fd41cff38be7984023d6533e5be0f348f4d5a6ad063ce88d8cee7c41", + "1fdea6971170f01ad7265376b3019734d7b964024ba62f7ec26cd7b04c857c92", + "20d663fbc9e96a190e63eed1aceebd784a62974cfc84c9cc38c0b310f59ca818", + "226f38763cfd25559b5d879340b3e015deae2a6621a2fc6009ceb789b56c34cf", + "22a00ec2fc1c1e0b5f973e47c0cc66cc1bb0e2f734deb8f9a0e331171db75df1", + "23e0172da036501f469223f55970428ba1afa7a87c68262a4fa73ad9385de170", + "2417b277b045e8fd5f8cfde35902e1435e291087f6154d5b0d37aee5cc9892c1", + "2422df56e5d9b9e9bd7f29721a7feedcb5591a21c8f537c63c87f57f085405fb", + "24364663a64287619fbf9dac34285de0ca3dac65807cb7061be548b7bc64ddec", + "245a77634a46e55794eafd183d1d194cad212e76f1b656049745ba661cdb9185", + "254ff49ab179b5d5a0b94758f278e94f669741d31d44169bf0b6cb0a0c653c85", + "25a6245b6aaf0752d5bcb931e0c8cd9cfd76bdcc879a45933b92c069e60c1390", + "276e6d60aa425e10a67b52b1e1d40a9bb6e8b9a108e1ad66e15895ee5304cdb0", + "27839a91e00d0de6096fa4ef91dd70381c183a35fb2c4d26544eba6fcc4adb89", + "2949d6d36631d447cdf838085c18a61ae51ef2a3aa770efe5ec05cdad399b7af", + "2aded735d6a51c1388e75f52bdb29420d19c4c072bbbefd4f4c9289025f4dec5", + "2b0ec582adc0627af263b9d32c2a423e4b80bc365526e92c360b1419e0415640", + "2b58c360659968938426bb958a020e4e64eecc766cd58aba76c306f0cb4eaf00", + "2c481c4d68540c88c07f513824dec10b393986c4af5f8446680e3077fada1422", + "2ccb9a75f39223cee1ac411e62f45a48c7212a30de36ac5edd101e423e339c0d", + "2deb97d33b4c787e950e66f80820a406e2ac03e07a8466a18e1b5d0d41204636", + "30bd131bca49f5c40c7dab09dfe3323d91f7a091cc4278d407d9c4a8ef9ffb69", + "32468709fc70fc41041e447d435dc26d02545ace514de5d431d1ff1c0ba5789f", + "332dcde648cc88734354a1b7ec42d2453064f61c4e5c8c73b6a2979336654a77", + "34c5e8ce32f1106cb699abdf1814cd8cef432101f73a51ca3374861b707aec37", + "3a54b38a9772c7cc2525195d238d5becf432fec5ff9b9d4402b8ee73811d1439", + "3c289663878c1834cb15e3f3e26c5a6cdc638351f0866174408dc34d1195c5a3", + "3eb4610e496222cb940e1e55069c9d17695d4c8fe8016f79cccf4bfcc0cf715e", + "4065a308f329f110c69e7ced5c748bea7d8f7784995e148467bb38fd20eac7f1", + "410ff7675e649c02b8919bcece666dd3d470f04b38f69927d49460793c9de3bf", + "4317a44c0ede7414907df8011e0e9818bb61619d16043b14f52356463f4d47bf", + "434ba84bcac4bb4a47038f8b78fbdba867bf5a770719d4fb15a7e9682bb5d2cf", + "44905e7044b2dcbb7c528f05a40ddf307e7b321775e61028ab38a4c8fe2809e6", + "45d471564de8960e626949274f7d51e43eec4cc9599830041078d8bd775a7bbd", + "4706db333fcae7432b87e6dfc4b5a83a12396cd707f358a659ce90c5c3e01caa", + "47f9b080b531cdc8b87a586d9313d83cceaa8c2d6cfba2380d3b6b68a3459b08", + "48c091cad1462e01565385ce8eff415a072c733f70f9e19725c0c7e7fa11118f", + "48c2f0e7d9518f9bd6ede4b9beac17b22411d64ad2369fad4e5fe8b3953c234e", + "49fcb082c5f258af547c0c8214de498ce07b84d2649bceb51e9a958ece0331cd", + "4e13cc7f7b438a47fbfe306190dc7543e940362b23cf94e4ba904ea6612d340d", + "4fa29b00e472744b4ba5caf894c4deb381141cd17fd569cce4216e1889aa479c", + "510adcb44644fa64bc36ee71de6b19b2ca29af728e2670dd309db158862c1138", + "510f1352f0f181411386da0b721ea23667da17b9c689e6096172d15b3c4dbc3e", + "51b8787612ab1ec84506bf3f65ef1f4e84fef466597de10c6079882f9e568208", + "52cc180bf22453b611917580bb7765611fa7e00871af8578d29b1fd99b570f91", + "58d84b360cef58d938c583490a52b629fde82a0c39c5536f05ae51f5b02a7418", + "59bcbece16f892b5c2c68ed9562a66b7cf79ae06dbff5a486809f95fc4fcad4f", + "59fd03569026148a4cd64ad0e0baeec9ea04e10bdcff302cfab62d5a2d758cfa", + "5af073fb0ccbbcc147174d55fbde441f4ab2e8457ac2eb4ac871ecc629db7fce", + "5b6f92cc1dadf62e8409c73e6177ec27d58e38cc536289b4dabf873300ca9d30", + "5b7640b09ed1f06151d1675889e0698f4384ba9ed1407ae76ae3a23d2a5d0323", + "5b77e672fbe466124612e2cd4dc0176ed8ae80fb19ffffff7b158579a019d6a4", + "5c2eeabe70baf1e9349da1645a4a264a515183f3c68d1c1315209cc2f1146177", + "5ce0f9a04336eba7b1384ac1af79ed7e37dff58a48cb614ee5110d6e8ad0540d", + "5ce1186b905e6688458f86f284c0e8c0b2a0934570b1e5a838d3b1e54d9fae5c", + "60b3537042a0aa953224fd9491941bfc7ac7e2315238528637641ca2852766c6", + "61aa2a94e1130be15baf740b91a9b6c51cdf9a35a781d91aa7fdcfde2b6202b6", + "62ded82ab432aeefc122080989955eae2aa443020b86253f9c920f0490b6289f", + "656745282eed8686d8ae6b4c3dce206d246c49654642b0eff66e160dcc707847", + "65d41b222b2cc5ea966715f33f9063a70430b872a6a3cce39a1d561c0552e335", + "675c49e423cd827d195b4e48ccd0bca7dc09298c0ac403a57bb807c859165086", + "6766ad08e0afe71c19dcb489188f6ce8cf0ac869a5d05c351569c2795b952347", + "67e68d2571819f187e5e43f779cc928c83ba1d63ac3a341f43d0e812d5cc9696", + "6802ba8671d597e01795c045fb50e3e8b1d3995639bb8908ec36fcb4d9db0266", + "6bb03c7455709efc019a972a1e7a9f4378c1b70ce40e68c8128cb9ab0d436a48", + "6c1ca6474335949348a6ce4cb1772e043ea7df4065e4a312f7c01ba5008ee358", + "6cb265811508435c3a66b724e3a5e83dec8ec8b8c7610abd66c9070a46b98ea8", + "6e1a6a583d8974dac757b0f2571cccfff6a9806de7186b5699c970950d91e80b", + "6e40191060e79d83706c4282a6264e1c62d4905b92a89970c6c690d92cfde0b9", + "6e608a6b2c33d01e3aac770e3d68d9691be5dd9b5e0d899f1d0fc3310f81e7d3", + "6e8fcc96854c414fae5b19b60750838f0ef25abea61aa7e78fa27e1d0a766dfc", + "6e97d67e051e3b8ca0b0ab02b0927038d84c24ffa901cc39d9054f206237a8b3", + "6f750635e0b8f12237a5519b6d3e7b57b4220ec137f25d8c5cf9768dcdc0380d", + "70dc36b087233a863334959f2b659390c37b23cfc892dd12e9b700fa15b65295", + "716330e68c214def65927a0584127f407ec7f5bcde6c52fd6f4482eeaa0ec30e", + "719e9eda64e369a6e3ee17e35529e310fda74727583b54699c01a263c30b21bf", + "731c41616df90e035aeadf7a599073c2336873ed67c141dac9781847062e3f33", + "734c0966b095ddaa341de2b382c4d3275896d4934ac5e35ca2f9dbb3655cb8b4", + "73e8bde6324f91d9194d59b7e71282bfdee6ca408ef63aa6f402a3e4cd28acc9", + "73e9c44bce976f79db7dd958c4ae10a1c2f53a66fcae50e0a2ba5a21a750c164", + "7403f44da0a74992263cfc76a6210b7d2593dabb2f1fc5b54da74b931f2bf6c3", + "74649041bc75f2d89c2508d08826351e5c982c99c9e2a66179ba3607df249b50", + "75c9f48a3529682576648e639cd8f06eb8bcf74294bf2bddef858e0763076ff9", + "760a00136b557787c23976312e232aea145b3d2cd9f02877b06103f6deaf7402", + "77b77cfe39c4eabbbe69c5acc0ab2f0b9812e03641dfcd7d021ab42f42430036", + "798a8e0d3f9fe76d53503f1428d23ba876e577a8165e0c9f0d7217c0fe182fd9", + "7bc26989c2a65a580b0fd736e8f98251954773d6ebf41c06e806ce06aed639b6", + "7d181bde0b8248fdce224ddd07d01252b1e7dc774dff097abcae1f8936d8566d", + "7f3283880bf730e92096a29ce9e7250759cd3b00a49d9e2fcb48a6462d72b8bf", + "7f475fb5f25232ad0ade7d8610e4293a6e8574b04124ff82490ec279304e6cb1", + "7f907172d2faec3d540650431aafd38c4244ee45564e8d3103b1b7461541777a", + "8064db094180818a5805f74f186318668bddfb00c39e4bdaaeb40fce4885dec5", + "8094f716f5b0dab8c5ea7b01e5ded49d598276b089aff291b6c4f026d527912c", + "82a2cb7bb5e2566f2ed96076c5d567c77d6a13d8b92016c73c77c7bc1f53305f", + "83b0ec27901f9938a2ae0a44a33266325e8ea11d13310d460bbff941c287ec6f", + "8964c4f787fddd50b0837fceb5ecc43c5a8e7119a7cfadea44fa6271dd05eaba", + "89e0cd21a55ab075625f83efc04aeb8ad3f9df3dd877af26d466d67cd0b5313a", + "8c013e6db270a64f968f7af86a48c0f62d786b4002af76a95be33ec0e4009672", + "8c20c2cc1b3e9645853ada7f18e559b606584de5d40f38e02ba76065243f792c", + "8cdc279538a0bf3e1e8fb498c597eb51eaf3463c7b77e2e01da9080f81fbc533", + "8ed2701e02fc35d2f6866f4e7b185c572bb8aa8b8e4077e339c4625b0d19cc0e", + "8fe299c91fcd0739652caa7809ab5e8dc2dce850279d6f2371d0a3fe09f2e7df", + "90e41a7241db13ce376abf06ad5a6e9b00abf38ee6a9d230fc5fff7e73fc2590", + "93dfb1d398581f7379aeb840263d42aabc0ad4e0c3da34853bffbe662b65fa88", + "9573f81ee1b2754bf026fcd897837f04c190efbe5724c29e31e2802782f7e471", + "96d97afcf3259375511c10ee62bd757bbc7ac3af41a35288c062b06d4869a520", + "9728cf71d1ad2e839f23b71d9816611a2e6fb6e6d99f16fb754ff334ce48925e", + "974184a8530bb6ea5c3ca2556df7776c83e597439408c6575c28499073789c01", + "9825cffddb6c64a41b3a196a7ebe12fe91105d634a69fdac429967f074e54496", + "982da687e4feb2b588b04b3ab75c00052927b7e9bf57cafc9a888e75fc40382d", + "9b384053bbe31aea5c8d4b55eab668b5f1900e2d07ded3e65cd8d0dc20a368a5", + "9be11295afef0b23596bca6d313f0c2c296117c0d924cde099aab878f3af6f93", + "9bf2ed7dd7d073f79afa0e088eec1323a0071aeaeda6b2c5b01f0f494f6924c8", + "9d9964404200e781ac1e7cd622a78df51e0591c66a051ac34e7905c6cc9c3f22", + "9de20c4b8a22009383d65c13c414d19e3ce0dee6651efadb8e4949f5931b8460", + "9e3c9b01e2b79ef6170b365a343933ce466b35b840e41326341d8b44a1ddd1da", + "9eccf87dccbb4c22895094d66f4d09d9b8b94159f2d0295af2bfefeb1aef026e", + "a069866af2de9f10b3b7ff906e099f0c58cfba74bbea5ba52c992fbb71e19806", + "a0ebf700ad17021f1124d292dc4b9efa919119f1b077dca4d7bb196060ea9c46", + "a24ec8b917453373d7ec10874c596e195eb6f661d7099637875a36a079fb0577", + "a337e98174d81dbdd32773932e351395f2b5854a40fa1bdd5f5f98d1c426c5ef", + "a46638664c945700a85bbf07aca88dce8f254db03bfb28170daf3e8a7aa27bc3", + "a53e564a49b467ad0b7c5f23f0ccf60c1039fea74f3b48386c7123ae37ff43bd", + "a6a17bba9cae1f770e49ede1dce04fb5d1a35f637e443db97ff9ffe4e6542789", + "a6e0146d745d605a77e96b02c082308279c1277cb4c41a2e26e0c9f26c50b513", + "a8831da1584165b9dbccf1b8540ee3235d2bc71ab2eaf2667a21017f0f5cfd15", + "aa0b32444db882da24ce236772548ae44c949dce21ce96e459e7ea71d73568e5", + "ab7c513cab8b71c7aa373b23451ca3bbea80b62924bf1594544dbb606e925ad8", + "ad4e3314125f7427b2e1bf33a4226991ab3825eb041c7dd6d2cb2588c5eaff74", + "ad793b8d261783d3a6c8eb5a94988d8e720181966456a9dd981829a47da7b5ea", + "aed8bf4097577db6f20473afeb5723b1ab86ae7d9f4db9884bc62ba15b624b8b", + "b029ad30b1ad1646f94df84d6c7d9b3d807a10283430716f89e2f46ad45bea43", + "b1d8b8c423a4f8e0d97a125513afe16fff7d33e4305dea2448d797d309dc18bf", + "b30d29e0d0e49df94ca28d49fb7a916af2a8cb27058e144a697f3c1f203af637", + "b35aba95ff8025215e88854b201a9b47d0e7f3b13d59f874c87c1044312fdd3f", + "b36e8ab6d6012459d1f5165728f8d791cf55364a1ae27876243c8fc2bedc3a5b", + "b36fc9de2bf221df0b7763af667e8a08be7a9a2a83ad4f414495f89a77d7e1f8", + "b3d1114a1607d47fd2275904f778c9b23831661d0680ad61db80ae38357b6922", + "b461783742b3090154f79628d93d1705b4bd1daa366edfabaa64786450bc05b7", + "b584363e042e867873818c03e0a5c462ece52e9ae82c809e8bf118bcd39eea07", + "b59fc0da63a5d2c623ee45ea3bc86a32e78c7cd644a1ca872e70451167cf3ee0", + "b6baaacbbbad20206f17ae0e8589e565ed929effb9d29c0917d479ddcb519ed8", + "b6cb30f98d310559b217c8dbdc0e826860952abaa196df37f6dae0a388a6eefb", + "b8e45583f785930d93047a5be1585d7854cd4e5d3bb7980b346029cc77f9bfe0", + "b90943a6f2363d6cd4602d6aac81a398d54aa92da7aa0f0ffc29b90f3843cd0f", + "ba6c9e7b221e78df4fb566cccb067fc096c007a5220992748e6e3d80f401ad39", + "bc0da364fab61dcc2ce1475dbaa520d306d0d0da4f2f10544dff4aa8e504c4ee", + "bcc0a69664b7a21a00e952d1f160ffed1ee21ba6c4a1be9090958cd0e231b840", + "be1ff40b18a3479e9492e549061de0280b38bdea39258cde35ed53c16aa57de1", + "bfca6bdbacff3390da0bb1d2a4fc9b32ae7a1979fc00043c622d3ed27fe4bddd", + "c1cad4c237fa6718df637573cae386bd9ab44379a08305d94e49629892471c11", + "c3501d82851c37a21fe16bffa20987bf1567cd6081e5a80513e2e9b96b1b1f1a", + "c3d93018e27122d06edd97611d2fe989c5160e22269727c14dbeac49d54ea16c", + "c5002020afb6d6ea3f1132d2954c6697d4e7008e5a6070dad7c66736864c2049", + "c56b3e8de969ec6a8a5f5c388cebba26fb993b62d688607e9a2bf68f004905ef", + "c62f0803b482f5f755fc4d9336425480b031dd5d016b7b47c3c8a6c0fcadd0c5", + "c773ecf94157701825054000d8b6eef242ca72ad12badf42294e19720e32b7b6", + "c86088556fcc027d388f0bd4d5a4d3f55be038fbf11798b71c88eb3244fe0f35", + "c89805c237f15717ba6b9ace95a72f6b7d65274fe153a63bb469719ff15f7099", + "c92a0d2b6a18a6172a6e12ee1ae073985134fa337ab29780830f70d9a39b10ee", + "cb33bc4f73c5cb9457dda9f6cd5e272c4543ffa00d89b2abfa4b43d38c7f0438", + "ccf36862cab69e8ddf43a227b52df58863558aedb1dcddbffedd18721a27bee1", + "cdf5e95fb760f5ea31a216be06cd9fb135273d49a3b213557a9401a95a43f872", + "d01a446b5a380cfbff00fbe85a14af858b2e46d0d78398f05f60270ec5d60aa2", + "d195613dafd6e7e5c0277411fb4d6937b4b5f168a541293320de035d6108e850", + "d1ae39212231cf0555d7b8f450e90d31136b4dcc7ddc4b52320fbe9d2982650d", + "d2a6d89190618c53b7898b91988c2d43f94b958ca99842fb6d8a7bb249e83556", + "d2b43a1fb7143ef10fda3ba30ad2e8b158847df6846a885680370bc1d37ba2f2", + "d2bcf2c2c7b74560df4f101dbdecb449c4e2a15362030dbca632d2958a6044ba", + "d2e461b976f0269631113f1de89c33ab78660135b7a8459c023f67f7f3a541f5", + "d33b05e95d22d4804cbea700479249f4e75ca32316a2795d3635672b99dab11c", + "d39425caf322f6d42bdd886e3407ebb6f8b5d2e5a3b74603897efb32075a2a42", + "d4dfcb1ee4e28e579a7d06090678dc7f05f8043dbbe89f35ea6d59c19279b2a9", + "d4f710f0716492a12febfa899284f63f0a0606f918e8bb4b4ac31ba8e23524f8", + "d5147385b3a0ddd841e74bcdf1793413f3fc684bea639d25059f27b5713dadaf", + "d5d74270d83cf40c3f3c2023bb4ab5188283d6cbd41f9408981d80ffe051e633", + "dbb7b6a1c4b02547a18a8936a917c6f9b5eba4e160c6a6e542ca14a3d95499d2", + "dc5bd9ba4cad7cbe838d5d0a23f04f768d8d333dd21a0a55a5d79f53cbf0d24a", + "dd8883819dc90386d3e4790f445fdb98e97939c09d7d78b846f6e9efce0d3574", + "e0260c98440c10eba8190440f2febdd2c1cf1bb1d403efb9df2a8dd769aaaa4c", + "e418c2d91e16e4f42d37d4e9e1ad5f7a6e68f09d8e8d0779b292e0c41c80d606", + "e6205c0ab89168b3e60ca4b2243c7338f204afec3ccf25ae6450bffae67a4efe", + "e66e2debca465e18d8b574d3734f80ca5ddc96b21f5c5c8f6b8f316089078c9b", + "e710a5d77155590d9631cf211e5985c1118134c7e64463aec3cd290ef5a94696", + "e71303c9e649ce4a12620eddb435e7034938e7e49a2ac89d0c57b4a05ed11050", + "e8e89af603f79bcf36c6f13f08b6710ba179542d8231206ff8bdab0dfefc7092", + "e9c0886e6ed7857d2c524e9ee9e55055c9d05726bbc56c3520ba3b08f4e7d320", + "ec192f7683df3318498fe5229c8b4fc34b7c42be47a04034f5c0ae19515ff07f", + "ecaf3d82e4a39ef25be6ded4c3d060a2912940e05c3504054034bb80025ea583", + "ed7502273b93ec426ac487fd6cc2362e63a431e0eb4ecd79bc9445f344ffe3f1", + "ee3c7d3bf987ab9c5c2b300c4284a11d99a814309b3efab1edede8742f8a8b30", + "eff67924194e6f421b458008fb1972ee57b0ee35b4995a40596811fe249c7484", + "f1285a5a9fe841b71ad34ea3b84ed46b4fbee43fb690751e2407eb2e6b8e2920", + "f220f6ee7a7e96991ceeb99b3e0f7706571713b0b5627e18f732e61d08fc27b8", + "f4330d4f5435e012f6481827d4cc1d4f6c0c966b5155e7f50154ce9abca1c9a7", + "f599677718e94bc5ea3ab90fd73a7f201708fdce1a57d5438323a55d1048c9bf", + "f726df6c7c9619ba2f455197c56ea1611bd037aca71aeb03098f3dbd5e9346b1", + "f72f6ef66e32fbeb5198711b8298a5702ccc2e16a376da8f9a452ea28dd8f8d1", + "f83a76d515eec8029c1b11fee99b2123f89b9350b6fd3cb2c8c552f7acd63447", + "f8c7be6f2cffbbdc8a755330bec7af2b6e6d0c17e5ece6cf4cd6976c233dbd75", + "fa0d71fa66a3b3d03b2b939dd50094bcdc633d7b5320acee7978a456533ab437", + "fa974399bceaaf20d83dd133e25b0c788ba535e69acaec8d069de67785ad06f9", + "fc02334ab348648db70c9661467d1c0f5b56f6324087878712e192af7a3aef6b", + "fceab8440e2e47d90232df5e830b88e3c09cf839048b934b3b1318c9fd5f469c", + ], + "app/pages/ssg-blog/page.tsx": [ + "00070a677101d1141de698264421a4b725469498f24edc5521452adc740269ae", + "0a00499fa4978b192dea04a7b19053b101bed8724389bc88707aba87323d85d1", + "1e2570a9ca9ab7665329a31c6451a3e1d81397c0bab840bac94d7c79cb8f3f23", + "2ae054cd6922cc41ba6c7354b5499718e3bf9c0f3f7e4f759ce07be7f9845d01", + "4d3c3fd6499dbfd5a94e7acc92beb5a8b94452c887939804db8c50e645311cc8", + "4ef38357ea2ed3a7541ad2b10c35b1a8574b8d0496c9be6656d96d40f9b48439", + "6c9a3a697a51ed9213b7e412c6b5d0bff9d7277cf7eb7016779c5014ea0ccc47", + "8b3f0b41c26d35b594611eeec0a602cdffc950923295448f25e4798b430db80a", + "9b388fa844f70961b5d04a5ef00e2904899e00b07de3ddf009bb1a1c228c2367", + "b34b75d39ee1c57286f6d62c49d89a97ea46fbeec2a3f8d3bf0db752e7d53c8a", + "b4874989d32f753f0446680a81f846d82ddea12ebed2bbe2b1b94a1e94a01036", + "d1722184d590da6c22fe6b1d1ad3534c1248d614f9221b916d7338b8a9247ddd", + ], + "app/pages/ssg-blog/[slug]/page.tsx": [ + "05f169c73cafa293fe65d5583640efaf7769485f1c8dcb8bdffe5d3323dc5689", + "0e4f8e53641f207ffdd99c627d99db269b6f98d341c5324b646b8c0382b21953", + "1a31a3817d8bd95857f324f7ee1dc39152cc644bc34772edcdfacb315852f630", + "3bff9d2dd6b6cffff202d29903891657a3cf2adb6542c1ce1cb6188247afd54e", + "452d28076e6f02de401f55ed7cd0aa80a343641693fc7062efac43d6abd5a927", + "89ab0da24c606309e9afa9d404249c42d9664469030df3198db475edbbd01633", + "8bf8ae497e8e3bb07480b490af0d57546db8de12022839764fc0ab081cea6886", + "a9d5604dc565a6c33185f7dbe023e692d2191f35f106cab41222e5bf1efbed2f", + "cac8a2fbc2f94444e39bd3690bbcf0cbc34320bf711d74c9b5a3d01a991987a2", + "da32f92e5b60d751903a60fd4793ce63a999ec7ea66f67a26667a2b2ca399091", + "e8e2ad4b04226a651efb5d160a96efb7908f5cc817c6096e64fd12fb90b87135", + "ebdbcb3265d930b1bb15f719e3f6a35c276b246aab0d2726b95e061a27a832d1", + ], + "app/pages/ssg-cms/[typeSlug]/page.tsx": [ + "0ec8b342243187bdb0ff159fa640556f8a157d603d8dfc009e920c6a313060d1", + "15eedc602de124a00594b4f7794fdd702fd9c9f9fb84a127f0cf9549ea252d6f", + "373021ddfb61ff43533dc012ca138edc61a08d704767811ba94559b9556e697c", + "44da4b69afdd7adcd3a6942f601f78e01bcb18a19b314343ecaa6b794ff6a695", + "899347b4b95901e083f39046cf74850ed124ddf31a551fed0c7c5d36c10efab7", + "8bdc65d7c6a40282c96ffb3e5c92dfd5dbb334a2b49354d8e8c2c37dcdb51d0f", + "8f1110ac7214dd830dbe9c4cb9a7238b4240138af4ff3aad4b5eba1977897996", + "ab498f1d77d57f9536c00601dda543979444c03bdecae75cb42bd1f8b7593a66", + "b396d4a8fd2648858ba25cb1fdf651061fd5a3fc7c27b10918fa8ef5cfc6ea96", + "b434ce09bbf9d584dae5ef9e1dffa7746df50f9b5cb0c6fac6eb675679b2f97b", + "c28560db440b3b2e4689de2768e2b5926cdd34f1f2db49250556256a400c7896", + "fc09c4a942f919a50f49fa31818027079c7887e44cc30b92c6b4974b1742dfef", + ], + "app/pages/ssg-forms/page.tsx": [ + "0b19e1751488cad313e67089bbb58b0edc46f3600e9354e8bf8a823702ff2f04", + "137b1397525a94d7ca5739b4362487535651e3b305b0ca59a52fabfc97fe554b", + "4328cacc5fef1c8329801d3f5fa9514eeabca9b9befc6c06fc34ca7ea56df5d9", + "4861ae658a70ccb056f5dc7d6c3e114c95b817c444213aa6ebf3f9acb63aa13a", + "5dfb21f7bed6431edad45e367fcd2cd511b3d63cb3e8c2f31b14cdb2cab5ad1c", + "6205443e6a85c184768ea809e1be51db7853430096a16afd22499c71c29ea293", + "703c6672e2dcbb5caa8135e19fac613434247c3d3cc93278e323afddc76b1df0", + "71eb899f044bad9e580623106a86ab7a70793f00b7d3120a9374d9247a986cd5", + "c3e8acc348f8ef3826bde8ed95c7afc2c5dbb841c0bc2da59443201da0597e60", + "d690c7e41ffc6d514651c57013e98ba1756dddc5a8b31f2ea4b8d76b7fcc10c5", + "da127e6104f8e9c0dcf7cee82adbeafbd6c34527b82b1ff5ea113ef09735cbce", + "daeb9c8d87798d67c1abcccae3a65fbad68a6f894460df50852797eae0919417", + ], + "app/pages/ssg-kanban/page.tsx": [ + "12d141a78275f71590c71e525c0b14eab5111b824f03ca51c5097af3a10212b7", + "172c7171dd50c2a28241f6bbd9d2029d2da813b2eda04f882326c48c3c03de85", + "3bc3acba3bd0d3e892730bc95f1395c95b985e5b976339e80e7215b7f8030619", + "3e0483382e78f0843d0eae44e20790716c9eb6f5df1441ef6bfc23620395718c", + "46c61701c5bcc311c2b1cd3aff08a60d4402f3d958d595014bcc089abf73d871", + "49efbf31b982bad7f1b4e874eeb2454abaeeaf6cb2ab6b3ff767eba5dec8359a", + "4e0badc3dc8ed42559a498939346f7ea14c132199c9a1205320fb3195079359c", + "97c3917251d9afb68cf765d7eb5ad9ba15c7563d337754b320835503962ebb8f", + "b515391e098fc36e39e62a0b9ff239c30d6e262e5d4c85b118dcce0ff272c84b", + "b76dd853f144158a803ec2a859de86a8c4f4ec9a13d8beb5d45bb678635af563", + "c20b3c49211126160a2290b945cd0c42f4a144490150a33f6b61d81730e161c6", + "dd502d063b782d7471eea6d4c771890ed28bdd1adeb77f4c3ce2ef97510de2d0", + ], +} as const; diff --git a/packages/cli/src/utils/legacy-next-scaffold.ts b/packages/cli/src/utils/legacy-next-scaffold.ts new file mode 100644 index 000000000..a64c46623 --- /dev/null +++ b/packages/cli/src/utils/legacy-next-scaffold.ts @@ -0,0 +1,129 @@ +import { createHash } from "node:crypto"; +import type { Dirent } from "node:fs"; +import { readFile, readdir, unlink } from "node:fs/promises"; +import { join } from "node:path"; +import type { ConflictPolicy } from "./file-writer"; +import { LEGACY_NEXT_RENDER_HASHES } from "./legacy-next-render-hashes"; +import type { FileWritePlanItem } from "../types"; + +interface LegacyNextFile { + legacyPath: string; + knownHashes: readonly string[]; +} + +const legacyNextPaths = Object.keys(LEGACY_NEXT_RENDER_HASHES) as Array< + keyof typeof LEGACY_NEXT_RENDER_HASHES +>; + +// Content outside the historical renderer matrix is consumer-owned and must +// never be deleted. +function getLegacyNextFiles(prefix: string): LegacyNextFile[] { + return legacyNextPaths.map((path) => ({ + legacyPath: `${prefix}${path}`, + knownHashes: LEGACY_NEXT_RENDER_HASHES[path], + })); +} + +async function collectDescendantFiles( + directory: string, + projectPath: string, +): Promise { + let entries: Dirent[]; + try { + entries = await readdir(directory, { withFileTypes: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; + throw error; + } + + const files: string[] = []; + for (const entry of entries) { + const entryProjectPath = `${projectPath}/${entry.name}`; + if (entry.isDirectory()) { + files.push( + ...(await collectDescendantFiles( + join(directory, entry.name), + entryProjectPath, + )), + ); + } else { + files.push(entryProjectPath); + } + } + return files; +} + +/** + * Removes recognized files from the previous Next.js BTST scaffold before the + * request-aware and static route groups are written. Unknown or customized + * files fail closed so init never silently deletes user code. + */ +export async function migrateLegacyNextScaffold( + cwd: string, + files: FileWritePlanItem[], + policy: ConflictPolicy, +): Promise { + const requestLayout = files.find((file) => + file.path.endsWith("app/(request)/pages/layout.tsx"), + ); + if (!requestLayout) return []; + + const prefix = requestLayout.path.slice( + 0, + requestLayout.path.length - "app/(request)/pages/layout.tsx".length, + ); + const found: Array = []; + + for (const legacyFile of getLegacyNextFiles(prefix)) { + try { + const content = await readFile(join(cwd, legacyFile.legacyPath), "utf8"); + found.push({ ...legacyFile, content }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + } + + if (found.length === 0) return []; + + const paths = found.map((file) => file.legacyPath).join(", "); + if (policy !== "overwrite") { + throw new Error( + `Legacy BTST Next.js routes conflict with the current request/static route groups: ${paths}. Back them up and remove them, or rerun init with overwrite selected.`, + ); + } + + if (found.some((file) => file.legacyPath.endsWith("app/pages/layout.tsx"))) { + const knownPaths = new Set([ + ...getLegacyNextFiles(prefix).map((file) => file.legacyPath), + ...files.map((file) => file.path), + ]); + const pagesProjectPath = `${prefix}app/pages`; + const unknownDescendants = ( + await collectDescendantFiles( + join(cwd, pagesProjectPath), + pagesProjectPath, + ) + ).filter((path) => !knownPaths.has(path)); + if (unknownDescendants.length > 0) { + throw new Error( + `Refusing to remove the legacy BTST Next.js layout while consumer-owned routes remain: ${unknownDescendants.join(", ")}. Move them into app/(request)/pages or app/(static)/pages, then rerun init.`, + ); + } + } + + const customized = found.filter((file) => { + const hash = createHash("sha256").update(file.content).digest("hex"); + return !file.knownHashes.includes(hash); + }); + if (customized.length > 0) { + throw new Error( + `Refusing to remove customized legacy BTST Next.js routes: ${customized.map((file) => file.legacyPath).join(", ")}. Move the customizations into app/(request)/pages or app/(static)/pages, then remove the legacy files and rerun init.`, + ); + } + + for (const file of found) { + await unlink(join(cwd, file.legacyPath)); + } + + return found.map((file) => file.legacyPath); +} diff --git a/packages/cli/src/utils/scaffold-plan.ts b/packages/cli/src/utils/scaffold-plan.ts index 2a0a1c37e..f1e881cfb 100644 --- a/packages/cli/src/utils/scaffold-plan.ts +++ b/packages/cli/src/utils/scaffold-plan.ts @@ -17,28 +17,20 @@ interface BuildScaffoldPlanInput { cssFile: string; } -const CANONICAL_CLIENT_PLUGIN_KEYS = new Set([ - "blog", - "ai-chat", - "cms", - "ui-builder", - "comments", - "form-builder", - "kanban", - "media", - "route-docs", -]); - function getFrameworkPaths(framework: Framework, cssFile: string) { if (framework === "nextjs") { const prefix = cssFile.startsWith("src/") ? "src/" : ""; return { stackPath: `${prefix}lib/stack.ts`, stackClientPath: `${prefix}lib/stack-client.tsx`, + stackClientServerPath: `${prefix}lib/stack-client.server.ts`, + stackClientOriginsPath: undefined, queryClientPath: `${prefix}lib/query-client.ts`, apiRoutePath: `${prefix}app/api/data/[[...all]]/route.ts`, - pageRoutePath: `${prefix}app/pages/[[...all]]/page.tsx`, - pagesLayoutPath: `${prefix}app/pages/layout.tsx`, + pageRoutePath: `${prefix}app/(request)/pages/[[...all]]/page.tsx`, + pagesLayoutPath: `${prefix}app/(request)/pages/layout.tsx`, + pagesStaticLayoutPath: `${prefix}app/(static)/pages/layout.tsx`, + pagesClientLayoutPath: `${prefix}app/pages/client-layout.tsx`, layoutPatchTarget: `${prefix}app/layout.tsx`, }; } @@ -47,10 +39,14 @@ function getFrameworkPaths(framework: Framework, cssFile: string) { return { stackPath: "app/lib/stack.ts", stackClientPath: "app/lib/stack-client.tsx", + stackClientServerPath: "app/lib/stack-client.server.ts", + stackClientOriginsPath: undefined, queryClientPath: "app/lib/query-client.ts", apiRoutePath: "app/routes/api/data/$.ts", pageRoutePath: "app/routes/pages/$.tsx", pagesLayoutPath: "app/routes/pages/_layout.tsx", + pagesStaticLayoutPath: undefined, + pagesClientLayoutPath: undefined, layoutPatchTarget: "app/root.tsx", }; } @@ -58,10 +54,14 @@ function getFrameworkPaths(framework: Framework, cssFile: string) { return { stackPath: "src/lib/stack.ts", stackClientPath: "src/lib/stack-client.tsx", + stackClientServerPath: "src/lib/stack-client.server.ts", + stackClientOriginsPath: "src/lib/stack-client.origins.ts", queryClientPath: "src/lib/query-client.ts", apiRoutePath: "src/routes/api/data/$.ts", pageRoutePath: "src/routes/pages/$.tsx", pagesLayoutPath: "src/routes/pages/route.tsx", + pagesStaticLayoutPath: undefined, + pagesClientLayoutPath: undefined, layoutPatchTarget: "src/routes/__root.tsx", }; } @@ -71,13 +71,38 @@ function getPublicSiteURLVar(framework: Framework) { return "VITE_PUBLIC_SITE_URL"; } +function getPublicApiURLVar(framework: Framework) { + if (framework === "nextjs") return "NEXT_PUBLIC_API_URL"; + return "VITE_PUBLIC_API_URL"; +} + +function getMigrationBaseURLVar(framework: Framework) { + if (framework === "nextjs") return "NEXT_PUBLIC_BASE_URL"; + return "VITE_BASE_URL"; +} + function getBrowserSiteURLExpression(framework: Framework) { if (framework === "nextjs") return "process.env.NEXT_PUBLIC_SITE_URL"; return "import.meta.env.VITE_PUBLIC_SITE_URL"; } +function getBrowserApiURLExpression(framework: Framework) { + if (framework === "nextjs") return "process.env.NEXT_PUBLIC_API_URL"; + return "import.meta.env.VITE_PUBLIC_API_URL"; +} + +function getMigrationBrowserBaseURLExpression(framework: Framework) { + if (framework === "nextjs") return "process.env.NEXT_PUBLIC_BASE_URL"; + return "import.meta.env.VITE_BASE_URL"; +} + +function getMigrationServerBaseURLExpression(framework: Framework) { + if (framework === "nextjs") return "process.env.NEXT_PUBLIC_BASE_URL"; + return "import.meta.env.VITE_BASE_URL"; +} + function getPagesLayoutFilePath(framework: Framework): string { - if (framework === "nextjs") return "app/pages/layout.tsx"; + if (framework === "nextjs") return "app/pages/client-layout.tsx"; if (framework === "react-router") return "app/routes/pages/_layout.tsx"; return "src/routes/pages/route.tsx"; } @@ -111,10 +136,6 @@ function buildPluginTemplateContext( Boolean(m.clientImportPath) && Boolean(m.clientSymbol), ); - const hasLegacyClientPlugins = clientMetas.some( - (m) => !CANONICAL_CLIENT_PLUGIN_KEYS.has(m.key), - ); - const backendImportLines = backendMetas .map((m) => `import { ${m.backendSymbol} } from "${m.backendImportPath}"`) .join("\n"); @@ -132,20 +153,22 @@ function buildPluginTemplateContext( backendImportLines, hasAiChat ? `import { openai } from "@ai-sdk/openai"` : "", hasCms ? `import { z } from "zod"` : "", + hasMedia + ? `import { localAdapter } from "@btst/stack/plugins/media/api/adapters/local"` + : "", ] .filter(Boolean) .join("\n"), clientImports: clientMetas .map((m) => `import { ${m.clientSymbol} } from "${m.clientImportPath}"`) .join("\n"), - hasLegacyClientPlugins, backendEntries: metas .map((m) => { if (!m.backendSymbol) { return ""; } if (m.key === "ai-chat") { - return `\t\t${m.configKey}: ${m.backendSymbol}({ model: openai("gpt-4o-mini"), access: "public" as const }),`; + return `\t\t${m.configKey}: ${m.backendSymbol}({ model: openai("gpt-4o-mini"), access: "public" }),`; } if (m.key === "cms") { const articleType = `{ @@ -168,7 +191,7 @@ function buildPluginTemplateContext( return `\t\t${m.configKey}: ${m.backendSymbol}({ allowPosting: false }),`; } if (m.key === "media") { - return `\t\t${m.configKey}: ${m.backendSymbol}({ storageAdapter: undefined as any }),`; + return `\t\t${m.configKey}: ${m.backendSymbol}({ storageAdapter: localAdapter() }),`; } if (m.key === "ui-builder") { return ""; @@ -180,21 +203,15 @@ function buildPluginTemplateContext( clientEntries: clientMetas .map((m) => { if (m.key === "ai-chat") { - return `\t\t\t${m.configKey}: ${m.clientSymbol}({ mode: "public" as const }),`; + return `\t\t\t${m.configKey}: ${m.clientSymbol}({ mode: "public" }),`; } - if (CANONICAL_CLIENT_PLUGIN_KEYS.has(m.key)) { - return `\t\t\t${m.configKey}: ${m.clientSymbol}(),`; - } - const siteBase = "/pages"; - return `\t\t\t${m.configKey}: ${m.clientSymbol}({ -\t\t\t\tapiBaseURL: baseURL, -\t\t\t\tapiBasePath: "/api/data", -\t\t\t\tsiteBaseURL: baseURL, -\t\t\t\tsiteBasePath: "${siteBase}", -\t\t\t\tqueryClient, -\t\t\t}),`; + return `\t\t\t${m.configKey}: ${m.clientSymbol}(),`; }) .join("\n"), + clientApiEndpointEntries: clientMetas + .filter((m) => m.backendSymbol && m.key !== "ui-builder") + .map((m) => `\t\t\t\t${m.configKey}: crossOriginApiEndpoint,`) + .join("\n"), pagesLayoutOverrides: clientMetas .map((m) => { if (m.key === "route-docs" || m.key === "media") { @@ -205,14 +222,14 @@ function buildPluginTemplateContext( return ""; } if (m.key === "blog") { - return `\t\t\t\t\t"${m.key}": { + return `\t\t\t\t\t${m.configKey}: { \t\t\t\t\t\tuploadImage: async () => { \t\t\t\t\t\t\tthrow new Error("TODO: implement blog.uploadImage override in ${layoutFile}") \t\t\t\t\t\t}, \t\t\t\t\t},`; } if (m.key === "kanban") { - return `\t\t\t\t\t"${m.key}": { + return `\t\t\t\t\t${m.configKey}: { \t\t\t\t\t\tuploadImage: async () => { \t\t\t\t\t\t\tthrow new Error("TODO: implement kanban.uploadImage override in ${layoutFile}") \t\t\t\t\t\t}, @@ -326,8 +343,16 @@ export async function buildScaffoldPlan( const sharedContext = { alias: input.alias, - providerApiLiteral: '{{ baseURL, basePath: "/api/data" }}', + browserApiURLExpression: getBrowserApiURLExpression(input.framework), browserSiteURLExpression: getBrowserSiteURLExpression(input.framework), + migrationBrowserBaseURLExpression: getMigrationBrowserBaseURLExpression( + input.framework, + ), + migrationServerBaseURLExpression: getMigrationServerBaseURLExpression( + input.framework, + ), + migrationBaseURLVar: getMigrationBaseURLVar(input.framework), + publicApiURLVar: getPublicApiURLVar(input.framework), publicSiteURLVar: getPublicSiteURLVar(input.framework), useGlobalSingleton: input.framework === "nextjs" && input.adapter === "memory", @@ -363,6 +388,26 @@ export async function buildScaffoldPlan( ), description: "BTST client stack configuration", }, + { + path: frameworkPaths.stackClientServerPath, + content: await renderTemplate( + "shared/lib/stack-client.server.ts.hbs", + sharedContext, + ), + description: "BTST credentialed request stack configuration", + }, + ...(frameworkPaths.stackClientOriginsPath + ? [ + { + path: frameworkPaths.stackClientOriginsPath, + content: await renderTemplate( + "tanstack/stack-client.origins.ts.hbs", + sharedContext, + ), + description: "BTST trusted client origin server function", + }, + ] + : []), { path: frameworkPaths.queryClientPath, content: await renderTemplate( @@ -400,6 +445,28 @@ export async function buildScaffoldPlan( }); } + if (frameworkPaths.pagesStaticLayoutPath) { + files.push({ + path: frameworkPaths.pagesStaticLayoutPath, + content: await renderTemplate( + "nextjs/pages-static-layout.tsx.hbs", + sharedContext, + ), + description: "BTST static pages layout wrapper", + }); + } + + if (frameworkPaths.pagesClientLayoutPath) { + files.push({ + path: frameworkPaths.pagesClientLayoutPath, + content: await renderTemplate( + "nextjs/pages-client-layout.tsx.hbs", + sharedContext, + ), + description: "BTST pages client provider", + }); + } + // ── Derived paths ───────────────────────────────────────────────────────── const prefix = input.framework === "nextjs" && input.cssFile.startsWith("src/") @@ -465,7 +532,7 @@ export async function buildScaffoldPlan( if (input.framework === "nextjs") { if (pluginContext.hasBlog) { files.push({ - path: `${prefix}app/pages/ssg-blog/page.tsx`, + path: `${prefix}app/(static)/pages/ssg-blog/page.tsx`, content: await renderTemplate( "nextjs/ssg-blog-list.tsx.hbs", sharedContext, @@ -473,7 +540,7 @@ export async function buildScaffoldPlan( description: "SSG Blog list page", }); files.push({ - path: `${prefix}app/pages/ssg-blog/[slug]/page.tsx`, + path: `${prefix}app/(static)/pages/ssg-blog/[slug]/page.tsx`, content: await renderTemplate( "nextjs/ssg-blog-post.tsx.hbs", sharedContext, @@ -483,14 +550,14 @@ export async function buildScaffoldPlan( } if (pluginContext.hasCms) { files.push({ - path: `${prefix}app/pages/ssg-cms/[typeSlug]/page.tsx`, + path: `${prefix}app/(static)/pages/ssg-cms/[typeSlug]/page.tsx`, content: await renderTemplate("nextjs/ssg-cms.tsx.hbs", sharedContext), description: "SSG CMS content list page", }); } if (pluginContext.hasFormBuilder) { files.push({ - path: `${prefix}app/pages/ssg-forms/page.tsx`, + path: `${prefix}app/(static)/pages/ssg-forms/page.tsx`, content: await renderTemplate( "nextjs/ssg-forms.tsx.hbs", sharedContext, @@ -500,7 +567,7 @@ export async function buildScaffoldPlan( } if (pluginContext.hasKanban) { files.push({ - path: `${prefix}app/pages/ssg-kanban/page.tsx`, + path: `${prefix}app/(static)/pages/ssg-kanban/page.tsx`, content: await renderTemplate( "nextjs/ssg-kanban.tsx.hbs", sharedContext, @@ -521,6 +588,14 @@ export async function buildScaffoldPlan( ), description: "Public AI chat page", }); + files.push({ + path: `${prefix}app/public-chat/client.tsx`, + content: await renderTemplate( + "nextjs/public-chat-client.tsx.hbs", + sharedContext, + ), + description: "Public AI chat client component", + }); } else if (input.framework === "react-router") { files.push({ path: "app/routes/public-chat.tsx", @@ -553,6 +628,14 @@ export async function buildScaffoldPlan( ), description: "Public form demo page", }); + files.push({ + path: `${prefix}app/form-demo/[slug]/client.tsx`, + content: await renderTemplate( + "nextjs/form-demo-client.tsx.hbs", + sharedContext, + ), + description: "Public form demo client component", + }); } else if (input.framework === "react-router") { files.push({ path: "app/routes/form-demo.tsx", diff --git a/packages/cli/src/utils/seed-plan.ts b/packages/cli/src/utils/seed-plan.ts index b6beeef1e..1ae241190 100644 --- a/packages/cli/src/utils/seed-plan.ts +++ b/packages/cli/src/utils/seed-plan.ts @@ -20,16 +20,14 @@ export function seedApiPath(pluginKey: PluginKey): string { // Each value is a function body string using `myStack` from the stack import. const BLOG_SEED_BODY = ` - const adapter = myStack.adapter - const existing = await adapter.findMany({ model: "post", limit: 1 }) - if (existing.length > 0) return { ok: true, skipped: true } + const blog = myStack.trusted.blog + const existing = await blog.listPosts({ limit: 1 }) + if (existing.items.length > 0) return { ok: true, skipped: true } const now = new Date() - await adapter.create({ - model: "post", - data: { - title: "Getting Started with BTST Blog", - slug: "getting-started", - content: \`# Getting Started with BTST Blog + await blog.createPost({ + title: "Getting Started with BTST Blog", + slug: "getting-started", + content: \`# Getting Started with BTST Blog Welcome to the **BTST Blog plugin** demo! This post was seeded automatically when the server started. @@ -45,19 +43,14 @@ Welcome to the **BTST Blog plugin** demo! This post was seeded automatically whe The editor supports full **Markdown** including code blocks, blockquotes, tables, lists, and headings. Try creating a new post to see the editor in action!\`, - excerpt: "An introduction to the BTST blog plugin — browse posts, create new ones, and explore the Markdown editor.", - published: true, - publishedAt: now, - createdAt: now, - updatedAt: now, - }, + excerpt: "An introduction to the BTST blog plugin — browse posts, create new ones, and explore the Markdown editor.", + published: true, + publishedAt: now, }) - await adapter.create({ - model: "post", - data: { - title: "Building Full-Stack Apps with Plugins", - slug: "full-stack-plugins", - content: \`# Building Full-Stack Apps with Plugins + await blog.createPost({ + title: "Building Full-Stack Apps with Plugins", + slug: "full-stack-plugins", + content: \`# Building Full-Stack Apps with Plugins BTST takes a plugin-first approach to full-stack development. Each plugin ships with backend API routes, database schema, React components, and React Query hooks. @@ -69,59 +62,67 @@ BTST takes a plugin-first approach to full-stack development. Each plugin ships | Kanban | Project boards and task tracking | | Form Builder | Dynamic forms with submissions | | UI Builder | Visual drag-and-drop page builder |\`, - excerpt: "Explore how BTST plugins combine backend APIs, database schemas, and React components into one cohesive system.", - published: true, - publishedAt: new Date(now.getTime() - 86400000), - createdAt: new Date(now.getTime() - 86400000), - updatedAt: new Date(now.getTime() - 86400000), - }, + excerpt: "Explore how BTST plugins combine backend APIs, database schemas, and React components into one cohesive system.", + published: true, + publishedAt: new Date(now.getTime() - 86400000), }) - await adapter.create({ - model: "post", - data: { - title: "SEO and Meta Tags in BTST", - slug: "seo-and-meta-tags", - content: \`# SEO and Meta Tags in BTST + await blog.createPost({ + title: "SEO and Meta Tags in BTST", + slug: "seo-and-meta-tags", + content: \`# SEO and Meta Tags in BTST BTST plugins generate proper meta tags for every page automatically including title, description, Open Graph, and Twitter card tags.\`, - excerpt: "BTST plugins generate Open Graph and Twitter card meta tags for every page automatically.", - published: true, - publishedAt: new Date(now.getTime() - 172800000), - createdAt: new Date(now.getTime() - 172800000), - updatedAt: new Date(now.getTime() - 172800000), - }, + excerpt: "BTST plugins generate Open Graph and Twitter card meta tags for every page automatically.", + published: true, + publishedAt: new Date(now.getTime() - 172800000), }) console.log("[seed] blog: 3 posts created") return { ok: true } `; const KANBAN_SEED_BODY = ` - const { findOrCreateKanbanBoard, getKanbanColumnsByBoardId, createKanbanTask } = await import("@btst/stack/plugins/kanban/api") - const adapter = myStack.adapter - const board = await findOrCreateKanbanBoard(adapter, "demo-board", "BTST Demo Board", ["To Do", "In Progress", "In Review", "Done"]) - const columns = await getKanbanColumnsByBoardId(adapter, board.id) - if (!columns || columns.length === 0) return { ok: true, skipped: true } + const kanban = myStack.trusted.kanban + const existingBoards = await kanban.listBoards({ slug: "demo-board", limit: 1 }) + const board = existingBoards.items[0] + ? await kanban.getBoard({ id: existingBoards.items[0].id }) + : await kanban.createBoard({ slug: "demo-board", name: "BTST Demo Board" }) + const columns = [...board.columns] const todoCol = columns.find((c) => c.title === "To Do") const inProgressCol = columns.find((c) => c.title === "In Progress") const doneCol = columns.find((c) => c.title === "Done") if (!todoCol || !inProgressCol || !doneCol) return { ok: true, skipped: true } - const existingTasks = await adapter.findMany({ model: "kanbanTask", where: [{ field: "columnId", value: todoCol.id, operator: "eq" }], limit: 1 }) - if (existingTasks.length > 0) return { ok: true, skipped: true } - await createKanbanTask(adapter, { title: "Set up the BTST stack", columnId: doneCol.id, description: "Install @btst/stack and configure the adapter", priority: "HIGH" }) - await createKanbanTask(adapter, { title: "Add the Kanban plugin", columnId: doneCol.id, description: "Register kanbanBackendPlugin and kanbanClientPlugin", priority: "HIGH" }) - await createKanbanTask(adapter, { title: "Configure custom columns", columnId: inProgressCol.id, description: "Customize the board columns to fit the team workflow", priority: "MEDIUM" }) - await createKanbanTask(adapter, { title: "Invite team members", columnId: inProgressCol.id, description: "Add colleagues to the demo board", priority: "LOW" }) - await createKanbanTask(adapter, { title: "Connect to a real database", columnId: todoCol.id, description: "Replace the in-memory adapter with Prisma, Drizzle, or another supported ORM", priority: "MEDIUM" }) - await createKanbanTask(adapter, { title: "Add authentication", columnId: todoCol.id, description: "Protect the kanban routes with your auth solution", priority: "HIGH" }) - await createKanbanTask(adapter, { title: "Deploy to production", columnId: todoCol.id, description: "Deploy the app to Vercel, Fly.io, or your preferred hosting", priority: "URGENT" }) + let inReviewCol = columns.find((c) => c.title === "In Review") + if (!inReviewCol) { + inReviewCol = await kanban.createColumn({ title: "In Review", boardId: board.id }) + columns.push(inReviewCol) + } + const primaryColumnIds = new Set([todoCol.id, inProgressCol.id, inReviewCol.id, doneCol.id]) + await kanban.reorderColumns({ + boardId: board.id, + columnIds: [ + todoCol.id, + inProgressCol.id, + inReviewCol.id, + doneCol.id, + ...columns.filter((column) => !primaryColumnIds.has(column.id)).map((column) => column.id), + ], + }) + if (todoCol.tasks && todoCol.tasks.length > 0) return { ok: true, skipped: true } + await kanban.createTask({ title: "Set up the BTST stack", columnId: doneCol.id, description: "Install @btst/stack and configure the adapter", priority: "HIGH" }) + await kanban.createTask({ title: "Add the Kanban plugin", columnId: doneCol.id, description: "Register kanbanBackendPlugin and kanbanClientPlugin", priority: "HIGH" }) + await kanban.createTask({ title: "Configure custom columns", columnId: inProgressCol.id, description: "Customize the board columns to fit the team workflow", priority: "MEDIUM" }) + await kanban.createTask({ title: "Invite team members", columnId: inProgressCol.id, description: "Add colleagues to the demo board", priority: "LOW" }) + await kanban.createTask({ title: "Connect to a real database", columnId: todoCol.id, description: "Replace the in-memory adapter with Prisma, Drizzle, or another supported ORM", priority: "MEDIUM" }) + await kanban.createTask({ title: "Add authentication", columnId: todoCol.id, description: "Protect the kanban routes with your auth solution", priority: "HIGH" }) + await kanban.createTask({ title: "Deploy to production", columnId: todoCol.id, description: "Deploy the app to Vercel, Fly.io, or your preferred hosting", priority: "URGENT" }) console.log("[seed] kanban: 1 board, 4 columns, 7 tasks created") return { ok: true } `; const FORM_BUILDER_SEED_BODY = ` - const adapter = myStack.adapter - const existing = await adapter.findMany({ model: "form", limit: 1 }) - if (existing.length > 0) return { ok: true, skipped: true } + const formBuilder = myStack.trusted.formBuilder + const existing = await formBuilder.listForms({ limit: 1 }) + if (existing.items.length > 0) return { ok: true, skipped: true } const contactFormSchema = JSON.stringify({ type: "object", properties: { @@ -142,9 +143,8 @@ const FORM_BUILDER_SEED_BODY = ` }, required: ["rating", "category"], }) - const now = new Date() - await adapter.create({ model: "form", data: { name: "Contact Us", slug: "contact-us", description: "A simple contact form for getting in touch.", schema: contactFormSchema, successMessage: "Thanks for reaching out! We'll get back to you soon.", status: "active", createdAt: now, updatedAt: now } }) - await adapter.create({ model: "form", data: { name: "Feedback Form", slug: "feedback", description: "Share your feedback about our product and services.", schema: feedbackFormSchema, successMessage: "Thank you for your feedback!", status: "active", createdAt: new Date(now.getTime() - 86400000), updatedAt: new Date(now.getTime() - 86400000) } }) + await formBuilder.createForm({ name: "Contact Us", slug: "contact-us", description: "A simple contact form for getting in touch.", schema: contactFormSchema, successMessage: "Thanks for reaching out! We'll get back to you soon.", status: "active" }) + await formBuilder.createForm({ name: "Feedback Form", slug: "feedback", description: "Share your feedback about our product and services.", schema: feedbackFormSchema, successMessage: "Thank you for your feedback!", status: "active" }) console.log("[seed] form-builder: 2 forms created") return { ok: true } `; diff --git a/packages/stack/build.config.ts b/packages/stack/build.config.ts index a077fa092..353838298 100644 --- a/packages/stack/build.config.ts +++ b/packages/stack/build.config.ts @@ -72,6 +72,7 @@ export default defineBuildConfig({ "./src/index.ts", "./src/api/index.ts", "./src/client/index.ts", + "./src/client/server.ts", "./src/client/hooks/index.ts", "./src/context/index.ts", "./src/authorization/index.ts", diff --git a/packages/stack/knip.json b/packages/stack/knip.json index 3a6ce0355..ecee7c351 100644 --- a/packages/stack/knip.json +++ b/packages/stack/knip.json @@ -4,6 +4,7 @@ "src/index.ts", "src/api/index.ts", "src/client/index.ts", + "src/client/server.ts", "src/client/hooks/index.ts", "src/context/index.ts", "src/authorization/index.ts", diff --git a/packages/stack/package.json b/packages/stack/package.json index f2f7e8c7a..21d0f1ae6 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -67,6 +67,16 @@ "default": "./dist/client/index.cjs" } }, + "./client/server": { + "import": { + "types": "./dist/client/server.d.ts", + "default": "./dist/client/server.mjs" + }, + "require": { + "types": "./dist/client/server.d.cts", + "default": "./dist/client/server.cjs" + } + }, "./client/hooks": { "import": { "types": "./dist/client/hooks/index.d.ts", @@ -802,6 +812,9 @@ "client": [ "./dist/client/index.d.ts" ], + "client/server": [ + "./dist/client/server.d.ts" + ], "client/hooks": [ "./dist/client/hooks/index.d.ts" ], diff --git a/packages/stack/registry/btst-route-docs.json b/packages/stack/registry/btst-route-docs.json new file mode 100644 index 000000000..9af1cf80e --- /dev/null +++ b/packages/stack/registry/btst-route-docs.json @@ -0,0 +1,30 @@ +{ + "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", + "table" + ], + "files": [ + { + "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..018b21d43 100644 --- a/packages/stack/registry/registry.json +++ b/packages/stack/registry/registry.json @@ -239,6 +239,28 @@ "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", + "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..90e9a857a 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,23 @@ 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", + "components/loading/docs-skeleton.tsx", + "components/loading/index.tsx", + ], + }, ]; // --------------------------------------------------------------------------- @@ -527,6 +546,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 +1174,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 +1202,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/fixtures/registry/README.md b/packages/stack/scripts/fixtures/registry/README.md new file mode 100644 index 000000000..ded1d1c0c --- /dev/null +++ b/packages/stack/scripts/fixtures/registry/README.md @@ -0,0 +1,15 @@ +# Registry integration fixtures + +These snapshots keep the required UI Builder-over-CMS registry install/build +test deterministic. Production registry items continue to reference their +upstream registries; `test-registry.sh` rewrites only its temporary served +copies to these checked-in artifacts. + +| Fixture | Upstream revision | +| --- | --- | +| `ui-builder.json` | `olliethedev/ui-builder@bc1ceda306935bba87c2760fd213725f9133013b` | +| `auto-form.json` | `better-stack-ai/form-builder@5e0f42e47ff4a87c4c32e20721bfeda955edbef6` | +| `minimal-tiptap.json` | `olliethedev/shadcn-minimal-tiptap@456908f0be88a38a6ab31518a22405d5d269739f` | + +Refresh a snapshot only when intentionally adopting an upstream registry +revision, then rerun the full registry install/build validation. diff --git a/packages/stack/scripts/fixtures/registry/auto-form.json b/packages/stack/scripts/fixtures/registry/auto-form.json new file mode 100644 index 000000000..8210fbaf2 --- /dev/null +++ b/packages/stack/scripts/fixtures/registry/auto-form.json @@ -0,0 +1,136 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "auto-form", + "title": "Auto Form", + "description": "Automatically generate forms from Zod schemas with full TypeScript support.", + "dependencies": [ + "zod", + "react-hook-form", + "@hookform/resolvers" + ], + "registryDependencies": [ + "button", + "form", + "input", + "checkbox", + "label", + "select", + "radio-group", + "switch", + "textarea", + "accordion", + "calendar", + "popover" + ], + "files": [ + { + "path": "components/ui/auto-form/common/label.tsx", + "content": "import { FormLabel } from \"@/components/ui/form\";\nimport { cn } from \"@/lib/utils\";\n\nfunction AutoFormLabel({\n label,\n isRequired,\n className,\n}: {\n label: string;\n isRequired: boolean;\n className?: string;\n}) {\n return (\n <>\n \n {label}\n {isRequired && *}\n \n \n );\n}\n\nexport default AutoFormLabel;\n", + "type": "registry:component", + "target": "components/ui/auto-form/common/label.tsx" + }, + { + "path": "components/ui/auto-form/common/tooltip.tsx", + "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nfunction AutoFormTooltip({ fieldConfigItem }: { fieldConfigItem: any }) {\n return (\n <>\n {fieldConfigItem?.description && (\n

\n {fieldConfigItem.description}\n

\n )}\n \n );\n}\n\nexport default AutoFormTooltip;\n", + "type": "registry:component", + "target": "components/ui/auto-form/common/tooltip.tsx" + }, + { + "path": "components/ui/auto-form/config.ts", + "content": "import AutoFormCheckbox from \"./fields/checkbox\";\nimport AutoFormDate from \"./fields/date\";\nimport AutoFormEnum from \"./fields/enum\";\nimport AutoFormInput from \"./fields/input\";\nimport AutoFormNumber from \"./fields/number\";\nimport AutoFormRadioGroup from \"./fields/radio-group\";\nimport AutoFormSwitch from \"./fields/switch\";\nimport AutoFormTextarea from \"./fields/textarea\";\n\nexport const INPUT_COMPONENTS = {\n checkbox: AutoFormCheckbox,\n date: AutoFormDate,\n select: AutoFormEnum,\n radio: AutoFormRadioGroup,\n switch: AutoFormSwitch,\n textarea: AutoFormTextarea,\n number: AutoFormNumber,\n fallback: AutoFormInput,\n};\n\n/**\n * Define handlers for specific Zod types.\n * You can expand this object to support more types.\n * \n * Supports both:\n * - Zod v3 style: \"ZodBoolean\", \"ZodEnum\", etc. (from _def.typeName)\n * - Zod v4 style: \"boolean\", \"enum\", etc. (from _def.type)\n */\nexport const DEFAULT_ZOD_HANDLERS: {\n [key: string]: keyof typeof INPUT_COMPONENTS;\n} = {\n // Zod v3 style type names\n ZodBoolean: \"checkbox\",\n ZodDate: \"date\",\n ZodEnum: \"select\",\n ZodNativeEnum: \"select\",\n ZodNumber: \"number\",\n // Zod v4 style type names (lowercase, no \"Zod\" prefix)\n boolean: \"checkbox\",\n date: \"date\",\n enum: \"select\",\n nativeEnum: \"select\",\n number: \"number\",\n int: \"number\",\n float: \"number\",\n};\n", + "type": "registry:component", + "target": "components/ui/auto-form/config.ts" + }, + { + "path": "components/ui/auto-form/dependencies.ts", + "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport type { FieldValues, UseFormWatch } from \"react-hook-form\";\nimport type { Dependency, EnumValues } from \"./types\";\nimport { DependencyType } from \"./types\";\nimport * as z from \"zod\";\n\nexport default function resolveDependencies<\n SchemaType extends z.infer>,\n>(\n dependencies: Dependency[],\n currentFieldName: keyof SchemaType,\n watch: UseFormWatch,\n) {\n let isDisabled = false;\n let isHidden = false;\n let isRequired = false;\n let overrideOptions: EnumValues | undefined;\n\n const currentFieldValue = watch(currentFieldName as string);\n\n const currentFieldDependencies = dependencies.filter(\n (dependency) => dependency.targetField === currentFieldName,\n );\n for (const dependency of currentFieldDependencies) {\n const watchedValue = watch(dependency.sourceField as string);\n\n const conditionMet = dependency.when(watchedValue, currentFieldValue);\n\n switch (dependency.type) {\n case DependencyType.DISABLES:\n if (conditionMet) {\n isDisabled = true;\n }\n break;\n case DependencyType.REQUIRES:\n if (conditionMet) {\n isRequired = true;\n }\n break;\n case DependencyType.HIDES:\n if (conditionMet) {\n isHidden = true;\n }\n break;\n case DependencyType.SETS_OPTIONS:\n if (conditionMet) {\n overrideOptions = dependency.options;\n }\n break;\n }\n }\n\n return {\n isDisabled,\n isHidden,\n isRequired,\n overrideOptions,\n };\n}\n", + "type": "registry:component", + "target": "components/ui/auto-form/dependencies.ts" + }, + { + "path": "components/ui/auto-form/fields/array.tsx", + "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport {\n AccordionContent,\n AccordionItem,\n AccordionTrigger,\n} from \"@/components/ui/accordion\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n FormControl,\n FormField,\n FormItem,\n FormLabel,\n FormMessage,\n} from \"@/components/ui/form\";\nimport { Input } from \"@/components/ui/input\";\nimport { Separator } from \"@/components/ui/separator\";\nimport { Plus, Trash } from \"lucide-react\";\nimport { useFieldArray, useForm, useWatch } from \"react-hook-form\";\nimport * as z from \"zod\";\nimport { beautifyObjectName, getBaseSchema, getBaseType } from \"../helpers\";\nimport AutoFormObject from \"./object\";\n\nfunction getDefType(schema: z.ZodType): string {\n return (schema as any)._zod?.def?.type || \"\";\n}\n\nfunction getArrayElementType(item: z.ZodType): z.ZodType | null {\n const def = (item as any)._zod?.def;\n const defType = getDefType(item);\n\n if (defType === \"array\") {\n return def?.element || null;\n }\n\n if ([\"default\", \"optional\", \"nullable\"].includes(defType)) {\n const innerType = def?.innerType;\n if (innerType) {\n return getArrayElementType(innerType);\n }\n }\n\n return null;\n}\n\nfunction getPrimitiveDefault(itemSchema: z.ZodType): unknown {\n const base = getBaseSchema(itemSchema as z.ZodType);\n if (!base) return \"\";\n switch (getBaseType(base)) {\n case \"ZodBoolean\":\n return false;\n case \"ZodNumber\":\n return 0;\n default:\n return \"\";\n }\n}\n\nexport default function AutoFormArray({\n name,\n item,\n form,\n path = [],\n fieldConfig,\n}: {\n name: string;\n item: z.ZodArray | z.ZodDefault;\n form: ReturnType;\n path?: string[];\n fieldConfig?: any;\n}) {\n const itemDefType = getArrayElementType(item);\n const elementBaseSchema = itemDefType\n ? getBaseSchema(itemDefType as z.ZodType)\n : null;\n const elementPrimitiveType = elementBaseSchema\n ? getBaseType(elementBaseSchema as z.ZodType)\n : \"\";\n const isObjectArray = !!itemDefType && elementPrimitiveType === \"ZodObject\";\n\n const title = fieldConfig?.label ?? beautifyObjectName(name);\n\n if (isObjectArray) {\n return (\n }\n title={title}\n />\n );\n }\n\n return (\n \n );\n}\n\n/**\n * ObjectAutoFormArray — uses useFieldArray (safe for object arrays because\n * react-hook-form only wraps objects; the `id` field it injects doesn't affect\n * validation since the schema doesn't include it).\n */\nfunction ObjectAutoFormArray({\n name,\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.ZodObject;\n title: string;\n}) {\n const fieldPath = path.join(\".\");\n const { fields, append, remove } = useFieldArray({\n control: form.control,\n name: fieldPath,\n });\n\n return (\n \n {title}\n \n {fields.map((_field, index) => {\n const key = _field.id;\n return (\n
\n \n
\n remove(index)}\n >\n \n \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