From ec2a7ffc3c7c27fc371eaf4b8066acbc81003240 Mon Sep 17 00:00:00 2001 From: olliethedev <5933733+olliethedev@users.noreply.github.com> Date: Sat, 29 Aug 2026 13:16:02 -0400 Subject: [PATCH 1/2] refactor(stack)!: remove legacy v3 seams --- docs/content/docs/auth.mdx | 2 +- docs/content/docs/plugins/cms.mdx | 3 +- docs/content/docs/plugins/form-builder.mdx | 8 +- docs/content/docs/plugins/kanban.mdx | 2 +- docs/v3-contract-audit.md | 42 ++++++ .../consumer-tests/client-runtime/index.ts | 18 +++ .../client-runtime/provider.tsx | 35 ++++- .../constructor-exports/index.cts | 42 ++---- .../constructor-exports/index.ts | 106 +++---------- packages/stack/registry/btst-ai-chat.json | 4 +- packages/stack/registry/btst-blog.json | 2 +- packages/stack/registry/btst-cms.json | 12 +- .../stack/registry/btst-form-builder.json | 2 +- packages/stack/registry/btst-ui-builder.json | 4 +- .../ai-chat-client-runtime.typecheck.tsx | 4 +- .../src/__tests__/auth-provider.test.tsx | 7 +- .../__tests__/authorization-client.test.tsx | 55 ++++--- .../stack/src/__tests__/authorization.test.ts | 18 +-- .../src/__tests__/authorization.typecheck.ts | 24 +-- .../src/__tests__/client-runtime.test.ts | 80 ++++++---- .../src/__tests__/client-runtime.typecheck.ts | 2 +- .../src/__tests__/client-stack-test-utils.ts | 36 +++++ .../src/__tests__/endpoint-inventory.test.ts | 78 +++++----- .../src/__tests__/i18n-provider.test.tsx | 7 +- .../src/__tests__/kanban-query-keys.test.ts | 8 +- .../src/__tests__/notify-provider.test.tsx | 5 +- .../src/__tests__/package-metadata.test.ts | 39 ++--- .../page-component-overrides.test.tsx | 7 +- .../__tests__/plugin-registration.test.tsx | 27 +--- .../plugin-registration.typecheck.tsx | 28 +--- packages/stack/src/__tests__/plugins.test.tsx | 114 ++++++++------ .../src/__tests__/resource-factory.test.tsx | 14 +- .../src/__tests__/resource-use-form.test.tsx | 4 +- .../__tests__/resource-use-select.test.tsx | 6 +- .../src/__tests__/router-overrides.test.tsx | 28 +++- packages/stack/src/__tests__/sitemap.test.ts | 60 +++++--- .../stack/src/__tests__/stack-api.test.ts | 18 +-- .../stack/src/__tests__/stack-auth.test.ts | 12 +- .../src/__tests__/stack-constructors.test.ts | 109 +++++++------- .../__tests__/stack-constructors.typecheck.ts | 89 +++-------- .../src/__tests__/use-list-state.test.tsx | 13 +- packages/stack/src/api/index.ts | 16 +- packages/stack/src/client/index.ts | 127 ++++------------ packages/stack/src/client/runtime.ts | 7 + packages/stack/src/context/provider.tsx | 63 ++------ packages/stack/src/next/router.tsx | 2 +- packages/stack/src/plugin-registration.ts | 22 +-- .../ai-chat/__tests__/authorization.test.ts | 6 +- .../ai-chat/__tests__/chat-hooks.test.tsx | 18 +-- .../chat-input-authorization.test.tsx | 10 +- .../ai-chat/__tests__/client-sweep.test.tsx | 7 +- .../client/components/chat-interface.tsx | 4 +- .../client/components/chat-sidebar.tsx | 4 +- packages/stack/src/plugins/api/index.ts | 30 +--- .../blog/__tests__/authorization.test.ts | 8 +- .../__tests__/client-runtime.typecheck.tsx | 8 +- .../blog/__tests__/client-sweep.test.tsx | 77 +++++++--- .../src/plugins/blog/client/overrides.ts | 2 +- packages/stack/src/plugins/client/index.ts | 59 +------- .../src/plugins/client/resource/hooks.tsx | 2 +- .../cms/__tests__/authorization.test.ts | 4 +- .../__tests__/client-runtime.typecheck.tsx | 8 +- .../cms/__tests__/client-sweep.test.tsx | 35 +++-- .../components/inverse-relations-panel.tsx | 4 +- .../cms/client/components/pages/404-page.tsx | 4 +- .../pages/content-editor-page.internal.tsx | 4 +- .../pages/content-list-page.internal.tsx | 4 +- .../pages/dashboard-page.internal.tsx | 4 +- .../stack/src/plugins/cms/client/overrides.ts | 2 +- .../comments/__tests__/authorization.test.ts | 4 +- .../comments/__tests__/client-sweep.test.tsx | 61 ++++---- .../__tests__/authorization.test.ts | 4 +- .../__tests__/client-sweep.test.tsx | 38 +++-- .../form-renderer-authorization.test.tsx | 10 +- .../plugins/form-builder/client/overrides.ts | 2 +- .../kanban/__tests__/authorization.test.ts | 4 +- .../kanban/__tests__/client-sweep.test.tsx | 8 +- .../__tests__/identity-partition.test.tsx | 31 +++- .../media/__tests__/authorization.test.ts | 4 +- .../media/__tests__/client-sweep.test.tsx | 22 +-- .../__tests__/identity-partition.test.tsx | 56 +++---- .../plugins/media/__tests__/plugin.test.ts | 4 +- .../__tests__/route-introspection.test.ts | 25 ++-- .../src/plugins/open-api/api/generator.ts | 7 +- .../stack/src/plugins/open-api/api/plugin.ts | 2 +- .../client/components/pages/docs-page.tsx | 2 +- .../src/plugins/route-docs/client/plugin.tsx | 5 +- .../stack/src/plugins/route-docs/generator.ts | 7 +- .../__tests__/client-sweep.test.tsx | 16 +- .../pages/page-builder-page.internal.tsx | 4 +- .../pages/page-list-page.internal.tsx | 4 +- packages/stack/src/react-router/router.tsx | 2 +- packages/stack/src/shared/entry-factories.ts | 6 +- packages/stack/src/tanstack/router.tsx | 2 +- packages/stack/src/types.ts | 139 ++++-------------- scripts/check-canonical-dx.mjs | 28 ++++ 96 files changed, 1054 insertions(+), 1158 deletions(-) create mode 100644 docs/v3-contract-audit.md create mode 100644 packages/stack/src/__tests__/client-stack-test-utils.ts diff --git a/docs/content/docs/auth.mdx b/docs/content/docs/auth.mdx index ce5a647c5..249db75ca 100644 --- a/docs/content/docs/auth.mdx +++ b/docs/content/docs/auth.mdx @@ -104,7 +104,7 @@ function UpdateControl({ post }) { ); } - + {children} ; ``` diff --git a/docs/content/docs/plugins/cms.mdx b/docs/content/docs/plugins/cms.mdx index 83e2ac51f..835d5eae1 100644 --- a/docs/content/docs/plugins/cms.mdx +++ b/docs/content/docs/plugins/cms.mdx @@ -751,9 +751,10 @@ const auth = createClientAuth({ }) {children} diff --git a/docs/content/docs/plugins/form-builder.mdx b/docs/content/docs/plugins/form-builder.mdx index 8425aee33..7b66a1aea 100644 --- a/docs/content/docs/plugins/form-builder.mdx +++ b/docs/content/docs/plugins/form-builder.mdx @@ -449,7 +449,11 @@ const clientAuth = createClientAuth({ loginPath: "/auth/sign-in", }) - + {children} ``` @@ -662,7 +666,7 @@ Creates the client plugin with routes and SSR loaders. | Property | Type | Required | Description | |----------|------|----------|-------------| | `fieldComponents` | `Record` | No | Custom field components | -| `localization` | `FormBuilderLocalization` | No | Custom labels | +| `localization` | `Partial` | No | Custom labels | | `showAttribution` | `boolean` | No | Show BTST attribution | | `onRouteRender` | `(route, context) => void` | No | Lifecycle hook | | `onRouteError` | `(route, error, context) => void` | No | Error hook | diff --git a/docs/content/docs/plugins/kanban.mdx b/docs/content/docs/plugins/kanban.mdx index 22eeb374e..21ec75fca 100644 --- a/docs/content/docs/plugins/kanban.mdx +++ b/docs/content/docs/plugins/kanban.mdx @@ -618,11 +618,11 @@ All built-in Kanban component copy is routed through the `i18n` provider on `Sta ```tsx i18next.t(key, { defaultValue, ...params }), }} - // ... /> ``` diff --git a/docs/v3-contract-audit.md b/docs/v3-contract-audit.md new file mode 100644 index 000000000..4e99991ad --- /dev/null +++ b/docs/v3-contract-audit.md @@ -0,0 +1,42 @@ +# v3 all-plugin contract audit + +This matrix is the completion record for issue #225. Programmatic IDs are +camelCase identifiers; kebab-case package exports and URL slugs remain +unchanged. Paths beginning with `src/` or `consumer-tests/` are relative to +`packages/stack/`. + +| Surface | Final contract | Focused evidence | +| --- | --- | --- | +| AI Chat (`aiChat`) | Options-object backend preserves model and tools; the runtime-independent client factory preserves public/authenticated mode and resolves all loader/browser transport from the client stack. | `src/plugins/ai-chat/__tests__/authorization.test.ts`, `client-sweep.test.tsx`, `chat-hooks.test.tsx`, and `src/__tests__/ai-chat-client-runtime.test.ts` | +| Blog (`blog`) | Options-object backend and nested lifecycle hooks remain; loaders, metadata, sitemap, hydration, queries, and mutations share the resolved runtime. | `src/plugins/blog/__tests__/authorization.test.ts`, `client-runtime.test.ts`, `client-runtime.browser.test.tsx`, and `client-sweep.test.tsx` | +| CMS (`cms`) | Content-type configuration remains factory-owned; request operations, trusted operations, raw helpers, client runtime, and provider behavior stay separated. | `src/plugins/cms/__tests__/authorization.test.ts`, `schema-roundtrip.test.ts`, `client-runtime.test.ts`, `client-runtime.browser.test.tsx`, and `client-sweep.test.tsx` | +| Comments (`comments`) | Flags, moderation, and user-resolution behavior remain; backend hooks use the nested grammar and client data uses the resolved runtime. | `src/plugins/comments/__tests__/authorization.test.ts`, `client-runtime.test.ts`, `client-runtime.browser.test.tsx`, and `client-sweep.test.tsx` | +| Form Builder (`formBuilder`) | Form/submission operation lifecycles, loaders, browser mutations, authorization gates, and provider services retain parity. | `src/plugins/form-builder/__tests__/authorization.test.ts`, `client-runtime.test.ts`, `client-runtime.browser.test.tsx`, `client-sweep.test.tsx`, and `form-renderer-authorization.test.tsx` | +| Kanban (`kanban`) | The options-object backend preserves user search/resolution and board/column/task lifecycles; protected query keys and browser runtime are identity-partitioned. | `src/plugins/kanban/__tests__/authorization.test.ts`, `client-runtime.test.ts`, `client-runtime.browser.test.tsx`, `identity-partition.test.tsx`, and `client-sweep.test.tsx` | +| Media (`media`) | Storage and tenant state stay server-side; upload lifecycle, cross-origin isolation, identity partitions, hydration, and browser mutation behavior use the resolved runtime without secret leakage. | `src/plugins/media/__tests__/authorization.test.ts`, `storage-adapters.test.ts`, `client-runtime.test.ts`, `client-runtime.browser.test.tsx`, `identity-partition.test.tsx`, and `client-sweep.test.tsx` | +| OpenAPI (`openApi`) | Intentionally backend-only; schema/reference routes use validated endpoint inventory and emit deterministic safe access metadata. | `src/plugins/open-api/__tests__/route-introspection.test.ts` and `src/__tests__/endpoint-inventory.test.ts` | +| Route Docs (`routeDocs`) | Intentionally client-only; the definition inherits site/query runtime and inspects resolved client routes by canonical ID. | `src/plugins/route-docs/client/plugin.tsx`, `src/plugins/route-docs/generator.ts`, and `src/__tests__/client-runtime.test.ts` | +| UI Builder (`uiBuilder`) | Intentionally client-only over CMS; component registration stays factory-owned while provider overrides are inferred from the registered definition. | `src/plugins/ui-builder/__tests__/client-sweep.test.tsx`, `src/plugins/ui-builder/client/plugin.tsx`, and `src/__tests__/plugin-registration.typecheck.tsx` | +| Third-party consumer | Public backend/client definitions require stable IDs, preserve route/operation inference, and reject mismatched IDs, overrides, configs, and auth catalogs without internal imports. | `consumer-tests/constructor-exports`, `src/__tests__/plugins.test.tsx`, `plugin-registration.test.tsx`, and `plugin-registration.typecheck.tsx` | + +## Cross-cutting contract evidence + +- `src/__tests__/stack-constructors.test.ts` and + `package-metadata.test.ts` prove that only `createBackendStack` and + `createClientStack` exist across source, declarations, ESM, and CJS. +- `src/__tests__/client-runtime.test.ts` covers one runtime, same-origin path + overrides, complete endpoint replacement, sensitive-header isolation, + provider projection, SSR/browser hydration, and rejected unqualified runtime + fields. +- `src/__tests__/plugin-registration.test.tsx` covers canonical IDs, mismatch and + duplicate failures, provider inference, and route diagnostics. +- `src/__tests__/authorization.test.ts`, `authorization.typecheck.ts`, and the + plugin authorization suites cover permissive omitted auth, authoritative + enabled auth, trusted fact derivation, `trusted`/`raw` boundaries, immutable + results, and lifecycle behavior. +- `src/__tests__/initial-identity-hydration.test.tsx` and + `initial-identity-layouts.test.tsx` cover tri-state request hydration across + Next.js, React Router, and TanStack helpers. +- `scripts/check-canonical-dx.mjs`, package consumer compiles, strict package + metadata tests, registry generation/install tests, generated app builds, and + root Node 22 verification are the release gates for this matrix. diff --git a/packages/stack/consumer-tests/client-runtime/index.ts b/packages/stack/consumer-tests/client-runtime/index.ts index c8d87cdea..0c7fc3b4d 100644 --- a/packages/stack/consumer-tests/client-runtime/index.ts +++ b/packages/stack/consumer-tests/client-runtime/index.ts @@ -115,6 +115,24 @@ observations[0]?.id satisfies string | undefined; // @ts-expect-error Request headers do not exist on the top-level provider projection. clientStack.provider.api.headers; +createClientStack({ + // @ts-expect-error Shared origins must be qualified under api or site. + baseURL: "https://ignored.example.com", + api: { baseURL: "https://app.example.com", basePath: "/api/data" }, + site: { baseURL: "https://app.example.com", basePath: "/pages" }, + queryClient, + plugins: { consumerProbe: probeClientPlugin() }, +}); + +createClientStack({ + // @ts-expect-error Shared paths must be qualified under api or site. + basePath: "/ignored", + api: { baseURL: "https://app.example.com", basePath: "/api/data" }, + site: { baseURL: "https://app.example.com", basePath: "/pages" }, + queryClient, + plugins: { consumerProbe: probeClientPlugin() }, +}); + createClientStack({ api: { baseURL: "https://app.example.com", basePath: "/api/data" }, site: { baseURL: "https://app.example.com", basePath: "/pages" }, diff --git a/packages/stack/consumer-tests/client-runtime/provider.tsx b/packages/stack/consumer-tests/client-runtime/provider.tsx index c88fd4c53..d2e70ed66 100644 --- a/packages/stack/consumer-tests/client-runtime/provider.tsx +++ b/packages/stack/consumer-tests/client-runtime/provider.tsx @@ -8,14 +8,41 @@ import { browserClientStack } from "./index"; ; -// @ts-expect-error Override values are inferred from the registered plugin factory. +// @ts-expect-error Provider paths come from the resolved stack. +; + +// @ts-expect-error Provider API configuration comes from the resolved stack. +; + +const propsWithLegacyBasePath = { + stack: browserClientStack, + basePath: "/ignored", +}; +// @ts-expect-error Spread provider props cannot override resolved stack paths. +; + +const propsWithLegacyApi = { + stack: browserClientStack, + api: { basePath: "/ignored" }, +}; +// @ts-expect-error Spread provider props cannot override resolved stack API configuration. +; + ; -// @ts-expect-error Unknown plugins are not valid provider override keys. ; diff --git a/packages/stack/consumer-tests/constructor-exports/index.cts b/packages/stack/consumer-tests/constructor-exports/index.cts index ceb541b66..4e27a5faf 100644 --- a/packages/stack/consumer-tests/constructor-exports/index.cts +++ b/packages/stack/consumer-tests/constructor-exports/index.cts @@ -1,33 +1,19 @@ import api = require("@btst/stack/api"); import client = require("@btst/stack/client"); -type Equal = (() => T extends TLeft ? 1 : 2) extends < - T, ->() => T extends TRight ? 1 : 2 - ? true - : false; -type Expect = T; - -type _BackendFactoriesMatch = Expect< - Equal ->; -type _ClientFactoriesMatch = Expect< - Equal ->; -type _BackendConfigAliasMatches = Expect< - Equal ->; -type _BackendResultAliasMatches = Expect< - Equal ->; -type _ClientConfigAliasMatches = Expect< - Equal ->; -type _ClientResultAliasMatches = Expect< - Equal ->; - void api.createBackendStack; -void api.stack; void client.createClientStack; -void client.createStackClient; + +type BackendConfig = api.BackendStackConfig; +type BackendResult = api.BackendStack; +type ClientConfig = client.ClientStackConfig; +type ClientResult = client.ClientStack; + +declare const backendConfig: BackendConfig; +declare const clientConfig: ClientConfig; +declare const backendResult: BackendResult; +declare const clientResult: ClientResult; +void backendConfig; +void clientConfig; +void backendResult; +void clientResult; diff --git a/packages/stack/consumer-tests/constructor-exports/index.ts b/packages/stack/consumer-tests/constructor-exports/index.ts index ae44c0374..8767db13d 100644 --- a/packages/stack/consumer-tests/constructor-exports/index.ts +++ b/packages/stack/consumer-tests/constructor-exports/index.ts @@ -1,27 +1,14 @@ import type { DatabaseDefinition, DBAdapter } from "@btst/db"; +import { QueryClient } from "@tanstack/react-query"; import { z } from "zod"; -import { - createBackendStack, - stack, - type BackendLib, - type BackendLibConfig, - type BackendStack, - type BackendStackConfig, -} from "@btst/stack/api"; +import { createBackendStack, type BackendStackConfig } from "@btst/stack/api"; import { defineAuthorization, definePermissions, permission, } from "@btst/stack/authorization"; import { createServerAuth } from "@btst/stack/authorization/server"; -import { - createClientStack, - createStackClient, - type ClientLib, - type ClientLibConfig, - type ClientStack, - type ClientStackConfig, -} from "@btst/stack/client"; +import { createClientStack, type ClientStackConfig } from "@btst/stack/client"; import { createDbPlugin, createEndpoint, @@ -56,7 +43,7 @@ const read = defineOperation({ execute: ({ input }) => ({ id: input.id }), }); const backendPlugin = defineBackendPlugin({ - name: "consumerProbe", + id: "consumerProbe", dbPlugin: createDbPlugin("consumerProbe", {}), operations: () => ({ read }), routes: (_adapter, _context, operations) => ({ @@ -76,41 +63,14 @@ const backendConfig = { { consumerProbe: typeof backendPlugin }, typeof auth >; -const canonicalBackend = createBackendStack(backendConfig); -const temporaryBackend = stack(backendConfig); +const backend = createBackendStack(backendConfig); -type _BackendFactoriesMatch = Expect< - Equal ->; -type _BackendInferenceMatches = Expect< - Equal ->; type _BackendRouteInference = Expect< - Equal + Equal >; -type _BackendConfigAliasMatches = Expect< - Equal< - BackendStackConfig<{ consumerProbe: typeof backendPlugin }, typeof auth>, - BackendLibConfig<{ consumerProbe: typeof backendPlugin }, typeof auth> - > ->; -type _BackendResultAliasMatches = Expect< - Equal< - BackendStack< - ReturnType, - Record, - typeof canonicalBackend.trusted - >, - BackendLib< - ReturnType, - Record, - typeof canonicalBackend.trusted - > - > ->; -canonicalBackend.router.endpoints.consumerProbe_read; -canonicalBackend.trusted.consumerProbe.read({ id: "record-1" }); -canonicalBackend +backend.router.endpoints.consumerProbe_read; +backend.trusted.consumerProbe.read({ id: "record-1" }); +backend .forRequest(new Request("https://example.com/api")) .operations.consumerProbe.read({ id: "record-1" }); @@ -127,46 +87,26 @@ const incompatibleAuth = createServerAuth({ }); createBackendStack({ ...backendConfig, - // @ts-expect-error public canonical constructor rejects incompatible catalogs - auth: incompatibleAuth, -}); -stack({ - ...backendConfig, - // @ts-expect-error public temporary constructor preserves catalog inference + // @ts-expect-error the public constructor rejects incompatible catalogs auth: incompatibleAuth, }); const clientPlugin = defineClientPlugin({ - name: "consumerProbe", - routes: () => ({ - read: createRoute("/consumer-probe/:id", ({ params }) => ({ - PageComponent: () => null, - loader: async () => ({ recordId: params.id }), - })), + id: "consumerProbe", + resolve: () => ({ + routes: () => ({ + read: createRoute("/consumer-probe/:id", ({ params }) => ({ + PageComponent: () => null, + loader: async () => ({ recordId: params.id }), + })), + }), }), }); const clientConfig = { + api: { baseURL: "https://example.com", basePath: "/api" }, + site: { baseURL: "https://example.com", basePath: "/pages" }, + queryClient: new QueryClient(), plugins: { consumerProbe: clientPlugin }, } satisfies ClientStackConfig<{ consumerProbe: typeof clientPlugin }>; -const canonicalClient = createClientStack(clientConfig); -const temporaryClient = createStackClient(clientConfig); - -type _ClientFactoriesMatch = Expect< - Equal ->; -type _ClientInferenceMatches = Expect< - Equal ->; -type _ClientConfigAliasMatches = Expect< - Equal< - ClientStackConfig<{ consumerProbe: typeof clientPlugin }>, - ClientLibConfig<{ consumerProbe: typeof clientPlugin }> - > ->; -type _ClientResultAliasMatches = Expect< - Equal< - ClientStack>, - ClientLib> - > ->; -canonicalClient.router.getRoute("/consumer-probe/record-1")?.loader?.(); +const client = createClientStack(clientConfig); +client.router.getRoute("/consumer-probe/record-1")?.loader?.(); diff --git a/packages/stack/registry/btst-ai-chat.json b/packages/stack/registry/btst-ai-chat.json index 104adf1d8..16463d564 100644 --- a/packages/stack/registry/btst-ai-chat.json +++ b/packages/stack/registry/btst-ai-chat.json @@ -56,7 +56,7 @@ { "path": "btst/ai-chat/client/components/chat-interface.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { useChat } from \"@ai-sdk/react\";\nimport {\n\tuseEffect,\n\tuseRef,\n\tuseState,\n\tuseMemo,\n\tuseCallback,\n\tuseLayoutEffect,\n\tuseId,\n} from \"react\";\nimport { hashKey, useQueryClient } from \"@tanstack/react-query\";\nimport { ChatMessage } from \"./chat-message\";\nimport { ChatInput, type AttachedFile } from \"./chat-input\";\nimport { StackAttribution } from \"@/components/ui/stack-attribution\";\nimport { ScrollArea } from \"@/components/ui/scroll-area\";\nimport {\n\tDefaultChatTransport,\n\tlastAssistantMessageIsCompleteWithToolCalls,\n\ttype UIMessage,\n} from \"ai\";\nimport { cn } from \"@/lib/utils\";\nimport {\n\tPermissionCheck,\n\tusePluginOverrides,\n\tuseStack,\n} from \"@btst/stack/context\";\nimport { aiChatPermissions } from \"../../permissions\";\nimport {\n\tresolveAiChatApiUrl,\n\tresolveAiChatMode,\n\tresolveAiChatSiteLocation,\n\ttype AiChatPluginOverrides,\n} from \"../overrides\";\nimport { useAiChatTranslation } from \"../localization\";\nimport { createApiClient } from \"@btst/stack/plugins/client\";\nimport type { AiChatApiRouter } from \"@btst/stack/plugins/ai-chat/api\";\nimport { aiChatIdentityKey, createAiChatQueryKeys } from \"@btst/stack/plugins/ai-chat/api\";\nimport {\n\tuseConversation,\n\tuseConversations,\n\tuseAiChatIdentityPartition,\n\ttype SerializedMessage,\n} from \"@btst/stack/plugins/ai-chat/client/hooks\";\nimport { usePageAIContext } from \"../context/page-ai-context\";\nimport { navigateAiChatCrossOrigin } from \"../navigation\";\n\ninterface ChatInterfaceProps {\n\tinitialMessages?: UIMessage[];\n\tid?: string;\n\t/** Variant: 'full' for full-page layout, 'widget' for embedded widget */\n\tvariant?: \"full\" | \"widget\";\n\tclassName?: string;\n\t/** Called whenever messages change (for persistence). Only fires in public mode. */\n\tonMessagesChange?: (messages: UIMessage[]) => void;\n}\n\ntype ChatAction = \"send\" | \"edit\" | \"retry\";\n\nfunction reconcilePersistedMessageIds(\n\tmessages: UIMessage[],\n\tpersistedMessages: SerializedMessage[],\n): UIMessage[] | null {\n\tconst userMessages = messages.filter((message) => message.role === \"user\");\n\tconst persistedUserMessages = persistedMessages.filter(\n\t\t(message) => message.role === \"user\",\n\t);\n\tif (persistedUserMessages.length !== userMessages.length) return null;\n\tconst persistedIds = new Map();\n\tfor (let index = 0; index < userMessages.length; index++) {\n\t\tconst message = userMessages[index];\n\t\tconst persisted = persistedUserMessages[index];\n\t\tif (!message || !persisted) return null;\n\t\tconst serializedParts = JSON.stringify(message.parts);\n\t\tconst legacyText =\n\t\t\tmessage.parts.length === 1 && message.parts[0]?.type === \"text\"\n\t\t\t\t? message.parts[0].text\n\t\t\t\t: undefined;\n\t\tif (\n\t\t\tpersisted.content !== serializedParts &&\n\t\t\tpersisted.content !== legacyText\n\t\t) {\n\t\t\treturn null;\n\t\t}\n\t\tpersistedIds.set(message, persisted.id);\n\t}\n\treturn messages.map((message) => ({\n\t\t...message,\n\t\tid: persistedIds.get(message) ?? message.id,\n\t}));\n}\n\nfunction persistedMessagesToUiMessages(\n\tmessages: readonly SerializedMessage[],\n): UIMessage[] {\n\treturn messages\n\t\t.filter((message) => message.role !== \"data\")\n\t\t.map((message) => {\n\t\t\tlet parts: UIMessage[\"parts\"];\n\t\t\ttry {\n\t\t\t\tconst parsed = JSON.parse(message.content);\n\t\t\t\tparts = Array.isArray(parsed)\n\t\t\t\t\t? parsed\n\t\t\t\t\t: [{ type: \"text\" as const, text: message.content }];\n\t\t\t} catch {\n\t\t\t\tparts = [{ type: \"text\" as const, text: message.content }];\n\t\t\t}\n\t\t\treturn {\n\t\t\t\tid: message.id,\n\t\t\t\trole: message.role as \"user\" | \"assistant\" | \"system\",\n\t\t\t\tparts,\n\t\t\t};\n\t\t});\n}\n\nfunction ChatActionCheck({\n\tpublicMode,\n\taction,\n\tconversationId,\n\townerId,\n\tmessageId,\n\ttoolNames = [],\n\trouteName,\n\tchildren,\n}: {\n\tpublicMode: boolean;\n\taction: ChatAction;\n\tconversationId?: string;\n\townerId?: string;\n\tmessageId?: string;\n\ttoolNames?: readonly string[];\n\trouteName?: string;\n\tchildren: (allowed: boolean) => React.ReactNode;\n}) {\n\tif (publicMode) return <>{children(true)};\n\tconst base = {\n\t\t...(conversationId ? { conversationId } : {}),\n\t\t...(ownerId ? { ownerId } : {}),\n\t};\n\tconst streamPermission = aiChatPermissions.stream.start({\n\t\t...base,\n\t\tcreatesConversation: !conversationId,\n\t\tintent: action,\n\t});\n\tconst messagePermission =\n\t\taction === \"send\"\n\t\t\t? aiChatPermissions.message.send({\n\t\t\t\t\t...base,\n\t\t\t\t\tcreatesConversation: !conversationId,\n\t\t\t\t})\n\t\t\t: action === \"edit\" && conversationId && messageId\n\t\t\t\t? aiChatPermissions.message.edit({\n\t\t\t\t\t\tconversationId,\n\t\t\t\t\t\t...(ownerId ? { ownerId } : {}),\n\t\t\t\t\t\tmessageId,\n\t\t\t\t\t})\n\t\t\t\t: action === \"retry\" && conversationId && messageId\n\t\t\t\t\t? aiChatPermissions.message.retry({\n\t\t\t\t\t\t\tconversationId,\n\t\t\t\t\t\t\t...(ownerId ? { ownerId } : {}),\n\t\t\t\t\t\t\tmessageId,\n\t\t\t\t\t\t})\n\t\t\t\t\t: null;\n\tif (!messagePermission) return <>{children(false)};\n\n\treturn (\n\t\t\n\t\t\t{(streamState) => (\n\t\t\t\t\n\t\t\t\t\t{(messageState) => {\n\t\t\t\t\t\tconst baseAllowed =\n\t\t\t\t\t\t\tstreamState.can &&\n\t\t\t\t\t\t\t!streamState.isPending &&\n\t\t\t\t\t\t\t!streamState.error &&\n\t\t\t\t\t\t\tmessageState.can &&\n\t\t\t\t\t\t\t!messageState.isPending &&\n\t\t\t\t\t\t\t!messageState.error;\n\t\t\t\t\t\tconst renderToolCheck = (allowed: boolean) => {\n\t\t\t\t\t\t\tif (!allowed || toolNames.length === 0) return children(allowed);\n\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{(toolState) =>\n\t\t\t\t\t\t\t\t\t\tchildren(\n\t\t\t\t\t\t\t\t\t\t\ttoolState.can && !toolState.isPending && !toolState.error,\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\tif (action !== \"send\" || conversationId) {\n\t\t\t\t\t\t\treturn renderToolCheck(Boolean(baseAllowed));\n\t\t\t\t\t\t}\n\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{(createState) =>\n\t\t\t\t\t\t\t\t\trenderToolCheck(\n\t\t\t\t\t\t\t\t\t\tBoolean(baseAllowed) &&\n\t\t\t\t\t\t\t\t\t\t\tcreateState.can &&\n\t\t\t\t\t\t\t\t\t\t\t!createState.isPending &&\n\t\t\t\t\t\t\t\t\t\t\t!createState.error,\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\nfunction PermissionedChatMessage({\n\tpublicMode,\n\taction,\n\tconversationId,\n\townerId,\n\tmessageId,\n\t...messageProps\n}: React.ComponentProps & {\n\tpublicMode: boolean;\n\taction?: \"edit\" | \"retry\";\n\tconversationId?: string;\n\townerId?: string;\n\tmessageId?: string;\n}) {\n\tif (!action) return ;\n\treturn (\n\t\t\n\t\t\t{(allowed) => (\n\t\t\t\t\n\t\t\t)}\n\t\t\n\t);\n}\n\nexport function ChatInterface({\n\tinitialMessages,\n\tid,\n\tvariant = \"full\",\n\tclassName,\n\tonMessagesChange,\n}: ChatInterfaceProps) {\n\tconst {\n\t\tlocalization: customLocalization,\n\t\tshowAttribution,\n\t\tchatSuggestions,\n\t} = usePluginOverrides>(\n\t\t\"aiChat\",\n\t\t{ showAttribution: true },\n\t);\n\tconst {\n\t\tapi,\n\t\tbasePath: legacyBasePath,\n\t\tplugins,\n\t\tqueryClient: stackQueryClient,\n\t} = useStack();\n\tconst aiChatApi = plugins?.aiChat?.api;\n\tconst apiBaseURL = aiChatApi?.baseURL ?? api?.baseURL;\n\tconst apiBasePath = aiChatApi?.basePath ?? api?.basePath;\n\tconst browserHeaders = aiChatApi?.browserHeaders;\n\tconst credentials = aiChatApi?.credentials;\n\tconst resolvedApiPath = resolveAiChatApiUrl(apiBaseURL, apiBasePath);\n\tconst mode = resolveAiChatMode(plugins?.aiChat?.config);\n\tconst siteBaseURL = plugins?.aiChat?.site.baseURL;\n\tconst siteBasePath = plugins?.aiChat?.site.basePath ?? legacyBasePath;\n\tconst isPublicMode = mode === \"public\";\n\n\t// Read page AI context registered by the current page\n\tconst pageAIContext = usePageAIContext();\n\n\tconst tr = useAiChatTranslation(customLocalization);\n\tconst queryClient = useQueryClient(stackQueryClient);\n\tconst identityPartition = useAiChatIdentityPartition();\n\tconst chatInstanceId = useId();\n\tconst identityPartitionKey = hashKey([aiChatIdentityKey(identityPartition)]);\n\tconst latestIdentityPartitionKey = useRef(identityPartitionKey);\n\tconst identitySessionGeneration = useRef(0);\n\tconst [identitySessionVersion, setIdentitySessionVersion] = useState(0);\n\tconst activeStreamPartitionKey = useRef(undefined);\n\tconst nextStreamRequestGeneration = useRef(0);\n\tconst latestStreamRequestGeneration = useRef(0);\n\tconst pendingStreamRequests = useRef<\n\t\tArray<{ generation: number; identityPartitionKey: string }>\n\t>([]);\n\tconst latestMessages = useRef([]);\n\n\tconst conversationsListQueryKey = useMemo(() => {\n\t\t// In public mode, we don't need conversation queries\n\t\tif (isPublicMode) return [\"ai-chat\", \"disabled\"];\n\t\tconst client = createApiClient({\n\t\t\tbaseURL: apiBaseURL,\n\t\t\tbasePath: apiBasePath,\n\t\t\tcredentials,\n\t\t});\n\t\tconst queries = createAiChatQueryKeys(client, browserHeaders);\n\t\treturn queries.conversations.list(identityPartition).queryKey;\n\t}, [\n\t\tapiBaseURL,\n\t\tapiBasePath,\n\t\tbrowserHeaders,\n\t\tcredentials,\n\t\tidentityPartition,\n\t\tisPublicMode,\n\t]);\n\tconst latestIdentityPartition = useRef(identityPartition);\n\tconst latestConversationsListQueryKey = useRef(conversationsListQueryKey);\n\tconst latestHeaders = useRef(browserHeaders);\n\n\t// Track the current conversation ID - initialized from prop, updated after first message\n\t// In public mode, we don't track conversation IDs\n\tconst [currentConversationId, setCurrentConversationId] = useState<\n\t\tstring | undefined\n\t>(isPublicMode ? undefined : id);\n\t// Track if we've sent the first message on a new chat (to trigger navigation)\n\tconst isFirstMessageSentRef = useRef(false);\n\tconst hasNavigatedRef = useRef(false);\n\n\t// Update currentConversationId when id prop changes (e.g., navigating to different conversation)\n\tuseEffect(() => {\n\t\tif (!isPublicMode) {\n\t\t\tsetCurrentConversationId(id);\n\t\t\tisFirstMessageSentRef.current = false;\n\t\t\thasNavigatedRef.current = false;\n\t\t\t// Reset edit flag on navigation\n\t\t\tisEditInProgressRef.current = false;\n\t\t}\n\t}, [id, isPublicMode]);\n\n\t// Fetch existing conversation messages when id is provided (authenticated mode only)\n\tconst { conversation, isLoading: isLoadingConversation } = useConversation(\n\t\tid,\n\t\t{ enabled: !!id && !isPublicMode },\n\t);\n\n\t// Fetch conversations list for navigation after first message (authenticated mode only)\n\tconst { conversations } = useConversations({ enabled: !isPublicMode });\n\tconst currentConversationOwnerId =\n\t\tconversation?.userId ??\n\t\tconversations.find((item) => item.id === currentConversationId)?.userId ??\n\t\t(!isPublicMode &&\n\t\tcurrentConversationId &&\n\t\ttypeof identityPartition === \"object\"\n\t\t\t? identityPartition.id\n\t\t\t: undefined);\n\n\t// Use a ref to track the conversation ID for the transport body\n\t// This ensures the transport always uses the latest value\n\t// In public mode, always undefined\n\tconst conversationIdRef = useRef(\n\t\tisPublicMode ? undefined : id,\n\t);\n\tuseEffect(() => {\n\t\tif (!isPublicMode) {\n\t\t\tconversationIdRef.current = currentConversationId;\n\t\t}\n\t}, [currentConversationId, isPublicMode]);\n\n\t// Ref to track edit operation with messages to use\n\tconst editMessagesRef = useRef(null);\n\n\t// Flag to prevent load conversation effect from overwriting messages during edit\n\tconst isEditInProgressRef = useRef(false);\n\n\t// Track if we've finished initializing messages\n\t// This prevents onMessagesChange from firing with an empty array before initialMessages are loaded\n\t// Without this guard, the effect would fire on mount with [], overwriting any saved messages\n\tconst [isMessagesInitialized, setIsMessagesInitialized] = useState(\n\t\t() =>\n\t\t\t// Start as initialized if there are no initialMessages to load\n\t\t\t!initialMessages || initialMessages.length === 0,\n\t);\n\tconst [input, setInput] = useState(\"\");\n\tconst [attachedFiles, setAttachedFiles] = useState([]);\n\tconst [pendingEdit, setPendingEdit] = useState<{\n\t\ttext: string;\n\t\texpectedLength: number;\n\t\tidentityPartitionKey: string;\n\t\tstreamRequestGeneration: number;\n\t} | null>(null);\n\tconst [historySyncError, setHistorySyncError] = useState(null);\n\tconst [isHistorySyncRetrying, setIsHistorySyncRetrying] = useState(false);\n\tuseEffect(() => {\n\t\tsetHistorySyncError(null);\n\t\tsetIsHistorySyncRetrying(false);\n\t}, [id, isPublicMode]);\n\n\t// Ref to always have the latest pageAIContext in the transport callback\n\t// without recreating the transport on every context change\n\tconst pageAIContextRef = useRef(pageAIContext);\n\tuseEffect(() => {\n\t\tpageAIContextRef.current = pageAIContext;\n\t}, [pageAIContext]);\n\n\t// Memoize the transport to prevent recreation on every render\n\tconst transport = useMemo(() => {\n\t\tconst trackedFetch = Object.assign(\n\t\t\tasync (\n\t\t\t\trequest: Parameters[0],\n\t\t\t\tinit?: Parameters[1],\n\t\t\t) => {\n\t\t\t\tconst requestIdentityPartitionKey = latestIdentityPartitionKey.current;\n\t\t\t\tconst requestGeneration = latestStreamRequestGeneration.current;\n\t\t\t\tconst response = await globalThis.fetch(request, init);\n\t\t\t\tif (\n\t\t\t\t\t!isPublicMode &&\n\t\t\t\t\trequestIdentityPartitionKey === latestIdentityPartitionKey.current &&\n\t\t\t\t\trequestGeneration === latestStreamRequestGeneration.current\n\t\t\t\t) {\n\t\t\t\t\tconst conversationId = response.headers.get(\"x-conversation-id\");\n\t\t\t\t\tif (conversationId) conversationIdRef.current = conversationId;\n\t\t\t\t}\n\t\t\t\treturn response;\n\t\t\t},\n\t\t\tglobalThis.fetch,\n\t\t) as typeof globalThis.fetch;\n\t\treturn new DefaultChatTransport({\n\t\t\tapi: resolvedApiPath,\n\t\t\theaders: browserHeaders,\n\t\t\tcredentials,\n\t\t\tfetch: trackedFetch,\n\t\t\t// In public mode, don't send conversationId\n\t\t\tbody: isPublicMode\n\t\t\t\t? undefined\n\t\t\t\t: () => ({ conversationId: conversationIdRef.current }),\n\t\t\t// Handle edit operations and inject page context\n\t\t\tprepareSendMessagesRequest: ({ messages: hookMessages }) => {\n\t\t\t\tif (!isPublicMode) {\n\t\t\t\t\tconst generation = ++nextStreamRequestGeneration.current;\n\t\t\t\t\tlatestStreamRequestGeneration.current = generation;\n\t\t\t\t\tconst request = {\n\t\t\t\t\t\tgeneration,\n\t\t\t\t\t\tidentityPartitionKey: latestIdentityPartitionKey.current,\n\t\t\t\t\t};\n\t\t\t\t\tpendingStreamRequests.current.push(request);\n\t\t\t\t\tactiveStreamPartitionKey.current = request.identityPartitionKey;\n\t\t\t\t}\n\t\t\t\tconst currentPageContext = pageAIContextRef.current;\n\n\t\t\t\t// Build page context fields to include in every request\n\t\t\t\tconst pageContextBody = currentPageContext?.pageDescription\n\t\t\t\t\t? {\n\t\t\t\t\t\t\tpageContext: currentPageContext.pageDescription,\n\t\t\t\t\t\t\tavailableTools: Object.keys(currentPageContext.clientTools ?? {}),\n\t\t\t\t\t\t\trouteName: currentPageContext.routeName,\n\t\t\t\t\t\t}\n\t\t\t\t\t: {};\n\n\t\t\t\t// If we're in an edit operation, use the truncated messages + new user message\n\t\t\t\tif (editMessagesRef.current !== null) {\n\t\t\t\t\tconst newUserMessage = hookMessages[hookMessages.length - 1];\n\t\t\t\t\tconst messagesToSend = [...editMessagesRef.current];\n\t\t\t\t\tif (newUserMessage) {\n\t\t\t\t\t\tmessagesToSend.push(newUserMessage);\n\t\t\t\t\t}\n\t\t\t\t\t// Clear the ref after use\n\t\t\t\t\teditMessagesRef.current = null;\n\t\t\t\t\treturn {\n\t\t\t\t\t\tbody: {\n\t\t\t\t\t\t\tmessages: messagesToSend,\n\t\t\t\t\t\t\tconversationId: conversationIdRef.current,\n\t\t\t\t\t\t\t...pageContextBody,\n\t\t\t\t\t\t},\n\t\t\t\t\t};\n\t\t\t\t}\n\t\t\t\t// Normal case - use the messages as-is\n\t\t\t\treturn {\n\t\t\t\t\tbody: {\n\t\t\t\t\t\tmessages: hookMessages,\n\t\t\t\t\t\tconversationId: conversationIdRef.current,\n\t\t\t\t\t\t...pageContextBody,\n\t\t\t\t\t},\n\t\t\t\t};\n\t\t\t},\n\t\t});\n\t}, [browserHeaders, credentials, isPublicMode, resolvedApiPath]);\n\n\t// Use a ref so addToolOutput is always current inside the onToolCall closure\n\tconst addToolOutputRef = useRef<\n\t\tReturnType[\"addToolOutput\"] | null\n\t>(null);\n\n\tconst {\n\t\tmessages,\n\t\tsendMessage,\n\t\tstatus,\n\t\terror,\n\t\tsetMessages,\n\t\tregenerate,\n\t\taddToolOutput,\n\t\tstop,\n\t} = useChat({\n\t\tid: `${chatInstanceId}:${isPublicMode ? \"public\" : identitySessionVersion}`,\n\t\ttransport,\n\t\t// Automatically resubmit after all client-side tool results are provided\n\t\tsendAutomaticallyWhen: (options) =>\n\t\t\t(isPublicMode ||\n\t\t\t\tidentitySessionVersion === identitySessionGeneration.current) &&\n\t\t\tlastAssistantMessageIsCompleteWithToolCalls(options),\n\t\tonToolCall: async ({ toolCall }) => {\n\t\t\tconst toolRequest = pendingStreamRequests.current.at(-1);\n\t\t\tif (\n\t\t\t\t!isPublicMode &&\n\t\t\t\t(identitySessionVersion !== identitySessionGeneration.current ||\n\t\t\t\t\t!toolRequest ||\n\t\t\t\t\ttoolRequest.identityPartitionKey !==\n\t\t\t\t\t\tlatestIdentityPartitionKey.current ||\n\t\t\t\t\ttoolRequest.generation !== latestStreamRequestGeneration.current ||\n\t\t\t\t\tactiveStreamPartitionKey.current !== toolRequest.identityPartitionKey)\n\t\t\t) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tconst toolIdentityPartitionKey =\n\t\t\t\ttoolRequest?.identityPartitionKey ?? latestIdentityPartitionKey.current;\n\t\t\tconst toolStreamRequestGeneration =\n\t\t\t\ttoolRequest?.generation ?? latestStreamRequestGeneration.current;\n\t\t\tconst toolRequestIsCurrent = () =>\n\t\t\t\tisPublicMode ||\n\t\t\t\t(identitySessionVersion === identitySessionGeneration.current &&\n\t\t\t\t\ttoolIdentityPartitionKey === latestIdentityPartitionKey.current &&\n\t\t\t\t\ttoolStreamRequestGeneration ===\n\t\t\t\t\t\tlatestStreamRequestGeneration.current);\n\t\t\t// Dispatch client-side tool calls to the handler registered by the current page.\n\t\t\t// In AI SDK v5, onToolCall returns void — addToolOutput must be called explicitly.\n\t\t\tconst toolName = toolCall.toolName;\n\t\t\tconst handler = pageAIContextRef.current?.clientTools?.[toolName];\n\t\t\tif (handler) {\n\t\t\t\ttry {\n\t\t\t\t\tconst result = await handler(toolCall.input);\n\t\t\t\t\tif (!toolRequestIsCurrent()) return;\n\t\t\t\t\t// No await — avoids potential deadlocks with sendAutomaticallyWhen\n\t\t\t\t\taddToolOutputRef.current?.({\n\t\t\t\t\t\ttool: toolName,\n\t\t\t\t\t\ttoolCallId: toolCall.toolCallId,\n\t\t\t\t\t\toutput: result,\n\t\t\t\t\t});\n\t\t\t\t} catch (err) {\n\t\t\t\t\tif (!toolRequestIsCurrent()) return;\n\t\t\t\t\taddToolOutputRef.current?.({\n\t\t\t\t\t\ttool: toolName,\n\t\t\t\t\t\ttoolCallId: toolCall.toolCallId,\n\t\t\t\t\t\tstate: \"output-error\",\n\t\t\t\t\t\terrorText:\n\t\t\t\t\t\t\terr instanceof Error\n\t\t\t\t\t\t\t\t? err.message\n\t\t\t\t\t\t\t\t: tr(\n\t\t\t\t\t\t\t\t\t\t\"TOOL_EXECUTION_FAILED\",\n\t\t\t\t\t\t\t\t\t\t\"aiChat.tools.executionFailed\",\n\t\t\t\t\t\t\t\t\t\t\"Tool execution failed\",\n\t\t\t\t\t\t\t\t\t),\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// No handler found — this happens when the user navigates away while a\n\t\t\t\t// tool-call response is streaming and the page context changes. Always\n\t\t\t\t// call addToolOutput so sendAutomaticallyWhen can unblock; without this\n\t\t\t\t// the conversation gets permanently stuck waiting for a missing output.\n\t\t\t\taddToolOutputRef.current?.({\n\t\t\t\t\ttool: toolName,\n\t\t\t\t\ttoolCallId: toolCall.toolCallId,\n\t\t\t\t\tstate: \"output-error\",\n\t\t\t\t\terrorText: tr(\n\t\t\t\t\t\t\"TOOL_HANDLER_MISSING\",\n\t\t\t\t\t\t\"aiChat.tools.handlerMissing\",\n\t\t\t\t\t\t'No client-side handler registered for tool \"{{toolName}}\". The page context may have changed while the response was streaming.',\n\t\t\t\t\t\t{ toolName },\n\t\t\t\t\t),\n\t\t\t\t});\n\t\t\t}\n\t\t},\n\t\tonError: (err) => {\n\t\t\tconsole.error(\"useChat onError:\", err);\n\t\t\tif (\n\t\t\t\t!isPublicMode &&\n\t\t\t\tidentitySessionVersion !== identitySessionGeneration.current\n\t\t\t) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tconst failedRequest = pendingStreamRequests.current[0];\n\t\t\tconst failedPartitionKey =\n\t\t\t\tfailedRequest?.identityPartitionKey ?? activeStreamPartitionKey.current;\n\t\t\tif (\n\t\t\t\t!isPublicMode &&\n\t\t\t\tfailedPartitionKey !== latestIdentityPartitionKey.current\n\t\t\t) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\t// AI SDK invokes onFinish after onError. Keep this request queued so\n\t\t\t// onFinish can reconcile a user message that the backend persisted before\n\t\t\t// the provider or response stream failed.\n\t\t},\n\t\tonFinish: async () => {\n\t\t\t// In public mode, skip all persistence-related operations\n\t\t\tif (isPublicMode) {\n\t\t\t\tactiveStreamPartitionKey.current = undefined;\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (identitySessionVersion !== identitySessionGeneration.current) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tconst finishedRequest = pendingStreamRequests.current.shift();\n\t\t\tconst finishingPartitionKey =\n\t\t\t\tfinishedRequest?.identityPartitionKey ??\n\t\t\t\tactiveStreamPartitionKey.current;\n\t\t\tif (finishingPartitionKey !== latestIdentityPartitionKey.current) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (\n\t\t\t\t!finishedRequest ||\n\t\t\t\tfinishedRequest.generation === latestStreamRequestGeneration.current\n\t\t\t) {\n\t\t\t\tactiveStreamPartitionKey.current = undefined;\n\t\t\t}\n\t\t\tconst identityIsCurrent = () =>\n\t\t\t\tfinishingPartitionKey === latestIdentityPartitionKey.current &&\n\t\t\t\t(!finishedRequest ||\n\t\t\t\t\tfinishedRequest.generation === latestStreamRequestGeneration.current);\n\t\t\tconst finishingIdentityPartition = latestIdentityPartition.current;\n\t\t\tconst finishingConversationsListQueryKey =\n\t\t\t\tlatestConversationsListQueryKey.current;\n\t\t\tconst finishingHeaders = latestHeaders.current;\n\n\t\t\ttry {\n\t\t\t\t// Invalidate conversation list to show new/updated conversations\n\t\t\t\tawait queryClient.invalidateQueries({\n\t\t\t\t\tqueryKey: finishingConversationsListQueryKey,\n\t\t\t\t});\n\t\t\t\tif (!identityIsCurrent()) return;\n\n\t\t\t\t// If this was the first message on a new chat, update the URL without full navigation\n\t\t\t\t// This avoids losing the in-memory messages during component remount\n\t\t\t\tif (isFirstMessageSentRef.current && !hasNavigatedRef.current) {\n\t\t\t\t\tconst discoveredConversationId = conversationIdRef.current;\n\t\t\t\t\tif (discoveredConversationId) {\n\t\t\t\t\t\thasNavigatedRef.current = true;\n\t\t\t\t\t\tsetCurrentConversationId(discoveredConversationId);\n\t\t\t\t\t\tconversationIdRef.current = discoveredConversationId;\n\t\t\t\t\t\t// Only update the URL in full-page mode; in widget mode the chat is\n\t\t\t\t\t\t// embedded in another page and clobbering the URL is disruptive.\n\t\t\t\t\t\tif (variant === \"full\") {\n\t\t\t\t\t\t\tconst newLocation = resolveAiChatSiteLocation(\n\t\t\t\t\t\t\t\t{ baseURL: siteBaseURL, basePath: siteBasePath },\n\t\t\t\t\t\t\t\ttypeof window === \"undefined\"\n\t\t\t\t\t\t\t\t\t? undefined\n\t\t\t\t\t\t\t\t\t: window.location.origin,\n\t\t\t\t\t\t\t\t\"chat\",\n\t\t\t\t\t\t\t\tdiscoveredConversationId,\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\tif (typeof window !== \"undefined\") {\n\t\t\t\t\t\t\t\tif (newLocation.crossOrigin) {\n\t\t\t\t\t\t\t\t\tnavigateAiChatCrossOrigin(newLocation.href, {\n\t\t\t\t\t\t\t\t\t\treplace: true,\n\t\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\t\twindow.history.replaceState(\n\t\t\t\t\t\t\t\t\t\t{ ...window.history.state },\n\t\t\t\t\t\t\t\t\t\t\"\",\n\t\t\t\t\t\t\t\t\t\tnewLocation.path,\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\tconst persistedConversationId = conversationIdRef.current;\n\t\t\t\tif (!persistedConversationId || !identityIsCurrent()) {\n\t\t\t\t\tif (isFirstMessageSentRef.current) {\n\t\t\t\t\t\tisFirstMessageSentRef.current = false;\n\t\t\t\t\t\thasNavigatedRef.current = false;\n\t\t\t\t\t\tthrow new Error(\n\t\t\t\t\t\t\t\"The persisted conversation id could not be discovered.\",\n\t\t\t\t\t\t);\n\t\t\t\t\t}\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tconst client = createApiClient({\n\t\t\t\t\tbaseURL: apiBaseURL,\n\t\t\t\t\tbasePath: apiBasePath,\n\t\t\t\t\tcredentials,\n\t\t\t\t});\n\t\t\t\tconst detailQuery = createAiChatQueryKeys(\n\t\t\t\t\tclient,\n\t\t\t\t\tfinishingHeaders,\n\t\t\t\t).conversations.detail(\n\t\t\t\t\tpersistedConversationId,\n\t\t\t\t\tfinishingIdentityPartition,\n\t\t\t\t);\n\t\t\t\tawait queryClient.cancelQueries({\n\t\t\t\t\tqueryKey: detailQuery.queryKey,\n\t\t\t\t\texact: true,\n\t\t\t\t});\n\t\t\t\tif (!identityIsCurrent()) return;\n\t\t\t\tqueryClient.removeQueries({\n\t\t\t\t\tqueryKey: detailQuery.queryKey,\n\t\t\t\t\texact: true,\n\t\t\t\t});\n\t\t\t\tconst persistedConversation = await queryClient.fetchQuery({\n\t\t\t\t\t...detailQuery,\n\t\t\t\t\tstaleTime: 0,\n\t\t\t\t});\n\t\t\t\tif (!identityIsCurrent() || !persistedConversation) return;\n\t\t\t\tconst reconciled = reconcilePersistedMessageIds(\n\t\t\t\t\tlatestMessages.current,\n\t\t\t\t\tpersistedConversation.messages,\n\t\t\t\t);\n\t\t\t\tif (!reconciled) {\n\t\t\t\t\tthrow new Error(\n\t\t\t\t\t\t\"Persisted chat history did not match the streamed transcript.\",\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t\tsetMessages(reconciled);\n\t\t\t\tsetHistorySyncError(null);\n\t\t\t} catch (error) {\n\t\t\t\tif (!identityIsCurrent()) return;\n\t\t\t\tconst syncError =\n\t\t\t\t\terror instanceof Error ? error : new Error(String(error));\n\t\t\t\tconsole.error(\"Failed to reconcile persisted chat history:\", syncError);\n\t\t\t\tsetHistorySyncError(syncError);\n\t\t\t\tsetMessages([]);\n\t\t\t}\n\t\t},\n\t});\n\tconst handleHistorySyncRetry = useCallback(async () => {\n\t\tif (isPublicMode || isHistorySyncRetrying) return;\n\t\tconst retryConversationId = conversationIdRef.current;\n\t\tif (!retryConversationId) {\n\t\t\tsetHistorySyncError(null);\n\t\t\treturn;\n\t\t}\n\n\t\tconst retryPartitionKey = latestIdentityPartitionKey.current;\n\t\tconst retrySessionGeneration = identitySessionGeneration.current;\n\t\tconst retryIsCurrent = () =>\n\t\t\tretryPartitionKey === latestIdentityPartitionKey.current &&\n\t\t\tretrySessionGeneration === identitySessionGeneration.current &&\n\t\t\tretryConversationId === conversationIdRef.current;\n\t\tsetIsHistorySyncRetrying(true);\n\t\ttry {\n\t\t\tconst client = createApiClient({\n\t\t\t\tbaseURL: apiBaseURL,\n\t\t\t\tbasePath: apiBasePath,\n\t\t\t\tcredentials,\n\t\t\t});\n\t\t\tconst detailQuery = createAiChatQueryKeys(\n\t\t\t\tclient,\n\t\t\t\tlatestHeaders.current,\n\t\t\t).conversations.detail(\n\t\t\t\tretryConversationId,\n\t\t\t\tlatestIdentityPartition.current,\n\t\t\t);\n\t\t\tawait queryClient.cancelQueries({\n\t\t\t\tqueryKey: detailQuery.queryKey,\n\t\t\t\texact: true,\n\t\t\t});\n\t\t\tif (!retryIsCurrent()) return;\n\t\t\tqueryClient.removeQueries({\n\t\t\t\tqueryKey: detailQuery.queryKey,\n\t\t\t\texact: true,\n\t\t\t});\n\t\t\tconst persistedConversation = await queryClient.fetchQuery({\n\t\t\t\t...detailQuery,\n\t\t\t\tstaleTime: 0,\n\t\t\t});\n\t\t\tif (!retryIsCurrent() || !persistedConversation) return;\n\t\t\tif (isFirstMessageSentRef.current && !hasNavigatedRef.current) {\n\t\t\t\thasNavigatedRef.current = true;\n\t\t\t\tsetCurrentConversationId(retryConversationId);\n\t\t\t\tif (variant === \"full\" && typeof window !== \"undefined\") {\n\t\t\t\t\tconst newLocation = resolveAiChatSiteLocation(\n\t\t\t\t\t\t{ baseURL: siteBaseURL, basePath: siteBasePath },\n\t\t\t\t\t\twindow.location.origin,\n\t\t\t\t\t\t\"chat\",\n\t\t\t\t\t\tretryConversationId,\n\t\t\t\t\t);\n\t\t\t\t\tif (newLocation.crossOrigin) {\n\t\t\t\t\t\tnavigateAiChatCrossOrigin(newLocation.href, { replace: true });\n\t\t\t\t\t} else {\n\t\t\t\t\t\twindow.history.replaceState(\n\t\t\t\t\t\t\t{ ...window.history.state },\n\t\t\t\t\t\t\t\"\",\n\t\t\t\t\t\t\tnewLocation.path,\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\tsetMessages(\n\t\t\t\tpersistedMessagesToUiMessages(persistedConversation.messages),\n\t\t\t);\n\t\t\tsetHistorySyncError(null);\n\t\t} catch (error) {\n\t\t\tif (!retryIsCurrent()) return;\n\t\t\tconst syncError =\n\t\t\t\terror instanceof Error ? error : new Error(String(error));\n\t\t\tconsole.error(\"Failed to reload persisted chat history:\", syncError);\n\t\t\tsetHistorySyncError(syncError);\n\t\t} finally {\n\t\t\tif (retryIsCurrent()) setIsHistorySyncRetrying(false);\n\t\t}\n\t}, [\n\t\tapiBasePath,\n\t\tapiBaseURL,\n\t\tcredentials,\n\t\tisHistorySyncRetrying,\n\t\tisPublicMode,\n\t\tqueryClient,\n\t\tsetMessages,\n\t\tsiteBasePath,\n\t\tsiteBaseURL,\n\t\tvariant,\n\t]);\n\tuseLayoutEffect(() => {\n\t\tlatestMessages.current = messages;\n\t}, [messages]);\n\n\tconst previousIdentityPartition = useRef(identityPartitionKey);\n\tuseLayoutEffect(() => {\n\t\tconst nextPartition = identityPartitionKey;\n\t\tlatestIdentityPartitionKey.current = nextPartition;\n\t\tlatestIdentityPartition.current = identityPartition;\n\t\tlatestConversationsListQueryKey.current = conversationsListQueryKey;\n\t\tlatestHeaders.current = browserHeaders;\n\t\tif (isPublicMode) return;\n\t\tif (previousIdentityPartition.current === nextPartition) return;\n\t\tpreviousIdentityPartition.current = nextPartition;\n\t\tactiveStreamPartitionKey.current = undefined;\n\t\tpendingStreamRequests.current = [];\n\t\tidentitySessionGeneration.current += 1;\n\t\tsetIdentitySessionVersion(identitySessionGeneration.current);\n\t\tlatestStreamRequestGeneration.current =\n\t\t\t++nextStreamRequestGeneration.current;\n\t\tvoid stop();\n\t\tsetMessages([]);\n\t\tsetInput(\"\");\n\t\tsetAttachedFiles([]);\n\t\tsetPendingEdit(null);\n\t\tsetHistorySyncError(null);\n\t\tsetIsHistorySyncRetrying(false);\n\t\teditMessagesRef.current = null;\n\t\tisEditInProgressRef.current = false;\n\t\tsetIsMessagesInitialized(true);\n\t\tsetCurrentConversationId(id);\n\t\tconversationIdRef.current = id;\n\t\tisFirstMessageSentRef.current = false;\n\t\thasNavigatedRef.current = false;\n\t}, [\n\t\tconversationsListQueryKey,\n\t\tbrowserHeaders,\n\t\tid,\n\t\tidentityPartition,\n\t\tidentityPartitionKey,\n\t\tisPublicMode,\n\t\tsetMessages,\n\t\tstop,\n\t]);\n\n\t// Keep addToolOutputRef in sync so onToolCall always has the latest reference\n\tuseEffect(() => {\n\t\taddToolOutputRef.current = addToolOutput;\n\t}, [addToolOutput]);\n\n\t// Load existing conversation messages when navigating to a conversation\n\tuseEffect(() => {\n\t\t// Don't overwrite messages if an edit is in progress\n\t\tif (isEditInProgressRef.current) {\n\t\t\treturn;\n\t\t}\n\t\tif (\n\t\t\tconversation?.messages &&\n\t\t\tconversation.messages.length > 0 &&\n\t\t\tmessages.length === 0\n\t\t) {\n\t\t\tsetMessages(persistedMessagesToUiMessages(conversation.messages));\n\t\t}\n\t}, [conversation, messages.length, setMessages]);\n\n\t// Set initial messages on mount (for SSR hydration)\n\tuseEffect(() => {\n\t\tif (\n\t\t\t!isMessagesInitialized &&\n\t\t\tinitialMessages &&\n\t\t\tinitialMessages.length > 0 &&\n\t\t\tmessages.length === 0\n\t\t) {\n\t\t\tsetMessages(initialMessages);\n\t\t\t// Mark as initialized - this is batched with setMessages so both take effect in the same render\n\t\t\tsetIsMessagesInitialized(true);\n\t\t}\n\t}, [initialMessages, isMessagesInitialized, setMessages, messages.length]);\n\n\tconst scrollRef = useRef(null);\n\n\t// Track whether the user has manually scrolled away from the bottom.\n\t// When true, auto-scroll is paused so the user can read earlier context.\n\tconst userHasScrolledRef = useRef(false);\n\tconst prevStatusRef = useRef(status);\n\n\t// Reset the scroll lock when a new generation starts so auto-scroll\n\t// resumes for the next assistant response.\n\tuseEffect(() => {\n\t\tif (\n\t\t\tstatus !== prevStatusRef.current &&\n\t\t\t(status === \"streaming\" || status === \"submitted\")\n\t\t) {\n\t\t\tuserHasScrolledRef.current = false;\n\t\t}\n\t\tprevStatusRef.current = status;\n\t}, [status]);\n\n\t// Attach a scroll listener to detect when the user scrolls away from the bottom.\n\tuseEffect(() => {\n\t\tconst viewport = scrollRef.current?.querySelector(\n\t\t\t\"[data-radix-scroll-area-viewport]\",\n\t\t);\n\t\tif (!viewport) return;\n\n\t\tconst handleScroll = () => {\n\t\t\tconst { scrollTop, scrollHeight, clientHeight } = viewport;\n\t\t\tconst isNearBottom = scrollHeight - scrollTop - clientHeight < 50;\n\t\t\tuserHasScrolledRef.current = !isNearBottom;\n\t\t};\n\n\t\tviewport.addEventListener(\"scroll\", handleScroll);\n\t\treturn () => viewport.removeEventListener(\"scroll\", handleScroll);\n\t}, []);\n\n\t// Auto-scroll to bottom when messages change, unless the user has scrolled away\n\tuseEffect(() => {\n\t\tif (userHasScrolledRef.current) return;\n\t\tif (scrollRef.current) {\n\t\t\tconst scrollElement = scrollRef.current.querySelector(\n\t\t\t\t\"[data-radix-scroll-area-viewport]\",\n\t\t\t);\n\t\t\tif (scrollElement) {\n\t\t\t\tscrollElement.scrollTop = scrollElement.scrollHeight;\n\t\t\t}\n\t\t}\n\t}, [messages]);\n\n\t// Notify parent when messages change (for persistence in public mode)\n\t// Only fire after initialization to prevent overwriting saved messages with an empty array\n\tuseEffect(() => {\n\t\tif (isPublicMode && onMessagesChange && isMessagesInitialized) {\n\t\t\tonMessagesChange(messages);\n\t\t}\n\t}, [messages, isPublicMode, onMessagesChange, isMessagesInitialized]);\n\n\tconst handleInputChange = (e: React.ChangeEvent) => {\n\t\tsetInput(e.target.value);\n\t};\n\n\tconst handleSubmit = async (\n\t\te: React.FormEvent,\n\t\tfiles?: AttachedFile[],\n\t) => {\n\t\te.preventDefault();\n\t\tconst text = input.trim();\n\t\t// Allow submit if there's text OR files\n\t\tif (!text && (!files || files.length === 0)) return;\n\n\t\t// Track if this is the first message on a new chat (authenticated mode only)\n\t\tif (!isPublicMode && !id && messages.length === 0) {\n\t\t\tisFirstMessageSentRef.current = true;\n\t\t}\n\n\t\t// Re-enable auto-scroll so the user's own message (and any subsequent\n\t\t// error indicator or assistant reply) is scrolled into view. Without\n\t\t// this, if the user had scrolled up earlier, userHasScrolledRef stays\n\t\t// true and none of the new content would be auto-scrolled to — and if\n\t\t// the request fails before reaching \"streaming\" status the ref would\n\t\t// remain stuck permanently.\n\t\tuserHasScrolledRef.current = false;\n\n\t\t// Save current values before clearing - we'll restore them if send fails\n\t\tconst savedInput = input;\n\t\tconst savedFiles = files ? [...files] : [];\n\t\tconst sendIdentityPartitionKey = latestIdentityPartitionKey.current;\n\t\tconst sendIdentitySessionGeneration = identitySessionGeneration.current;\n\n\t\t// Capture the message count before sending so we can restore to this\n\t\t// exact point on failure. The SDK may append both a user message and a\n\t\t// partial assistant message during streaming — using a fixed snapshot\n\t\t// length removes all of them instead of just the last one.\n\t\tconst messageCountBeforeSend = messages.length;\n\n\t\t// Clear input immediately (optimistically) - the AI SDK renders messages optimistically,\n\t\t// so we need to clear the input before the message appears to avoid duplicate text\n\t\tsetInput(\"\");\n\t\tsetAttachedFiles([]);\n\t\tsetHistorySyncError(null);\n\n\t\ttry {\n\t\t\tactiveStreamPartitionKey.current = latestIdentityPartitionKey.current;\n\t\t\t// Use AI SDK's file attachment format\n\t\t\t// The SDK automatically converts supported file types (images, text) to the correct format\n\t\t\tif (files && files.length > 0) {\n\t\t\t\t// Convert AttachedFile[] to FileUIPart[] format expected by AI SDK\n\t\t\t\tconst fileUIParts = files.map((file) => ({\n\t\t\t\t\ttype: \"file\" as const,\n\t\t\t\t\tmediaType: file.mediaType,\n\t\t\t\t\turl: file.url,\n\t\t\t\t\tfilename: file.filename,\n\t\t\t\t}));\n\n\t\t\t\tconst request = sendMessage({\n\t\t\t\t\ttext: text || \"\", // AI SDK requires text, even if empty\n\t\t\t\t\tfiles: fileUIParts,\n\t\t\t\t});\n\t\t\t\tawait request;\n\t\t\t} else {\n\t\t\t\tconst request = sendMessage({ text });\n\t\t\t\tawait request;\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tif (\n\t\t\t\tactiveStreamPartitionKey.current === sendIdentityPartitionKey &&\n\t\t\t\tsendIdentitySessionGeneration === identitySessionGeneration.current\n\t\t\t) {\n\t\t\t\tactiveStreamPartitionKey.current = undefined;\n\t\t\t}\n\t\t\tif (\n\t\t\t\t!isPublicMode &&\n\t\t\t\t(sendIdentityPartitionKey !== latestIdentityPartitionKey.current ||\n\t\t\t\t\tsendIdentitySessionGeneration !== identitySessionGeneration.current)\n\t\t\t) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\t// Restore input on failure so user can retry\n\t\t\tsetInput(savedInput);\n\t\t\tsetAttachedFiles(savedFiles);\n\t\t\t// Reset first-message tracking so the next attempt still triggers navigation\n\t\t\tif (isFirstMessageSentRef.current && !hasNavigatedRef.current) {\n\t\t\t\tisFirstMessageSentRef.current = false;\n\t\t\t}\n\t\t\t// Remove all messages the SDK added after our send attempt (optimistic\n\t\t\t// user message AND any partial assistant message from a mid-stream failure).\n\t\t\tsetMessages((prev) => prev.slice(0, messageCountBeforeSend));\n\t\t\tconsole.error(\"Error sending message:\", error);\n\t\t}\n\t};\n\n\tconst isLoading = status === \"streaming\" || status === \"submitted\";\n\n\t// Handler for retrying/regenerating the last AI response\n\tconst handleRetry = useCallback(() => {\n\t\tsetHistorySyncError(null);\n\t\tactiveStreamPartitionKey.current = latestIdentityPartitionKey.current;\n\t\tregenerate();\n\t}, [regenerate]);\n\n\t// Effect to send the edited message after React has processed the truncation\n\tuseEffect(() => {\n\t\tif (\n\t\t\tpendingEdit &&\n\t\t\tpendingEdit.identityPartitionKey === latestIdentityPartitionKey.current &&\n\t\t\tpendingEdit.streamRequestGeneration ===\n\t\t\t\tlatestStreamRequestGeneration.current &&\n\t\t\tmessages.length === pendingEdit.expectedLength\n\t\t) {\n\t\t\tconst textToSend = pendingEdit.text;\n\t\t\tsetPendingEdit(null);\n\t\t\t// Clear edit in progress flag - the new message will now be sent\n\t\t\t// and we want subsequent effects to work normally\n\t\t\tisEditInProgressRef.current = false;\n\t\t\tactiveStreamPartitionKey.current = latestIdentityPartitionKey.current;\n\t\t\tsendMessage({ text: textToSend });\n\t\t}\n\t}, [messages.length, pendingEdit, sendMessage]);\n\n\t// Handler for editing a user message - replaces the message and all subsequent messages\n\tconst handleEditMessage = useCallback(\n\t\t(messageId: string, newText: string) => {\n\t\t\tsetHistorySyncError(null);\n\t\t\tconst messageIndex = messages.findIndex((m) => m.id === messageId);\n\t\t\tif (messageIndex === -1) return;\n\n\t\t\t// Get the message to edit\n\t\t\tconst messageToEdit = messages[messageIndex];\n\t\t\tif (!messageToEdit || messageToEdit.role !== \"user\") return;\n\n\t\t\t// Truncate to BEFORE the edited message (remove it and all subsequent)\n\t\t\tconst truncatedMessages = messages.slice(0, messageIndex);\n\n\t\t\t// Mark edit in progress to prevent load conversation effect from overwriting\n\t\t\tisEditInProgressRef.current = true;\n\n\t\t\t// Store the truncated messages in the ref for the transport to use\n\t\t\teditMessagesRef.current = truncatedMessages;\n\n\t\t\t// Set the pending edit - the useEffect will send after truncation is processed\n\t\t\tsetPendingEdit({\n\t\t\t\ttext: newText,\n\t\t\t\texpectedLength: messageIndex,\n\t\t\t\tidentityPartitionKey: latestIdentityPartitionKey.current,\n\t\t\t\tstreamRequestGeneration: latestStreamRequestGeneration.current,\n\t\t\t});\n\n\t\t\t// Truncate the messages - React will batch this and the useEffect will fire after\n\t\t\tsetMessages(truncatedMessages);\n\t\t},\n\t\t[messages, setMessages],\n\t);\n\n\tconst isWidget = variant === \"widget\";\n\n\treturn (\n\t\t<>\n\t\t\t
\n\t\t\t\t\n\t\t\t\t\t{/* Messages Area */}\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{messages.length === 0 ? (\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{tr(\n\t\t\t\t\t\t\t\t\t\t\t\t\"CHAT_EMPTY_STATE\",\n\t\t\t\t\t\t\t\t\t\t\t\t\"aiChat.chat.emptyState\",\n\t\t\t\t\t\t\t\t\t\t\t\t\"Start a conversation...\",\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// Merge static suggestions from overrides with dynamic ones from page context.\n\t\t\t\t\t\t\t\t\t\t// Page context suggestions appear first (most relevant to current page).\n\t\t\t\t\t\t\t\t\t\tconst pageSuggestions = pageAIContext?.suggestions ?? [];\n\t\t\t\t\t\t\t\t\t\tconst allSuggestions = [\n\t\t\t\t\t\t\t\t\t\t\t...pageSuggestions,\n\t\t\t\t\t\t\t\t\t\t\t...(chatSuggestions ?? []),\n\t\t\t\t\t\t\t\t\t\t];\n\t\t\t\t\t\t\t\t\t\treturn allSuggestions.length > 0 ? (\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{allSuggestions.map((suggestion, index) => (\n\t\t\t\t\t\t\t\t\t\t\t\t\t setInput(suggestion)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"px-3 py-2 text-sm rounded-lg border border-border bg-background hover:bg-accent hover:text-accent-foreground transition-colors text-foreground\"\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\t{suggestion}\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) : null;\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\tmessages.map((m, index) => (\n\t\t\t\t\t\t\t\t\t message.role === \"user\")?.id\n\t\t\t\t\t\t\t\t\t\t\t\t: m.id\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\tmessage={m}\n\t\t\t\t\t\t\t\t\t\tisStreaming={\n\t\t\t\t\t\t\t\t\t\t\tstatus === \"streaming\" &&\n\t\t\t\t\t\t\t\t\t\t\tm.id === messages[messages.length - 1]?.id &&\n\t\t\t\t\t\t\t\t\t\t\tm.role === \"assistant\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\tvariant={isWidget ? \"compact\" : \"default\"}\n\t\t\t\t\t\t\t\t\t\tonRetry={\n\t\t\t\t\t\t\t\t\t\t\t// Only show retry on the last assistant message\n\t\t\t\t\t\t\t\t\t\t\tm.role === \"assistant\" && index === messages.length - 1\n\t\t\t\t\t\t\t\t\t\t\t\t? handleRetry\n\t\t\t\t\t\t\t\t\t\t\t\t: undefined\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\tonEdit={\n\t\t\t\t\t\t\t\t\t\t\t// Allow editing user messages\n\t\t\t\t\t\t\t\t\t\t\tm.role === \"user\"\n\t\t\t\t\t\t\t\t\t\t\t\t? (newText) => handleEditMessage(m.id, newText)\n\t\t\t\t\t\t\t\t\t\t\t\t: undefined\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\tisRetrying={isLoading && m.role === \"assistant\"}\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{isLoading &&\n\t\t\t\t\t\t\t\tmessages[messages.length - 1]?.role !== \"assistant\" && (\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{tr(\"CHAT_LOADING\", \"aiChat.chat.loading\", \"Thinking...\")}\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{(error || historySyncError) && (\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{tr(\n\t\t\t\t\t\t\t\t\t\t\t\"CHAT_ERROR\",\n\t\t\t\t\t\t\t\t\t\t\t\"aiChat.chat.error\",\n\t\t\t\t\t\t\t\t\t\t\t\"Something went wrong. Please try again.\",\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{historySyncError && (\n\t\t\t\t\t\t\t\t\t\t void handleHistorySyncRetry()}\n\t\t\t\t\t\t\t\t\t\t\tdisabled={isHistorySyncRetrying}\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"rounded-md border border-destructive/30 px-2.5 py-1 font-medium hover:bg-destructive/10 disabled:cursor-not-allowed disabled:opacity-50\"\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t{tr(\"CHAT_RETRY\", \"aiChat.chat.retry\", \"Retry\")}\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\t\t\t{/* Input Area */}\n\t\t\t\n\t\t\t\t{(allowed) =>\n\t\t\t\t\tallowed && !historySyncError ? (\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{showAttribution && }\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\n\t\t\t\t\t) : null\n\t\t\t\t}\n\t\t\t\n\t\t\n\t);\n}\n", + "content": "\"use client\";\n\nimport { useChat } from \"@ai-sdk/react\";\nimport {\n\tuseEffect,\n\tuseRef,\n\tuseState,\n\tuseMemo,\n\tuseCallback,\n\tuseLayoutEffect,\n\tuseId,\n} from \"react\";\nimport { hashKey, useQueryClient } from \"@tanstack/react-query\";\nimport { ChatMessage } from \"./chat-message\";\nimport { ChatInput, type AttachedFile } from \"./chat-input\";\nimport { StackAttribution } from \"@/components/ui/stack-attribution\";\nimport { ScrollArea } from \"@/components/ui/scroll-area\";\nimport {\n\tDefaultChatTransport,\n\tlastAssistantMessageIsCompleteWithToolCalls,\n\ttype UIMessage,\n} from \"ai\";\nimport { cn } from \"@/lib/utils\";\nimport {\n\tPermissionCheck,\n\tusePluginOverrides,\n\tuseStack,\n} from \"@btst/stack/context\";\nimport { aiChatPermissions } from \"../../permissions\";\nimport {\n\tresolveAiChatApiUrl,\n\tresolveAiChatMode,\n\tresolveAiChatSiteLocation,\n\ttype AiChatPluginOverrides,\n} from \"../overrides\";\nimport { useAiChatTranslation } from \"../localization\";\nimport { createApiClient } from \"@btst/stack/plugins/client\";\nimport type { AiChatApiRouter } from \"@btst/stack/plugins/ai-chat/api\";\nimport { aiChatIdentityKey, createAiChatQueryKeys } from \"@btst/stack/plugins/ai-chat/api\";\nimport {\n\tuseConversation,\n\tuseConversations,\n\tuseAiChatIdentityPartition,\n\ttype SerializedMessage,\n} from \"@btst/stack/plugins/ai-chat/client/hooks\";\nimport { usePageAIContext } from \"../context/page-ai-context\";\nimport { navigateAiChatCrossOrigin } from \"../navigation\";\n\ninterface ChatInterfaceProps {\n\tinitialMessages?: UIMessage[];\n\tid?: string;\n\t/** Variant: 'full' for full-page layout, 'widget' for embedded widget */\n\tvariant?: \"full\" | \"widget\";\n\tclassName?: string;\n\t/** Called whenever messages change (for persistence). Only fires in public mode. */\n\tonMessagesChange?: (messages: UIMessage[]) => void;\n}\n\ntype ChatAction = \"send\" | \"edit\" | \"retry\";\n\nfunction reconcilePersistedMessageIds(\n\tmessages: UIMessage[],\n\tpersistedMessages: SerializedMessage[],\n): UIMessage[] | null {\n\tconst userMessages = messages.filter((message) => message.role === \"user\");\n\tconst persistedUserMessages = persistedMessages.filter(\n\t\t(message) => message.role === \"user\",\n\t);\n\tif (persistedUserMessages.length !== userMessages.length) return null;\n\tconst persistedIds = new Map();\n\tfor (let index = 0; index < userMessages.length; index++) {\n\t\tconst message = userMessages[index];\n\t\tconst persisted = persistedUserMessages[index];\n\t\tif (!message || !persisted) return null;\n\t\tconst serializedParts = JSON.stringify(message.parts);\n\t\tconst legacyText =\n\t\t\tmessage.parts.length === 1 && message.parts[0]?.type === \"text\"\n\t\t\t\t? message.parts[0].text\n\t\t\t\t: undefined;\n\t\tif (\n\t\t\tpersisted.content !== serializedParts &&\n\t\t\tpersisted.content !== legacyText\n\t\t) {\n\t\t\treturn null;\n\t\t}\n\t\tpersistedIds.set(message, persisted.id);\n\t}\n\treturn messages.map((message) => ({\n\t\t...message,\n\t\tid: persistedIds.get(message) ?? message.id,\n\t}));\n}\n\nfunction persistedMessagesToUiMessages(\n\tmessages: readonly SerializedMessage[],\n): UIMessage[] {\n\treturn messages\n\t\t.filter((message) => message.role !== \"data\")\n\t\t.map((message) => {\n\t\t\tlet parts: UIMessage[\"parts\"];\n\t\t\ttry {\n\t\t\t\tconst parsed = JSON.parse(message.content);\n\t\t\t\tparts = Array.isArray(parsed)\n\t\t\t\t\t? parsed\n\t\t\t\t\t: [{ type: \"text\" as const, text: message.content }];\n\t\t\t} catch {\n\t\t\t\tparts = [{ type: \"text\" as const, text: message.content }];\n\t\t\t}\n\t\t\treturn {\n\t\t\t\tid: message.id,\n\t\t\t\trole: message.role as \"user\" | \"assistant\" | \"system\",\n\t\t\t\tparts,\n\t\t\t};\n\t\t});\n}\n\nfunction ChatActionCheck({\n\tpublicMode,\n\taction,\n\tconversationId,\n\townerId,\n\tmessageId,\n\ttoolNames = [],\n\trouteName,\n\tchildren,\n}: {\n\tpublicMode: boolean;\n\taction: ChatAction;\n\tconversationId?: string;\n\townerId?: string;\n\tmessageId?: string;\n\ttoolNames?: readonly string[];\n\trouteName?: string;\n\tchildren: (allowed: boolean) => React.ReactNode;\n}) {\n\tif (publicMode) return <>{children(true)};\n\tconst base = {\n\t\t...(conversationId ? { conversationId } : {}),\n\t\t...(ownerId ? { ownerId } : {}),\n\t};\n\tconst streamPermission = aiChatPermissions.stream.start({\n\t\t...base,\n\t\tcreatesConversation: !conversationId,\n\t\tintent: action,\n\t});\n\tconst messagePermission =\n\t\taction === \"send\"\n\t\t\t? aiChatPermissions.message.send({\n\t\t\t\t\t...base,\n\t\t\t\t\tcreatesConversation: !conversationId,\n\t\t\t\t})\n\t\t\t: action === \"edit\" && conversationId && messageId\n\t\t\t\t? aiChatPermissions.message.edit({\n\t\t\t\t\t\tconversationId,\n\t\t\t\t\t\t...(ownerId ? { ownerId } : {}),\n\t\t\t\t\t\tmessageId,\n\t\t\t\t\t})\n\t\t\t\t: action === \"retry\" && conversationId && messageId\n\t\t\t\t\t? aiChatPermissions.message.retry({\n\t\t\t\t\t\t\tconversationId,\n\t\t\t\t\t\t\t...(ownerId ? { ownerId } : {}),\n\t\t\t\t\t\t\tmessageId,\n\t\t\t\t\t\t})\n\t\t\t\t\t: null;\n\tif (!messagePermission) return <>{children(false)};\n\n\treturn (\n\t\t\n\t\t\t{(streamState) => (\n\t\t\t\t\n\t\t\t\t\t{(messageState) => {\n\t\t\t\t\t\tconst baseAllowed =\n\t\t\t\t\t\t\tstreamState.can &&\n\t\t\t\t\t\t\t!streamState.isPending &&\n\t\t\t\t\t\t\t!streamState.error &&\n\t\t\t\t\t\t\tmessageState.can &&\n\t\t\t\t\t\t\t!messageState.isPending &&\n\t\t\t\t\t\t\t!messageState.error;\n\t\t\t\t\t\tconst renderToolCheck = (allowed: boolean) => {\n\t\t\t\t\t\t\tif (!allowed || toolNames.length === 0) return children(allowed);\n\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{(toolState) =>\n\t\t\t\t\t\t\t\t\t\tchildren(\n\t\t\t\t\t\t\t\t\t\t\ttoolState.can && !toolState.isPending && !toolState.error,\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\tif (action !== \"send\" || conversationId) {\n\t\t\t\t\t\t\treturn renderToolCheck(Boolean(baseAllowed));\n\t\t\t\t\t\t}\n\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{(createState) =>\n\t\t\t\t\t\t\t\t\trenderToolCheck(\n\t\t\t\t\t\t\t\t\t\tBoolean(baseAllowed) &&\n\t\t\t\t\t\t\t\t\t\t\tcreateState.can &&\n\t\t\t\t\t\t\t\t\t\t\t!createState.isPending &&\n\t\t\t\t\t\t\t\t\t\t\t!createState.error,\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\nfunction PermissionedChatMessage({\n\tpublicMode,\n\taction,\n\tconversationId,\n\townerId,\n\tmessageId,\n\t...messageProps\n}: React.ComponentProps & {\n\tpublicMode: boolean;\n\taction?: \"edit\" | \"retry\";\n\tconversationId?: string;\n\townerId?: string;\n\tmessageId?: string;\n}) {\n\tif (!action) return ;\n\treturn (\n\t\t\n\t\t\t{(allowed) => (\n\t\t\t\t\n\t\t\t)}\n\t\t\n\t);\n}\n\nexport function ChatInterface({\n\tinitialMessages,\n\tid,\n\tvariant = \"full\",\n\tclassName,\n\tonMessagesChange,\n}: ChatInterfaceProps) {\n\tconst {\n\t\tlocalization: customLocalization,\n\t\tshowAttribution,\n\t\tchatSuggestions,\n\t} = usePluginOverrides>(\n\t\t\"aiChat\",\n\t\t{ showAttribution: true },\n\t);\n\tconst {\n\t\tapi,\n\t\tbasePath: stackBasePath,\n\t\tplugins,\n\t\tqueryClient: stackQueryClient,\n\t} = useStack();\n\tconst aiChatApi = plugins?.aiChat?.api;\n\tconst apiBaseURL = aiChatApi?.baseURL ?? api?.baseURL;\n\tconst apiBasePath = aiChatApi?.basePath ?? api?.basePath;\n\tconst browserHeaders = aiChatApi?.browserHeaders;\n\tconst credentials = aiChatApi?.credentials;\n\tconst resolvedApiPath = resolveAiChatApiUrl(apiBaseURL, apiBasePath);\n\tconst mode = resolveAiChatMode(plugins?.aiChat?.config);\n\tconst siteBaseURL = plugins?.aiChat?.site.baseURL;\n\tconst siteBasePath = plugins?.aiChat?.site.basePath ?? stackBasePath;\n\tconst isPublicMode = mode === \"public\";\n\n\t// Read page AI context registered by the current page\n\tconst pageAIContext = usePageAIContext();\n\n\tconst tr = useAiChatTranslation(customLocalization);\n\tconst queryClient = useQueryClient(stackQueryClient);\n\tconst identityPartition = useAiChatIdentityPartition();\n\tconst chatInstanceId = useId();\n\tconst identityPartitionKey = hashKey([aiChatIdentityKey(identityPartition)]);\n\tconst latestIdentityPartitionKey = useRef(identityPartitionKey);\n\tconst identitySessionGeneration = useRef(0);\n\tconst [identitySessionVersion, setIdentitySessionVersion] = useState(0);\n\tconst activeStreamPartitionKey = useRef(undefined);\n\tconst nextStreamRequestGeneration = useRef(0);\n\tconst latestStreamRequestGeneration = useRef(0);\n\tconst pendingStreamRequests = useRef<\n\t\tArray<{ generation: number; identityPartitionKey: string }>\n\t>([]);\n\tconst latestMessages = useRef([]);\n\n\tconst conversationsListQueryKey = useMemo(() => {\n\t\t// In public mode, we don't need conversation queries\n\t\tif (isPublicMode) return [\"ai-chat\", \"disabled\"];\n\t\tconst client = createApiClient({\n\t\t\tbaseURL: apiBaseURL,\n\t\t\tbasePath: apiBasePath,\n\t\t\tcredentials,\n\t\t});\n\t\tconst queries = createAiChatQueryKeys(client, browserHeaders);\n\t\treturn queries.conversations.list(identityPartition).queryKey;\n\t}, [\n\t\tapiBaseURL,\n\t\tapiBasePath,\n\t\tbrowserHeaders,\n\t\tcredentials,\n\t\tidentityPartition,\n\t\tisPublicMode,\n\t]);\n\tconst latestIdentityPartition = useRef(identityPartition);\n\tconst latestConversationsListQueryKey = useRef(conversationsListQueryKey);\n\tconst latestHeaders = useRef(browserHeaders);\n\n\t// Track the current conversation ID - initialized from prop, updated after first message\n\t// In public mode, we don't track conversation IDs\n\tconst [currentConversationId, setCurrentConversationId] = useState<\n\t\tstring | undefined\n\t>(isPublicMode ? undefined : id);\n\t// Track if we've sent the first message on a new chat (to trigger navigation)\n\tconst isFirstMessageSentRef = useRef(false);\n\tconst hasNavigatedRef = useRef(false);\n\n\t// Update currentConversationId when id prop changes (e.g., navigating to different conversation)\n\tuseEffect(() => {\n\t\tif (!isPublicMode) {\n\t\t\tsetCurrentConversationId(id);\n\t\t\tisFirstMessageSentRef.current = false;\n\t\t\thasNavigatedRef.current = false;\n\t\t\t// Reset edit flag on navigation\n\t\t\tisEditInProgressRef.current = false;\n\t\t}\n\t}, [id, isPublicMode]);\n\n\t// Fetch existing conversation messages when id is provided (authenticated mode only)\n\tconst { conversation, isLoading: isLoadingConversation } = useConversation(\n\t\tid,\n\t\t{ enabled: !!id && !isPublicMode },\n\t);\n\n\t// Fetch conversations list for navigation after first message (authenticated mode only)\n\tconst { conversations } = useConversations({ enabled: !isPublicMode });\n\tconst currentConversationOwnerId =\n\t\tconversation?.userId ??\n\t\tconversations.find((item) => item.id === currentConversationId)?.userId ??\n\t\t(!isPublicMode &&\n\t\tcurrentConversationId &&\n\t\ttypeof identityPartition === \"object\"\n\t\t\t? identityPartition.id\n\t\t\t: undefined);\n\n\t// Use a ref to track the conversation ID for the transport body\n\t// This ensures the transport always uses the latest value\n\t// In public mode, always undefined\n\tconst conversationIdRef = useRef(\n\t\tisPublicMode ? undefined : id,\n\t);\n\tuseEffect(() => {\n\t\tif (!isPublicMode) {\n\t\t\tconversationIdRef.current = currentConversationId;\n\t\t}\n\t}, [currentConversationId, isPublicMode]);\n\n\t// Ref to track edit operation with messages to use\n\tconst editMessagesRef = useRef(null);\n\n\t// Flag to prevent load conversation effect from overwriting messages during edit\n\tconst isEditInProgressRef = useRef(false);\n\n\t// Track if we've finished initializing messages\n\t// This prevents onMessagesChange from firing with an empty array before initialMessages are loaded\n\t// Without this guard, the effect would fire on mount with [], overwriting any saved messages\n\tconst [isMessagesInitialized, setIsMessagesInitialized] = useState(\n\t\t() =>\n\t\t\t// Start as initialized if there are no initialMessages to load\n\t\t\t!initialMessages || initialMessages.length === 0,\n\t);\n\tconst [input, setInput] = useState(\"\");\n\tconst [attachedFiles, setAttachedFiles] = useState([]);\n\tconst [pendingEdit, setPendingEdit] = useState<{\n\t\ttext: string;\n\t\texpectedLength: number;\n\t\tidentityPartitionKey: string;\n\t\tstreamRequestGeneration: number;\n\t} | null>(null);\n\tconst [historySyncError, setHistorySyncError] = useState(null);\n\tconst [isHistorySyncRetrying, setIsHistorySyncRetrying] = useState(false);\n\tuseEffect(() => {\n\t\tsetHistorySyncError(null);\n\t\tsetIsHistorySyncRetrying(false);\n\t}, [id, isPublicMode]);\n\n\t// Ref to always have the latest pageAIContext in the transport callback\n\t// without recreating the transport on every context change\n\tconst pageAIContextRef = useRef(pageAIContext);\n\tuseEffect(() => {\n\t\tpageAIContextRef.current = pageAIContext;\n\t}, [pageAIContext]);\n\n\t// Memoize the transport to prevent recreation on every render\n\tconst transport = useMemo(() => {\n\t\tconst trackedFetch = Object.assign(\n\t\t\tasync (\n\t\t\t\trequest: Parameters[0],\n\t\t\t\tinit?: Parameters[1],\n\t\t\t) => {\n\t\t\t\tconst requestIdentityPartitionKey = latestIdentityPartitionKey.current;\n\t\t\t\tconst requestGeneration = latestStreamRequestGeneration.current;\n\t\t\t\tconst response = await globalThis.fetch(request, init);\n\t\t\t\tif (\n\t\t\t\t\t!isPublicMode &&\n\t\t\t\t\trequestIdentityPartitionKey === latestIdentityPartitionKey.current &&\n\t\t\t\t\trequestGeneration === latestStreamRequestGeneration.current\n\t\t\t\t) {\n\t\t\t\t\tconst conversationId = response.headers.get(\"x-conversation-id\");\n\t\t\t\t\tif (conversationId) conversationIdRef.current = conversationId;\n\t\t\t\t}\n\t\t\t\treturn response;\n\t\t\t},\n\t\t\tglobalThis.fetch,\n\t\t) as typeof globalThis.fetch;\n\t\treturn new DefaultChatTransport({\n\t\t\tapi: resolvedApiPath,\n\t\t\theaders: browserHeaders,\n\t\t\tcredentials,\n\t\t\tfetch: trackedFetch,\n\t\t\t// In public mode, don't send conversationId\n\t\t\tbody: isPublicMode\n\t\t\t\t? undefined\n\t\t\t\t: () => ({ conversationId: conversationIdRef.current }),\n\t\t\t// Handle edit operations and inject page context\n\t\t\tprepareSendMessagesRequest: ({ messages: hookMessages }) => {\n\t\t\t\tif (!isPublicMode) {\n\t\t\t\t\tconst generation = ++nextStreamRequestGeneration.current;\n\t\t\t\t\tlatestStreamRequestGeneration.current = generation;\n\t\t\t\t\tconst request = {\n\t\t\t\t\t\tgeneration,\n\t\t\t\t\t\tidentityPartitionKey: latestIdentityPartitionKey.current,\n\t\t\t\t\t};\n\t\t\t\t\tpendingStreamRequests.current.push(request);\n\t\t\t\t\tactiveStreamPartitionKey.current = request.identityPartitionKey;\n\t\t\t\t}\n\t\t\t\tconst currentPageContext = pageAIContextRef.current;\n\n\t\t\t\t// Build page context fields to include in every request\n\t\t\t\tconst pageContextBody = currentPageContext?.pageDescription\n\t\t\t\t\t? {\n\t\t\t\t\t\t\tpageContext: currentPageContext.pageDescription,\n\t\t\t\t\t\t\tavailableTools: Object.keys(currentPageContext.clientTools ?? {}),\n\t\t\t\t\t\t\trouteName: currentPageContext.routeName,\n\t\t\t\t\t\t}\n\t\t\t\t\t: {};\n\n\t\t\t\t// If we're in an edit operation, use the truncated messages + new user message\n\t\t\t\tif (editMessagesRef.current !== null) {\n\t\t\t\t\tconst newUserMessage = hookMessages[hookMessages.length - 1];\n\t\t\t\t\tconst messagesToSend = [...editMessagesRef.current];\n\t\t\t\t\tif (newUserMessage) {\n\t\t\t\t\t\tmessagesToSend.push(newUserMessage);\n\t\t\t\t\t}\n\t\t\t\t\t// Clear the ref after use\n\t\t\t\t\teditMessagesRef.current = null;\n\t\t\t\t\treturn {\n\t\t\t\t\t\tbody: {\n\t\t\t\t\t\t\tmessages: messagesToSend,\n\t\t\t\t\t\t\tconversationId: conversationIdRef.current,\n\t\t\t\t\t\t\t...pageContextBody,\n\t\t\t\t\t\t},\n\t\t\t\t\t};\n\t\t\t\t}\n\t\t\t\t// Normal case - use the messages as-is\n\t\t\t\treturn {\n\t\t\t\t\tbody: {\n\t\t\t\t\t\tmessages: hookMessages,\n\t\t\t\t\t\tconversationId: conversationIdRef.current,\n\t\t\t\t\t\t...pageContextBody,\n\t\t\t\t\t},\n\t\t\t\t};\n\t\t\t},\n\t\t});\n\t}, [browserHeaders, credentials, isPublicMode, resolvedApiPath]);\n\n\t// Use a ref so addToolOutput is always current inside the onToolCall closure\n\tconst addToolOutputRef = useRef<\n\t\tReturnType[\"addToolOutput\"] | null\n\t>(null);\n\n\tconst {\n\t\tmessages,\n\t\tsendMessage,\n\t\tstatus,\n\t\terror,\n\t\tsetMessages,\n\t\tregenerate,\n\t\taddToolOutput,\n\t\tstop,\n\t} = useChat({\n\t\tid: `${chatInstanceId}:${isPublicMode ? \"public\" : identitySessionVersion}`,\n\t\ttransport,\n\t\t// Automatically resubmit after all client-side tool results are provided\n\t\tsendAutomaticallyWhen: (options) =>\n\t\t\t(isPublicMode ||\n\t\t\t\tidentitySessionVersion === identitySessionGeneration.current) &&\n\t\t\tlastAssistantMessageIsCompleteWithToolCalls(options),\n\t\tonToolCall: async ({ toolCall }) => {\n\t\t\tconst toolRequest = pendingStreamRequests.current.at(-1);\n\t\t\tif (\n\t\t\t\t!isPublicMode &&\n\t\t\t\t(identitySessionVersion !== identitySessionGeneration.current ||\n\t\t\t\t\t!toolRequest ||\n\t\t\t\t\ttoolRequest.identityPartitionKey !==\n\t\t\t\t\t\tlatestIdentityPartitionKey.current ||\n\t\t\t\t\ttoolRequest.generation !== latestStreamRequestGeneration.current ||\n\t\t\t\t\tactiveStreamPartitionKey.current !== toolRequest.identityPartitionKey)\n\t\t\t) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tconst toolIdentityPartitionKey =\n\t\t\t\ttoolRequest?.identityPartitionKey ?? latestIdentityPartitionKey.current;\n\t\t\tconst toolStreamRequestGeneration =\n\t\t\t\ttoolRequest?.generation ?? latestStreamRequestGeneration.current;\n\t\t\tconst toolRequestIsCurrent = () =>\n\t\t\t\tisPublicMode ||\n\t\t\t\t(identitySessionVersion === identitySessionGeneration.current &&\n\t\t\t\t\ttoolIdentityPartitionKey === latestIdentityPartitionKey.current &&\n\t\t\t\t\ttoolStreamRequestGeneration ===\n\t\t\t\t\t\tlatestStreamRequestGeneration.current);\n\t\t\t// Dispatch client-side tool calls to the handler registered by the current page.\n\t\t\t// In AI SDK v5, onToolCall returns void — addToolOutput must be called explicitly.\n\t\t\tconst toolName = toolCall.toolName;\n\t\t\tconst handler = pageAIContextRef.current?.clientTools?.[toolName];\n\t\t\tif (handler) {\n\t\t\t\ttry {\n\t\t\t\t\tconst result = await handler(toolCall.input);\n\t\t\t\t\tif (!toolRequestIsCurrent()) return;\n\t\t\t\t\t// No await — avoids potential deadlocks with sendAutomaticallyWhen\n\t\t\t\t\taddToolOutputRef.current?.({\n\t\t\t\t\t\ttool: toolName,\n\t\t\t\t\t\ttoolCallId: toolCall.toolCallId,\n\t\t\t\t\t\toutput: result,\n\t\t\t\t\t});\n\t\t\t\t} catch (err) {\n\t\t\t\t\tif (!toolRequestIsCurrent()) return;\n\t\t\t\t\taddToolOutputRef.current?.({\n\t\t\t\t\t\ttool: toolName,\n\t\t\t\t\t\ttoolCallId: toolCall.toolCallId,\n\t\t\t\t\t\tstate: \"output-error\",\n\t\t\t\t\t\terrorText:\n\t\t\t\t\t\t\terr instanceof Error\n\t\t\t\t\t\t\t\t? err.message\n\t\t\t\t\t\t\t\t: tr(\n\t\t\t\t\t\t\t\t\t\t\"TOOL_EXECUTION_FAILED\",\n\t\t\t\t\t\t\t\t\t\t\"aiChat.tools.executionFailed\",\n\t\t\t\t\t\t\t\t\t\t\"Tool execution failed\",\n\t\t\t\t\t\t\t\t\t),\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// No handler found — this happens when the user navigates away while a\n\t\t\t\t// tool-call response is streaming and the page context changes. Always\n\t\t\t\t// call addToolOutput so sendAutomaticallyWhen can unblock; without this\n\t\t\t\t// the conversation gets permanently stuck waiting for a missing output.\n\t\t\t\taddToolOutputRef.current?.({\n\t\t\t\t\ttool: toolName,\n\t\t\t\t\ttoolCallId: toolCall.toolCallId,\n\t\t\t\t\tstate: \"output-error\",\n\t\t\t\t\terrorText: tr(\n\t\t\t\t\t\t\"TOOL_HANDLER_MISSING\",\n\t\t\t\t\t\t\"aiChat.tools.handlerMissing\",\n\t\t\t\t\t\t'No client-side handler registered for tool \"{{toolName}}\". The page context may have changed while the response was streaming.',\n\t\t\t\t\t\t{ toolName },\n\t\t\t\t\t),\n\t\t\t\t});\n\t\t\t}\n\t\t},\n\t\tonError: (err) => {\n\t\t\tconsole.error(\"useChat onError:\", err);\n\t\t\tif (\n\t\t\t\t!isPublicMode &&\n\t\t\t\tidentitySessionVersion !== identitySessionGeneration.current\n\t\t\t) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tconst failedRequest = pendingStreamRequests.current[0];\n\t\t\tconst failedPartitionKey =\n\t\t\t\tfailedRequest?.identityPartitionKey ?? activeStreamPartitionKey.current;\n\t\t\tif (\n\t\t\t\t!isPublicMode &&\n\t\t\t\tfailedPartitionKey !== latestIdentityPartitionKey.current\n\t\t\t) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\t// AI SDK invokes onFinish after onError. Keep this request queued so\n\t\t\t// onFinish can reconcile a user message that the backend persisted before\n\t\t\t// the provider or response stream failed.\n\t\t},\n\t\tonFinish: async () => {\n\t\t\t// In public mode, skip all persistence-related operations\n\t\t\tif (isPublicMode) {\n\t\t\t\tactiveStreamPartitionKey.current = undefined;\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (identitySessionVersion !== identitySessionGeneration.current) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tconst finishedRequest = pendingStreamRequests.current.shift();\n\t\t\tconst finishingPartitionKey =\n\t\t\t\tfinishedRequest?.identityPartitionKey ??\n\t\t\t\tactiveStreamPartitionKey.current;\n\t\t\tif (finishingPartitionKey !== latestIdentityPartitionKey.current) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (\n\t\t\t\t!finishedRequest ||\n\t\t\t\tfinishedRequest.generation === latestStreamRequestGeneration.current\n\t\t\t) {\n\t\t\t\tactiveStreamPartitionKey.current = undefined;\n\t\t\t}\n\t\t\tconst identityIsCurrent = () =>\n\t\t\t\tfinishingPartitionKey === latestIdentityPartitionKey.current &&\n\t\t\t\t(!finishedRequest ||\n\t\t\t\t\tfinishedRequest.generation === latestStreamRequestGeneration.current);\n\t\t\tconst finishingIdentityPartition = latestIdentityPartition.current;\n\t\t\tconst finishingConversationsListQueryKey =\n\t\t\t\tlatestConversationsListQueryKey.current;\n\t\t\tconst finishingHeaders = latestHeaders.current;\n\n\t\t\ttry {\n\t\t\t\t// Invalidate conversation list to show new/updated conversations\n\t\t\t\tawait queryClient.invalidateQueries({\n\t\t\t\t\tqueryKey: finishingConversationsListQueryKey,\n\t\t\t\t});\n\t\t\t\tif (!identityIsCurrent()) return;\n\n\t\t\t\t// If this was the first message on a new chat, update the URL without full navigation\n\t\t\t\t// This avoids losing the in-memory messages during component remount\n\t\t\t\tif (isFirstMessageSentRef.current && !hasNavigatedRef.current) {\n\t\t\t\t\tconst discoveredConversationId = conversationIdRef.current;\n\t\t\t\t\tif (discoveredConversationId) {\n\t\t\t\t\t\thasNavigatedRef.current = true;\n\t\t\t\t\t\tsetCurrentConversationId(discoveredConversationId);\n\t\t\t\t\t\tconversationIdRef.current = discoveredConversationId;\n\t\t\t\t\t\t// Only update the URL in full-page mode; in widget mode the chat is\n\t\t\t\t\t\t// embedded in another page and clobbering the URL is disruptive.\n\t\t\t\t\t\tif (variant === \"full\") {\n\t\t\t\t\t\t\tconst newLocation = resolveAiChatSiteLocation(\n\t\t\t\t\t\t\t\t{ baseURL: siteBaseURL, basePath: siteBasePath },\n\t\t\t\t\t\t\t\ttypeof window === \"undefined\"\n\t\t\t\t\t\t\t\t\t? undefined\n\t\t\t\t\t\t\t\t\t: window.location.origin,\n\t\t\t\t\t\t\t\t\"chat\",\n\t\t\t\t\t\t\t\tdiscoveredConversationId,\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\tif (typeof window !== \"undefined\") {\n\t\t\t\t\t\t\t\tif (newLocation.crossOrigin) {\n\t\t\t\t\t\t\t\t\tnavigateAiChatCrossOrigin(newLocation.href, {\n\t\t\t\t\t\t\t\t\t\treplace: true,\n\t\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\t\twindow.history.replaceState(\n\t\t\t\t\t\t\t\t\t\t{ ...window.history.state },\n\t\t\t\t\t\t\t\t\t\t\"\",\n\t\t\t\t\t\t\t\t\t\tnewLocation.path,\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\tconst persistedConversationId = conversationIdRef.current;\n\t\t\t\tif (!persistedConversationId || !identityIsCurrent()) {\n\t\t\t\t\tif (isFirstMessageSentRef.current) {\n\t\t\t\t\t\tisFirstMessageSentRef.current = false;\n\t\t\t\t\t\thasNavigatedRef.current = false;\n\t\t\t\t\t\tthrow new Error(\n\t\t\t\t\t\t\t\"The persisted conversation id could not be discovered.\",\n\t\t\t\t\t\t);\n\t\t\t\t\t}\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tconst client = createApiClient({\n\t\t\t\t\tbaseURL: apiBaseURL,\n\t\t\t\t\tbasePath: apiBasePath,\n\t\t\t\t\tcredentials,\n\t\t\t\t});\n\t\t\t\tconst detailQuery = createAiChatQueryKeys(\n\t\t\t\t\tclient,\n\t\t\t\t\tfinishingHeaders,\n\t\t\t\t).conversations.detail(\n\t\t\t\t\tpersistedConversationId,\n\t\t\t\t\tfinishingIdentityPartition,\n\t\t\t\t);\n\t\t\t\tawait queryClient.cancelQueries({\n\t\t\t\t\tqueryKey: detailQuery.queryKey,\n\t\t\t\t\texact: true,\n\t\t\t\t});\n\t\t\t\tif (!identityIsCurrent()) return;\n\t\t\t\tqueryClient.removeQueries({\n\t\t\t\t\tqueryKey: detailQuery.queryKey,\n\t\t\t\t\texact: true,\n\t\t\t\t});\n\t\t\t\tconst persistedConversation = await queryClient.fetchQuery({\n\t\t\t\t\t...detailQuery,\n\t\t\t\t\tstaleTime: 0,\n\t\t\t\t});\n\t\t\t\tif (!identityIsCurrent() || !persistedConversation) return;\n\t\t\t\tconst reconciled = reconcilePersistedMessageIds(\n\t\t\t\t\tlatestMessages.current,\n\t\t\t\t\tpersistedConversation.messages,\n\t\t\t\t);\n\t\t\t\tif (!reconciled) {\n\t\t\t\t\tthrow new Error(\n\t\t\t\t\t\t\"Persisted chat history did not match the streamed transcript.\",\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t\tsetMessages(reconciled);\n\t\t\t\tsetHistorySyncError(null);\n\t\t\t} catch (error) {\n\t\t\t\tif (!identityIsCurrent()) return;\n\t\t\t\tconst syncError =\n\t\t\t\t\terror instanceof Error ? error : new Error(String(error));\n\t\t\t\tconsole.error(\"Failed to reconcile persisted chat history:\", syncError);\n\t\t\t\tsetHistorySyncError(syncError);\n\t\t\t\tsetMessages([]);\n\t\t\t}\n\t\t},\n\t});\n\tconst handleHistorySyncRetry = useCallback(async () => {\n\t\tif (isPublicMode || isHistorySyncRetrying) return;\n\t\tconst retryConversationId = conversationIdRef.current;\n\t\tif (!retryConversationId) {\n\t\t\tsetHistorySyncError(null);\n\t\t\treturn;\n\t\t}\n\n\t\tconst retryPartitionKey = latestIdentityPartitionKey.current;\n\t\tconst retrySessionGeneration = identitySessionGeneration.current;\n\t\tconst retryIsCurrent = () =>\n\t\t\tretryPartitionKey === latestIdentityPartitionKey.current &&\n\t\t\tretrySessionGeneration === identitySessionGeneration.current &&\n\t\t\tretryConversationId === conversationIdRef.current;\n\t\tsetIsHistorySyncRetrying(true);\n\t\ttry {\n\t\t\tconst client = createApiClient({\n\t\t\t\tbaseURL: apiBaseURL,\n\t\t\t\tbasePath: apiBasePath,\n\t\t\t\tcredentials,\n\t\t\t});\n\t\t\tconst detailQuery = createAiChatQueryKeys(\n\t\t\t\tclient,\n\t\t\t\tlatestHeaders.current,\n\t\t\t).conversations.detail(\n\t\t\t\tretryConversationId,\n\t\t\t\tlatestIdentityPartition.current,\n\t\t\t);\n\t\t\tawait queryClient.cancelQueries({\n\t\t\t\tqueryKey: detailQuery.queryKey,\n\t\t\t\texact: true,\n\t\t\t});\n\t\t\tif (!retryIsCurrent()) return;\n\t\t\tqueryClient.removeQueries({\n\t\t\t\tqueryKey: detailQuery.queryKey,\n\t\t\t\texact: true,\n\t\t\t});\n\t\t\tconst persistedConversation = await queryClient.fetchQuery({\n\t\t\t\t...detailQuery,\n\t\t\t\tstaleTime: 0,\n\t\t\t});\n\t\t\tif (!retryIsCurrent() || !persistedConversation) return;\n\t\t\tif (isFirstMessageSentRef.current && !hasNavigatedRef.current) {\n\t\t\t\thasNavigatedRef.current = true;\n\t\t\t\tsetCurrentConversationId(retryConversationId);\n\t\t\t\tif (variant === \"full\" && typeof window !== \"undefined\") {\n\t\t\t\t\tconst newLocation = resolveAiChatSiteLocation(\n\t\t\t\t\t\t{ baseURL: siteBaseURL, basePath: siteBasePath },\n\t\t\t\t\t\twindow.location.origin,\n\t\t\t\t\t\t\"chat\",\n\t\t\t\t\t\tretryConversationId,\n\t\t\t\t\t);\n\t\t\t\t\tif (newLocation.crossOrigin) {\n\t\t\t\t\t\tnavigateAiChatCrossOrigin(newLocation.href, { replace: true });\n\t\t\t\t\t} else {\n\t\t\t\t\t\twindow.history.replaceState(\n\t\t\t\t\t\t\t{ ...window.history.state },\n\t\t\t\t\t\t\t\"\",\n\t\t\t\t\t\t\tnewLocation.path,\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\tsetMessages(\n\t\t\t\tpersistedMessagesToUiMessages(persistedConversation.messages),\n\t\t\t);\n\t\t\tsetHistorySyncError(null);\n\t\t} catch (error) {\n\t\t\tif (!retryIsCurrent()) return;\n\t\t\tconst syncError =\n\t\t\t\terror instanceof Error ? error : new Error(String(error));\n\t\t\tconsole.error(\"Failed to reload persisted chat history:\", syncError);\n\t\t\tsetHistorySyncError(syncError);\n\t\t} finally {\n\t\t\tif (retryIsCurrent()) setIsHistorySyncRetrying(false);\n\t\t}\n\t}, [\n\t\tapiBasePath,\n\t\tapiBaseURL,\n\t\tcredentials,\n\t\tisHistorySyncRetrying,\n\t\tisPublicMode,\n\t\tqueryClient,\n\t\tsetMessages,\n\t\tsiteBasePath,\n\t\tsiteBaseURL,\n\t\tvariant,\n\t]);\n\tuseLayoutEffect(() => {\n\t\tlatestMessages.current = messages;\n\t}, [messages]);\n\n\tconst previousIdentityPartition = useRef(identityPartitionKey);\n\tuseLayoutEffect(() => {\n\t\tconst nextPartition = identityPartitionKey;\n\t\tlatestIdentityPartitionKey.current = nextPartition;\n\t\tlatestIdentityPartition.current = identityPartition;\n\t\tlatestConversationsListQueryKey.current = conversationsListQueryKey;\n\t\tlatestHeaders.current = browserHeaders;\n\t\tif (isPublicMode) return;\n\t\tif (previousIdentityPartition.current === nextPartition) return;\n\t\tpreviousIdentityPartition.current = nextPartition;\n\t\tactiveStreamPartitionKey.current = undefined;\n\t\tpendingStreamRequests.current = [];\n\t\tidentitySessionGeneration.current += 1;\n\t\tsetIdentitySessionVersion(identitySessionGeneration.current);\n\t\tlatestStreamRequestGeneration.current =\n\t\t\t++nextStreamRequestGeneration.current;\n\t\tvoid stop();\n\t\tsetMessages([]);\n\t\tsetInput(\"\");\n\t\tsetAttachedFiles([]);\n\t\tsetPendingEdit(null);\n\t\tsetHistorySyncError(null);\n\t\tsetIsHistorySyncRetrying(false);\n\t\teditMessagesRef.current = null;\n\t\tisEditInProgressRef.current = false;\n\t\tsetIsMessagesInitialized(true);\n\t\tsetCurrentConversationId(id);\n\t\tconversationIdRef.current = id;\n\t\tisFirstMessageSentRef.current = false;\n\t\thasNavigatedRef.current = false;\n\t}, [\n\t\tconversationsListQueryKey,\n\t\tbrowserHeaders,\n\t\tid,\n\t\tidentityPartition,\n\t\tidentityPartitionKey,\n\t\tisPublicMode,\n\t\tsetMessages,\n\t\tstop,\n\t]);\n\n\t// Keep addToolOutputRef in sync so onToolCall always has the latest reference\n\tuseEffect(() => {\n\t\taddToolOutputRef.current = addToolOutput;\n\t}, [addToolOutput]);\n\n\t// Load existing conversation messages when navigating to a conversation\n\tuseEffect(() => {\n\t\t// Don't overwrite messages if an edit is in progress\n\t\tif (isEditInProgressRef.current) {\n\t\t\treturn;\n\t\t}\n\t\tif (\n\t\t\tconversation?.messages &&\n\t\t\tconversation.messages.length > 0 &&\n\t\t\tmessages.length === 0\n\t\t) {\n\t\t\tsetMessages(persistedMessagesToUiMessages(conversation.messages));\n\t\t}\n\t}, [conversation, messages.length, setMessages]);\n\n\t// Set initial messages on mount (for SSR hydration)\n\tuseEffect(() => {\n\t\tif (\n\t\t\t!isMessagesInitialized &&\n\t\t\tinitialMessages &&\n\t\t\tinitialMessages.length > 0 &&\n\t\t\tmessages.length === 0\n\t\t) {\n\t\t\tsetMessages(initialMessages);\n\t\t\t// Mark as initialized - this is batched with setMessages so both take effect in the same render\n\t\t\tsetIsMessagesInitialized(true);\n\t\t}\n\t}, [initialMessages, isMessagesInitialized, setMessages, messages.length]);\n\n\tconst scrollRef = useRef(null);\n\n\t// Track whether the user has manually scrolled away from the bottom.\n\t// When true, auto-scroll is paused so the user can read earlier context.\n\tconst userHasScrolledRef = useRef(false);\n\tconst prevStatusRef = useRef(status);\n\n\t// Reset the scroll lock when a new generation starts so auto-scroll\n\t// resumes for the next assistant response.\n\tuseEffect(() => {\n\t\tif (\n\t\t\tstatus !== prevStatusRef.current &&\n\t\t\t(status === \"streaming\" || status === \"submitted\")\n\t\t) {\n\t\t\tuserHasScrolledRef.current = false;\n\t\t}\n\t\tprevStatusRef.current = status;\n\t}, [status]);\n\n\t// Attach a scroll listener to detect when the user scrolls away from the bottom.\n\tuseEffect(() => {\n\t\tconst viewport = scrollRef.current?.querySelector(\n\t\t\t\"[data-radix-scroll-area-viewport]\",\n\t\t);\n\t\tif (!viewport) return;\n\n\t\tconst handleScroll = () => {\n\t\t\tconst { scrollTop, scrollHeight, clientHeight } = viewport;\n\t\t\tconst isNearBottom = scrollHeight - scrollTop - clientHeight < 50;\n\t\t\tuserHasScrolledRef.current = !isNearBottom;\n\t\t};\n\n\t\tviewport.addEventListener(\"scroll\", handleScroll);\n\t\treturn () => viewport.removeEventListener(\"scroll\", handleScroll);\n\t}, []);\n\n\t// Auto-scroll to bottom when messages change, unless the user has scrolled away\n\tuseEffect(() => {\n\t\tif (userHasScrolledRef.current) return;\n\t\tif (scrollRef.current) {\n\t\t\tconst scrollElement = scrollRef.current.querySelector(\n\t\t\t\t\"[data-radix-scroll-area-viewport]\",\n\t\t\t);\n\t\t\tif (scrollElement) {\n\t\t\t\tscrollElement.scrollTop = scrollElement.scrollHeight;\n\t\t\t}\n\t\t}\n\t}, [messages]);\n\n\t// Notify parent when messages change (for persistence in public mode)\n\t// Only fire after initialization to prevent overwriting saved messages with an empty array\n\tuseEffect(() => {\n\t\tif (isPublicMode && onMessagesChange && isMessagesInitialized) {\n\t\t\tonMessagesChange(messages);\n\t\t}\n\t}, [messages, isPublicMode, onMessagesChange, isMessagesInitialized]);\n\n\tconst handleInputChange = (e: React.ChangeEvent) => {\n\t\tsetInput(e.target.value);\n\t};\n\n\tconst handleSubmit = async (\n\t\te: React.FormEvent,\n\t\tfiles?: AttachedFile[],\n\t) => {\n\t\te.preventDefault();\n\t\tconst text = input.trim();\n\t\t// Allow submit if there's text OR files\n\t\tif (!text && (!files || files.length === 0)) return;\n\n\t\t// Track if this is the first message on a new chat (authenticated mode only)\n\t\tif (!isPublicMode && !id && messages.length === 0) {\n\t\t\tisFirstMessageSentRef.current = true;\n\t\t}\n\n\t\t// Re-enable auto-scroll so the user's own message (and any subsequent\n\t\t// error indicator or assistant reply) is scrolled into view. Without\n\t\t// this, if the user had scrolled up earlier, userHasScrolledRef stays\n\t\t// true and none of the new content would be auto-scrolled to — and if\n\t\t// the request fails before reaching \"streaming\" status the ref would\n\t\t// remain stuck permanently.\n\t\tuserHasScrolledRef.current = false;\n\n\t\t// Save current values before clearing - we'll restore them if send fails\n\t\tconst savedInput = input;\n\t\tconst savedFiles = files ? [...files] : [];\n\t\tconst sendIdentityPartitionKey = latestIdentityPartitionKey.current;\n\t\tconst sendIdentitySessionGeneration = identitySessionGeneration.current;\n\n\t\t// Capture the message count before sending so we can restore to this\n\t\t// exact point on failure. The SDK may append both a user message and a\n\t\t// partial assistant message during streaming — using a fixed snapshot\n\t\t// length removes all of them instead of just the last one.\n\t\tconst messageCountBeforeSend = messages.length;\n\n\t\t// Clear input immediately (optimistically) - the AI SDK renders messages optimistically,\n\t\t// so we need to clear the input before the message appears to avoid duplicate text\n\t\tsetInput(\"\");\n\t\tsetAttachedFiles([]);\n\t\tsetHistorySyncError(null);\n\n\t\ttry {\n\t\t\tactiveStreamPartitionKey.current = latestIdentityPartitionKey.current;\n\t\t\t// Use AI SDK's file attachment format\n\t\t\t// The SDK automatically converts supported file types (images, text) to the correct format\n\t\t\tif (files && files.length > 0) {\n\t\t\t\t// Convert AttachedFile[] to FileUIPart[] format expected by AI SDK\n\t\t\t\tconst fileUIParts = files.map((file) => ({\n\t\t\t\t\ttype: \"file\" as const,\n\t\t\t\t\tmediaType: file.mediaType,\n\t\t\t\t\turl: file.url,\n\t\t\t\t\tfilename: file.filename,\n\t\t\t\t}));\n\n\t\t\t\tconst request = sendMessage({\n\t\t\t\t\ttext: text || \"\", // AI SDK requires text, even if empty\n\t\t\t\t\tfiles: fileUIParts,\n\t\t\t\t});\n\t\t\t\tawait request;\n\t\t\t} else {\n\t\t\t\tconst request = sendMessage({ text });\n\t\t\t\tawait request;\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tif (\n\t\t\t\tactiveStreamPartitionKey.current === sendIdentityPartitionKey &&\n\t\t\t\tsendIdentitySessionGeneration === identitySessionGeneration.current\n\t\t\t) {\n\t\t\t\tactiveStreamPartitionKey.current = undefined;\n\t\t\t}\n\t\t\tif (\n\t\t\t\t!isPublicMode &&\n\t\t\t\t(sendIdentityPartitionKey !== latestIdentityPartitionKey.current ||\n\t\t\t\t\tsendIdentitySessionGeneration !== identitySessionGeneration.current)\n\t\t\t) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\t// Restore input on failure so user can retry\n\t\t\tsetInput(savedInput);\n\t\t\tsetAttachedFiles(savedFiles);\n\t\t\t// Reset first-message tracking so the next attempt still triggers navigation\n\t\t\tif (isFirstMessageSentRef.current && !hasNavigatedRef.current) {\n\t\t\t\tisFirstMessageSentRef.current = false;\n\t\t\t}\n\t\t\t// Remove all messages the SDK added after our send attempt (optimistic\n\t\t\t// user message AND any partial assistant message from a mid-stream failure).\n\t\t\tsetMessages((prev) => prev.slice(0, messageCountBeforeSend));\n\t\t\tconsole.error(\"Error sending message:\", error);\n\t\t}\n\t};\n\n\tconst isLoading = status === \"streaming\" || status === \"submitted\";\n\n\t// Handler for retrying/regenerating the last AI response\n\tconst handleRetry = useCallback(() => {\n\t\tsetHistorySyncError(null);\n\t\tactiveStreamPartitionKey.current = latestIdentityPartitionKey.current;\n\t\tregenerate();\n\t}, [regenerate]);\n\n\t// Effect to send the edited message after React has processed the truncation\n\tuseEffect(() => {\n\t\tif (\n\t\t\tpendingEdit &&\n\t\t\tpendingEdit.identityPartitionKey === latestIdentityPartitionKey.current &&\n\t\t\tpendingEdit.streamRequestGeneration ===\n\t\t\t\tlatestStreamRequestGeneration.current &&\n\t\t\tmessages.length === pendingEdit.expectedLength\n\t\t) {\n\t\t\tconst textToSend = pendingEdit.text;\n\t\t\tsetPendingEdit(null);\n\t\t\t// Clear edit in progress flag - the new message will now be sent\n\t\t\t// and we want subsequent effects to work normally\n\t\t\tisEditInProgressRef.current = false;\n\t\t\tactiveStreamPartitionKey.current = latestIdentityPartitionKey.current;\n\t\t\tsendMessage({ text: textToSend });\n\t\t}\n\t}, [messages.length, pendingEdit, sendMessage]);\n\n\t// Handler for editing a user message - replaces the message and all subsequent messages\n\tconst handleEditMessage = useCallback(\n\t\t(messageId: string, newText: string) => {\n\t\t\tsetHistorySyncError(null);\n\t\t\tconst messageIndex = messages.findIndex((m) => m.id === messageId);\n\t\t\tif (messageIndex === -1) return;\n\n\t\t\t// Get the message to edit\n\t\t\tconst messageToEdit = messages[messageIndex];\n\t\t\tif (!messageToEdit || messageToEdit.role !== \"user\") return;\n\n\t\t\t// Truncate to BEFORE the edited message (remove it and all subsequent)\n\t\t\tconst truncatedMessages = messages.slice(0, messageIndex);\n\n\t\t\t// Mark edit in progress to prevent load conversation effect from overwriting\n\t\t\tisEditInProgressRef.current = true;\n\n\t\t\t// Store the truncated messages in the ref for the transport to use\n\t\t\teditMessagesRef.current = truncatedMessages;\n\n\t\t\t// Set the pending edit - the useEffect will send after truncation is processed\n\t\t\tsetPendingEdit({\n\t\t\t\ttext: newText,\n\t\t\t\texpectedLength: messageIndex,\n\t\t\t\tidentityPartitionKey: latestIdentityPartitionKey.current,\n\t\t\t\tstreamRequestGeneration: latestStreamRequestGeneration.current,\n\t\t\t});\n\n\t\t\t// Truncate the messages - React will batch this and the useEffect will fire after\n\t\t\tsetMessages(truncatedMessages);\n\t\t},\n\t\t[messages, setMessages],\n\t);\n\n\tconst isWidget = variant === \"widget\";\n\n\treturn (\n\t\t<>\n\t\t\t
\n\t\t\t\t\n\t\t\t\t\t{/* Messages Area */}\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{messages.length === 0 ? (\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{tr(\n\t\t\t\t\t\t\t\t\t\t\t\t\"CHAT_EMPTY_STATE\",\n\t\t\t\t\t\t\t\t\t\t\t\t\"aiChat.chat.emptyState\",\n\t\t\t\t\t\t\t\t\t\t\t\t\"Start a conversation...\",\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// Merge static suggestions from overrides with dynamic ones from page context.\n\t\t\t\t\t\t\t\t\t\t// Page context suggestions appear first (most relevant to current page).\n\t\t\t\t\t\t\t\t\t\tconst pageSuggestions = pageAIContext?.suggestions ?? [];\n\t\t\t\t\t\t\t\t\t\tconst allSuggestions = [\n\t\t\t\t\t\t\t\t\t\t\t...pageSuggestions,\n\t\t\t\t\t\t\t\t\t\t\t...(chatSuggestions ?? []),\n\t\t\t\t\t\t\t\t\t\t];\n\t\t\t\t\t\t\t\t\t\treturn allSuggestions.length > 0 ? (\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{allSuggestions.map((suggestion, index) => (\n\t\t\t\t\t\t\t\t\t\t\t\t\t setInput(suggestion)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"px-3 py-2 text-sm rounded-lg border border-border bg-background hover:bg-accent hover:text-accent-foreground transition-colors text-foreground\"\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\t{suggestion}\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) : null;\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\tmessages.map((m, index) => (\n\t\t\t\t\t\t\t\t\t message.role === \"user\")?.id\n\t\t\t\t\t\t\t\t\t\t\t\t: m.id\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\tmessage={m}\n\t\t\t\t\t\t\t\t\t\tisStreaming={\n\t\t\t\t\t\t\t\t\t\t\tstatus === \"streaming\" &&\n\t\t\t\t\t\t\t\t\t\t\tm.id === messages[messages.length - 1]?.id &&\n\t\t\t\t\t\t\t\t\t\t\tm.role === \"assistant\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\tvariant={isWidget ? \"compact\" : \"default\"}\n\t\t\t\t\t\t\t\t\t\tonRetry={\n\t\t\t\t\t\t\t\t\t\t\t// Only show retry on the last assistant message\n\t\t\t\t\t\t\t\t\t\t\tm.role === \"assistant\" && index === messages.length - 1\n\t\t\t\t\t\t\t\t\t\t\t\t? handleRetry\n\t\t\t\t\t\t\t\t\t\t\t\t: undefined\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\tonEdit={\n\t\t\t\t\t\t\t\t\t\t\t// Allow editing user messages\n\t\t\t\t\t\t\t\t\t\t\tm.role === \"user\"\n\t\t\t\t\t\t\t\t\t\t\t\t? (newText) => handleEditMessage(m.id, newText)\n\t\t\t\t\t\t\t\t\t\t\t\t: undefined\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\tisRetrying={isLoading && m.role === \"assistant\"}\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{isLoading &&\n\t\t\t\t\t\t\t\tmessages[messages.length - 1]?.role !== \"assistant\" && (\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{tr(\"CHAT_LOADING\", \"aiChat.chat.loading\", \"Thinking...\")}\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{(error || historySyncError) && (\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{tr(\n\t\t\t\t\t\t\t\t\t\t\t\"CHAT_ERROR\",\n\t\t\t\t\t\t\t\t\t\t\t\"aiChat.chat.error\",\n\t\t\t\t\t\t\t\t\t\t\t\"Something went wrong. Please try again.\",\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{historySyncError && (\n\t\t\t\t\t\t\t\t\t\t void handleHistorySyncRetry()}\n\t\t\t\t\t\t\t\t\t\t\tdisabled={isHistorySyncRetrying}\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"rounded-md border border-destructive/30 px-2.5 py-1 font-medium hover:bg-destructive/10 disabled:cursor-not-allowed disabled:opacity-50\"\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t{tr(\"CHAT_RETRY\", \"aiChat.chat.retry\", \"Retry\")}\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\t\t\t{/* Input Area */}\n\t\t\t\n\t\t\t\t{(allowed) =>\n\t\t\t\t\tallowed && !historySyncError ? (\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{showAttribution && }\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\n\t\t\t\t\t) : null\n\t\t\t\t}\n\t\t\t\n\t\t\n\t);\n}\n", "target": "src/components/btst/ai-chat/client/components/chat-interface.tsx" }, { @@ -74,7 +74,7 @@ { "path": "btst/ai-chat/client/components/chat-sidebar.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { useState } from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { ScrollArea } from \"@/components/ui/scroll-area\";\nimport {\n\tDropdownMenu,\n\tDropdownMenuContent,\n\tDropdownMenuItem,\n\tDropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport {\n\tAlertDialog,\n\tAlertDialogAction,\n\tAlertDialogCancel,\n\tAlertDialogContent,\n\tAlertDialogDescription,\n\tAlertDialogFooter,\n\tAlertDialogHeader,\n\tAlertDialogTitle,\n} from \"@/components/ui/alert-dialog\";\nimport {\n\tDialog,\n\tDialogContent,\n\tDialogDescription,\n\tDialogFooter,\n\tDialogHeader,\n\tDialogTitle,\n} from \"@/components/ui/dialog\";\nimport {\n\tMessageSquarePlus,\n\tMoreHorizontal,\n\tPencil,\n\tTrash2,\n\tMessageSquare,\n} from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\nimport {\n\tPermissionAccess,\n\tuseNotify,\n\tusePluginOverrides,\n\tuseStack,\n} from \"@btst/stack/context\";\nimport { aiChatPermissions } from \"../../permissions\";\nimport {\n\tresolveAiChatSiteLocation,\n\ttype AiChatPluginOverrides,\n} from \"../overrides\";\nimport type { SerializedConversation } from \"../../types\";\nimport {\n\tuseConversations,\n\tuseRenameConversationForm,\n\tuseDeleteConversation,\n} from \"@btst/stack/plugins/ai-chat/client/hooks\";\nimport { useAiChatTranslation } from \"../localization\";\nimport { navigateAiChatCrossOrigin } from \"../navigation\";\n\ninterface ChatSidebarProps {\n\tcurrentConversationId?: string;\n\tonNewChat?: () => void;\n\tclassName?: string;\n}\n\nexport function ChatSidebar({\n\tcurrentConversationId,\n\tonNewChat,\n\tclassName,\n}: ChatSidebarProps) {\n\tconst { localization: customLocalization } = usePluginOverrides<\n\t\tAiChatPluginOverrides,\n\t\tPartial\n\t>(\"aiChat\", {});\n\tconst { basePath: legacyBasePath, plugins, router } = useStack();\n\tconst navigate = router?.navigate;\n\tconst siteBaseURL = plugins?.aiChat?.site.baseURL;\n\tconst siteBasePath = plugins?.aiChat?.site.basePath ?? legacyBasePath;\n\tconst notify = useNotify();\n\tconst tr = useAiChatTranslation(customLocalization);\n\tconst { conversations, isLoading } = useConversations();\n\tconst deleteMutation = useDeleteConversation();\n\n\tconst [renameDialogOpen, setRenameDialogOpen] = useState(false);\n\tconst [deleteDialogOpen, setDeleteDialogOpen] = useState(false);\n\tconst [selectedConversation, setSelectedConversation] =\n\t\tuseState(null);\n\tconst [newTitle, setNewTitle] = useState(\"\");\n\tconst renameForm = useRenameConversationForm({\n\t\tconversation: selectedConversation,\n\t\tonSuccess: () => {\n\t\t\tsetRenameDialogOpen(false);\n\t\t\tsetSelectedConversation(null);\n\t\t\tsetNewTitle(\"\");\n\t\t},\n\t});\n\tconst navigateToChat = (...segments: string[]) => {\n\t\tconst location = resolveAiChatSiteLocation(\n\t\t\t{ baseURL: siteBaseURL, basePath: siteBasePath },\n\t\t\ttypeof window === \"undefined\" ? undefined : window.location.origin,\n\t\t\t\"chat\",\n\t\t\t...segments,\n\t\t);\n\t\tif (location.crossOrigin && typeof window !== \"undefined\") {\n\t\t\tnavigateAiChatCrossOrigin(location.href);\n\t\t\treturn;\n\t\t}\n\t\treturn navigate?.(location.path);\n\t};\n\n\tconst handleNewChat = () => {\n\t\t// Use the StackProvider router when available.\n\t\t// Also run onNewChat to support \"reset chat\" behavior when already on /chat.\n\t\tvoid navigateToChat();\n\t\tif (onNewChat) {\n\t\t\tonNewChat();\n\t\t}\n\t};\n\n\tconst handleConversationClick = (conversation: SerializedConversation) => {\n\t\tvoid navigateToChat(conversation.id);\n\t};\n\n\tconst handleRenameClick = (conversation: SerializedConversation) => {\n\t\trenameForm.clearErrors();\n\t\tsetSelectedConversation(conversation);\n\t\tsetNewTitle(conversation.title);\n\t\tsetRenameDialogOpen(true);\n\t};\n\n\tconst handleDeleteClick = (conversation: SerializedConversation) => {\n\t\tsetSelectedConversation(conversation);\n\t\tsetDeleteDialogOpen(true);\n\t};\n\n\tconst handleRenameConfirm = async () => {\n\t\tif (selectedConversation) {\n\t\t\tawait renameForm.submit({ title: newTitle });\n\t\t}\n\t};\n\n\tconst handleDeleteConfirm = async () => {\n\t\tif (selectedConversation) {\n\t\t\ttry {\n\t\t\t\tconst deletingCurrentConversation =\n\t\t\t\t\tselectedConversation.id === currentConversationId;\n\t\t\t\tif (deletingCurrentConversation) {\n\t\t\t\t\tawait deleteMutation.mutateAsync(\n\t\t\t\t\t\t{ id: selectedConversation.id },\n\t\t\t\t\t\t{ onSuccess: () => navigateToChat() },\n\t\t\t\t\t);\n\t\t\t\t} else {\n\t\t\t\t\tawait deleteMutation.mutateAsync({ id: selectedConversation.id });\n\t\t\t\t}\n\t\t\t\tnotify.success(\n\t\t\t\t\ttr(\n\t\t\t\t\t\t\"CONVERSATION_DELETE_SUCCESS\",\n\t\t\t\t\t\t\"aiChat.toasts.deleteSuccess\",\n\t\t\t\t\t\t\"Conversation deleted\",\n\t\t\t\t\t),\n\t\t\t\t);\n\t\t\t\tsetDeleteDialogOpen(false);\n\t\t\t\tsetSelectedConversation(null);\n\t\t\t} catch {\n\t\t\t\tnotify.error(\n\t\t\t\t\ttr(\n\t\t\t\t\t\t\"CONVERSATION_DELETE_FAILURE\",\n\t\t\t\t\t\t\"aiChat.toasts.deleteFailure\",\n\t\t\t\t\t\t\"Failed to delete conversation\",\n\t\t\t\t\t),\n\t\t\t\t);\n\t\t\t}\n\t\t}\n\t};\n\n\tconst formatDate = (dateString: string) => {\n\t\tconst date = new Date(dateString);\n\t\tconst now = new Date();\n\t\tconst diffMs = now.getTime() - date.getTime();\n\t\tconst diffMins = Math.floor(diffMs / 60000);\n\t\tconst diffHours = Math.floor(diffMs / 3600000);\n\t\tconst diffDays = Math.floor(diffMs / 86400000);\n\n\t\tif (diffMins < 1)\n\t\t\treturn tr(\"TIME_JUST_NOW\", \"aiChat.time.justNow\", \"Just now\");\n\t\tif (diffMins < 60)\n\t\t\treturn tr(\n\t\t\t\t\"TIME_MINUTES_AGO\",\n\t\t\t\t\"aiChat.time.minutesAgo\",\n\t\t\t\t\"{{count}} minutes ago\",\n\t\t\t\t{ count: diffMins },\n\t\t\t);\n\t\tif (diffHours < 24)\n\t\t\treturn tr(\n\t\t\t\t\"TIME_HOURS_AGO\",\n\t\t\t\t\"aiChat.time.hoursAgo\",\n\t\t\t\t\"{{count}} hours ago\",\n\t\t\t\t{ count: diffHours },\n\t\t\t);\n\t\tif (diffDays === 1)\n\t\t\treturn tr(\"TIME_YESTERDAY\", \"aiChat.time.yesterday\", \"Yesterday\");\n\t\tif (diffDays < 7)\n\t\t\treturn tr(\"TIME_DAYS_AGO\", \"aiChat.time.daysAgo\", \"{{count}} days ago\", {\n\t\t\t\tcount: diffDays,\n\t\t\t});\n\t\treturn date.toLocaleDateString();\n\t};\n\n\treturn (\n\t\t\n\t\t\t{/* Header */}\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{tr(\"SIDEBAR_NEW_CHAT\", \"aiChat.sidebar.newChat\", \"New chat\")}\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t
\n\n\t\t\t{/* Conversations List */}\n\t\t\tdiv]:!block\">\n\t\t\t\t
\n\t\t\t\t\t{isLoading ? (\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t{tr(\"CHAT_LOADING\", \"aiChat.chat.loading\", \"Thinking...\")}\n\t\t\t\t\t\t
\n\t\t\t\t\t) : conversations.length === 0 ? (\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t{tr(\n\t\t\t\t\t\t\t\t\"SIDEBAR_NO_CONVERSATIONS\",\n\t\t\t\t\t\t\t\t\"aiChat.sidebar.empty\",\n\t\t\t\t\t\t\t\t\"No conversations yet\",\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\t
\n\t\t\t\t\t\t\t{conversations.map((conversation) => (\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t handleConversationClick(conversation)}\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
\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{conversation.title}\n\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{formatDate(conversation.updatedAt)}\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\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 {\n\t\t\t\t\t\t\t\t\t\t\t\t\t\te.stopPropagation();\n\t\t\t\t\t\t\t\t\t\t\t\t\t\thandleRenameClick(conversation);\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\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t{tr(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"CONVERSATION_RENAME\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"aiChat.conversation.rename\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"Rename\",\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\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\te.stopPropagation();\n\t\t\t\t\t\t\t\t\t\t\t\t\t\thandleDeleteClick(conversation);\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\tclassName=\"text-destructive focus:text-destructive\"\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{tr(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"CONVERSATION_DELETE\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"aiChat.conversation.delete\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"Delete\",\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\t\t\t
\n\n\t\t\t{/* Rename Dialog */}\n\t\t\t {\n\t\t\t\t\tsetRenameDialogOpen(open);\n\t\t\t\t\tif (!open) renameForm.clearErrors();\n\t\t\t\t}}\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\t{tr(\n\t\t\t\t\t\t\t\t\"CONVERSATION_RENAME\",\n\t\t\t\t\t\t\t\t\"aiChat.conversation.rename\",\n\t\t\t\t\t\t\t\t\"Rename\",\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{tr(\n\t\t\t\t\t\t\t\t\"CONVERSATION_RENAME_DESCRIPTION\",\n\t\t\t\t\t\t\t\t\"aiChat.conversation.renameDescription\",\n\t\t\t\t\t\t\t\t\"Enter a new title for this conversation.\",\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 setNewTitle(e.target.value)}\n\t\t\t\t\t\tplaceholder={tr(\n\t\t\t\t\t\t\t\"CONVERSATION_RENAME_PLACEHOLDER\",\n\t\t\t\t\t\t\t\"aiChat.conversation.renamePlaceholder\",\n\t\t\t\t\t\t\t\"Enter conversation name\",\n\t\t\t\t\t\t)}\n\t\t\t\t\t\taria-invalid={!!renameForm.fieldErrors.title}\n\t\t\t\t\t\tonKeyDown={(e) => {\n\t\t\t\t\t\t\tif (e.key === \"Enter\") {\n\t\t\t\t\t\t\t\tvoid handleRenameConfirm();\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{renameForm.fieldErrors.title && (\n\t\t\t\t\t\t

\n\t\t\t\t\t\t\t{tr(\n\t\t\t\t\t\t\t\t\"CONVERSATION_TITLE_REQUIRED\",\n\t\t\t\t\t\t\t\t\"aiChat.conversation.titleRequired\",\n\t\t\t\t\t\t\t\t\"Title is required\",\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\n\t\t\t\t\t\t setRenameDialogOpen(false)}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{tr(\n\t\t\t\t\t\t\t\t\"CONVERSATION_RENAME_CANCEL\",\n\t\t\t\t\t\t\t\t\"aiChat.conversation.renameCancel\",\n\t\t\t\t\t\t\t\t\"Cancel\",\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{tr(\n\t\t\t\t\t\t\t\t\"CONVERSATION_RENAME_SAVE\",\n\t\t\t\t\t\t\t\t\"aiChat.conversation.renameSave\",\n\t\t\t\t\t\t\t\t\"Save\",\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{/* Delete Confirmation Dialog */}\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\t{tr(\n\t\t\t\t\t\t\t\t\"CONVERSATION_DELETE_CONFIRM_TITLE\",\n\t\t\t\t\t\t\t\t\"aiChat.conversation.deleteConfirmTitle\",\n\t\t\t\t\t\t\t\t\"Delete conversation\",\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{tr(\n\t\t\t\t\t\t\t\t\"CONVERSATION_DELETE_CONFIRM_DESCRIPTION\",\n\t\t\t\t\t\t\t\t\"aiChat.conversation.deleteConfirmDescription\",\n\t\t\t\t\t\t\t\t\"Are you sure you want to delete this conversation? This action cannot be undone.\",\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\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{tr(\n\t\t\t\t\t\t\t\t\"CONVERSATION_DELETE_CANCEL\",\n\t\t\t\t\t\t\t\t\"aiChat.conversation.deleteCancel\",\n\t\t\t\t\t\t\t\t\"Cancel\",\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\tevent.preventDefault();\n\t\t\t\t\t\t\t\tvoid handleDeleteConfirm();\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\tdisabled={deleteMutation.isPending}\n\t\t\t\t\t\t\tclassName=\"bg-destructive text-destructive-foreground hover:bg-destructive/90\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{tr(\n\t\t\t\t\t\t\t\t\"CONVERSATION_DELETE_CONFIRM_BUTTON\",\n\t\t\t\t\t\t\t\t\"aiChat.conversation.deleteConfirmButton\",\n\t\t\t\t\t\t\t\t\"Delete\",\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", + "content": "\"use client\";\n\nimport { useState } from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { ScrollArea } from \"@/components/ui/scroll-area\";\nimport {\n\tDropdownMenu,\n\tDropdownMenuContent,\n\tDropdownMenuItem,\n\tDropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport {\n\tAlertDialog,\n\tAlertDialogAction,\n\tAlertDialogCancel,\n\tAlertDialogContent,\n\tAlertDialogDescription,\n\tAlertDialogFooter,\n\tAlertDialogHeader,\n\tAlertDialogTitle,\n} from \"@/components/ui/alert-dialog\";\nimport {\n\tDialog,\n\tDialogContent,\n\tDialogDescription,\n\tDialogFooter,\n\tDialogHeader,\n\tDialogTitle,\n} from \"@/components/ui/dialog\";\nimport {\n\tMessageSquarePlus,\n\tMoreHorizontal,\n\tPencil,\n\tTrash2,\n\tMessageSquare,\n} from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\nimport {\n\tPermissionAccess,\n\tuseNotify,\n\tusePluginOverrides,\n\tuseStack,\n} from \"@btst/stack/context\";\nimport { aiChatPermissions } from \"../../permissions\";\nimport {\n\tresolveAiChatSiteLocation,\n\ttype AiChatPluginOverrides,\n} from \"../overrides\";\nimport type { SerializedConversation } from \"../../types\";\nimport {\n\tuseConversations,\n\tuseRenameConversationForm,\n\tuseDeleteConversation,\n} from \"@btst/stack/plugins/ai-chat/client/hooks\";\nimport { useAiChatTranslation } from \"../localization\";\nimport { navigateAiChatCrossOrigin } from \"../navigation\";\n\ninterface ChatSidebarProps {\n\tcurrentConversationId?: string;\n\tonNewChat?: () => void;\n\tclassName?: string;\n}\n\nexport function ChatSidebar({\n\tcurrentConversationId,\n\tonNewChat,\n\tclassName,\n}: ChatSidebarProps) {\n\tconst { localization: customLocalization } = usePluginOverrides<\n\t\tAiChatPluginOverrides,\n\t\tPartial\n\t>(\"aiChat\", {});\n\tconst { basePath: stackBasePath, plugins, router } = useStack();\n\tconst navigate = router?.navigate;\n\tconst siteBaseURL = plugins?.aiChat?.site.baseURL;\n\tconst siteBasePath = plugins?.aiChat?.site.basePath ?? stackBasePath;\n\tconst notify = useNotify();\n\tconst tr = useAiChatTranslation(customLocalization);\n\tconst { conversations, isLoading } = useConversations();\n\tconst deleteMutation = useDeleteConversation();\n\n\tconst [renameDialogOpen, setRenameDialogOpen] = useState(false);\n\tconst [deleteDialogOpen, setDeleteDialogOpen] = useState(false);\n\tconst [selectedConversation, setSelectedConversation] =\n\t\tuseState(null);\n\tconst [newTitle, setNewTitle] = useState(\"\");\n\tconst renameForm = useRenameConversationForm({\n\t\tconversation: selectedConversation,\n\t\tonSuccess: () => {\n\t\t\tsetRenameDialogOpen(false);\n\t\t\tsetSelectedConversation(null);\n\t\t\tsetNewTitle(\"\");\n\t\t},\n\t});\n\tconst navigateToChat = (...segments: string[]) => {\n\t\tconst location = resolveAiChatSiteLocation(\n\t\t\t{ baseURL: siteBaseURL, basePath: siteBasePath },\n\t\t\ttypeof window === \"undefined\" ? undefined : window.location.origin,\n\t\t\t\"chat\",\n\t\t\t...segments,\n\t\t);\n\t\tif (location.crossOrigin && typeof window !== \"undefined\") {\n\t\t\tnavigateAiChatCrossOrigin(location.href);\n\t\t\treturn;\n\t\t}\n\t\treturn navigate?.(location.path);\n\t};\n\n\tconst handleNewChat = () => {\n\t\t// Use the StackProvider router when available.\n\t\t// Also run onNewChat to support \"reset chat\" behavior when already on /chat.\n\t\tvoid navigateToChat();\n\t\tif (onNewChat) {\n\t\t\tonNewChat();\n\t\t}\n\t};\n\n\tconst handleConversationClick = (conversation: SerializedConversation) => {\n\t\tvoid navigateToChat(conversation.id);\n\t};\n\n\tconst handleRenameClick = (conversation: SerializedConversation) => {\n\t\trenameForm.clearErrors();\n\t\tsetSelectedConversation(conversation);\n\t\tsetNewTitle(conversation.title);\n\t\tsetRenameDialogOpen(true);\n\t};\n\n\tconst handleDeleteClick = (conversation: SerializedConversation) => {\n\t\tsetSelectedConversation(conversation);\n\t\tsetDeleteDialogOpen(true);\n\t};\n\n\tconst handleRenameConfirm = async () => {\n\t\tif (selectedConversation) {\n\t\t\tawait renameForm.submit({ title: newTitle });\n\t\t}\n\t};\n\n\tconst handleDeleteConfirm = async () => {\n\t\tif (selectedConversation) {\n\t\t\ttry {\n\t\t\t\tconst deletingCurrentConversation =\n\t\t\t\t\tselectedConversation.id === currentConversationId;\n\t\t\t\tif (deletingCurrentConversation) {\n\t\t\t\t\tawait deleteMutation.mutateAsync(\n\t\t\t\t\t\t{ id: selectedConversation.id },\n\t\t\t\t\t\t{ onSuccess: () => navigateToChat() },\n\t\t\t\t\t);\n\t\t\t\t} else {\n\t\t\t\t\tawait deleteMutation.mutateAsync({ id: selectedConversation.id });\n\t\t\t\t}\n\t\t\t\tnotify.success(\n\t\t\t\t\ttr(\n\t\t\t\t\t\t\"CONVERSATION_DELETE_SUCCESS\",\n\t\t\t\t\t\t\"aiChat.toasts.deleteSuccess\",\n\t\t\t\t\t\t\"Conversation deleted\",\n\t\t\t\t\t),\n\t\t\t\t);\n\t\t\t\tsetDeleteDialogOpen(false);\n\t\t\t\tsetSelectedConversation(null);\n\t\t\t} catch {\n\t\t\t\tnotify.error(\n\t\t\t\t\ttr(\n\t\t\t\t\t\t\"CONVERSATION_DELETE_FAILURE\",\n\t\t\t\t\t\t\"aiChat.toasts.deleteFailure\",\n\t\t\t\t\t\t\"Failed to delete conversation\",\n\t\t\t\t\t),\n\t\t\t\t);\n\t\t\t}\n\t\t}\n\t};\n\n\tconst formatDate = (dateString: string) => {\n\t\tconst date = new Date(dateString);\n\t\tconst now = new Date();\n\t\tconst diffMs = now.getTime() - date.getTime();\n\t\tconst diffMins = Math.floor(diffMs / 60000);\n\t\tconst diffHours = Math.floor(diffMs / 3600000);\n\t\tconst diffDays = Math.floor(diffMs / 86400000);\n\n\t\tif (diffMins < 1)\n\t\t\treturn tr(\"TIME_JUST_NOW\", \"aiChat.time.justNow\", \"Just now\");\n\t\tif (diffMins < 60)\n\t\t\treturn tr(\n\t\t\t\t\"TIME_MINUTES_AGO\",\n\t\t\t\t\"aiChat.time.minutesAgo\",\n\t\t\t\t\"{{count}} minutes ago\",\n\t\t\t\t{ count: diffMins },\n\t\t\t);\n\t\tif (diffHours < 24)\n\t\t\treturn tr(\n\t\t\t\t\"TIME_HOURS_AGO\",\n\t\t\t\t\"aiChat.time.hoursAgo\",\n\t\t\t\t\"{{count}} hours ago\",\n\t\t\t\t{ count: diffHours },\n\t\t\t);\n\t\tif (diffDays === 1)\n\t\t\treturn tr(\"TIME_YESTERDAY\", \"aiChat.time.yesterday\", \"Yesterday\");\n\t\tif (diffDays < 7)\n\t\t\treturn tr(\"TIME_DAYS_AGO\", \"aiChat.time.daysAgo\", \"{{count}} days ago\", {\n\t\t\t\tcount: diffDays,\n\t\t\t});\n\t\treturn date.toLocaleDateString();\n\t};\n\n\treturn (\n\t\t\n\t\t\t{/* Header */}\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{tr(\"SIDEBAR_NEW_CHAT\", \"aiChat.sidebar.newChat\", \"New chat\")}\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t
\n\n\t\t\t{/* Conversations List */}\n\t\t\tdiv]:!block\">\n\t\t\t\t
\n\t\t\t\t\t{isLoading ? (\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t{tr(\"CHAT_LOADING\", \"aiChat.chat.loading\", \"Thinking...\")}\n\t\t\t\t\t\t
\n\t\t\t\t\t) : conversations.length === 0 ? (\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t{tr(\n\t\t\t\t\t\t\t\t\"SIDEBAR_NO_CONVERSATIONS\",\n\t\t\t\t\t\t\t\t\"aiChat.sidebar.empty\",\n\t\t\t\t\t\t\t\t\"No conversations yet\",\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\t
\n\t\t\t\t\t\t\t{conversations.map((conversation) => (\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t handleConversationClick(conversation)}\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
\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{conversation.title}\n\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{formatDate(conversation.updatedAt)}\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\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 {\n\t\t\t\t\t\t\t\t\t\t\t\t\t\te.stopPropagation();\n\t\t\t\t\t\t\t\t\t\t\t\t\t\thandleRenameClick(conversation);\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\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t{tr(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"CONVERSATION_RENAME\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"aiChat.conversation.rename\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"Rename\",\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\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\te.stopPropagation();\n\t\t\t\t\t\t\t\t\t\t\t\t\t\thandleDeleteClick(conversation);\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\tclassName=\"text-destructive focus:text-destructive\"\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{tr(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"CONVERSATION_DELETE\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"aiChat.conversation.delete\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"Delete\",\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\t\t\t
\n\n\t\t\t{/* Rename Dialog */}\n\t\t\t {\n\t\t\t\t\tsetRenameDialogOpen(open);\n\t\t\t\t\tif (!open) renameForm.clearErrors();\n\t\t\t\t}}\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\t{tr(\n\t\t\t\t\t\t\t\t\"CONVERSATION_RENAME\",\n\t\t\t\t\t\t\t\t\"aiChat.conversation.rename\",\n\t\t\t\t\t\t\t\t\"Rename\",\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{tr(\n\t\t\t\t\t\t\t\t\"CONVERSATION_RENAME_DESCRIPTION\",\n\t\t\t\t\t\t\t\t\"aiChat.conversation.renameDescription\",\n\t\t\t\t\t\t\t\t\"Enter a new title for this conversation.\",\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 setNewTitle(e.target.value)}\n\t\t\t\t\t\tplaceholder={tr(\n\t\t\t\t\t\t\t\"CONVERSATION_RENAME_PLACEHOLDER\",\n\t\t\t\t\t\t\t\"aiChat.conversation.renamePlaceholder\",\n\t\t\t\t\t\t\t\"Enter conversation name\",\n\t\t\t\t\t\t)}\n\t\t\t\t\t\taria-invalid={!!renameForm.fieldErrors.title}\n\t\t\t\t\t\tonKeyDown={(e) => {\n\t\t\t\t\t\t\tif (e.key === \"Enter\") {\n\t\t\t\t\t\t\t\tvoid handleRenameConfirm();\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{renameForm.fieldErrors.title && (\n\t\t\t\t\t\t

\n\t\t\t\t\t\t\t{tr(\n\t\t\t\t\t\t\t\t\"CONVERSATION_TITLE_REQUIRED\",\n\t\t\t\t\t\t\t\t\"aiChat.conversation.titleRequired\",\n\t\t\t\t\t\t\t\t\"Title is required\",\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\n\t\t\t\t\t\t setRenameDialogOpen(false)}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{tr(\n\t\t\t\t\t\t\t\t\"CONVERSATION_RENAME_CANCEL\",\n\t\t\t\t\t\t\t\t\"aiChat.conversation.renameCancel\",\n\t\t\t\t\t\t\t\t\"Cancel\",\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{tr(\n\t\t\t\t\t\t\t\t\"CONVERSATION_RENAME_SAVE\",\n\t\t\t\t\t\t\t\t\"aiChat.conversation.renameSave\",\n\t\t\t\t\t\t\t\t\"Save\",\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{/* Delete Confirmation Dialog */}\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\t{tr(\n\t\t\t\t\t\t\t\t\"CONVERSATION_DELETE_CONFIRM_TITLE\",\n\t\t\t\t\t\t\t\t\"aiChat.conversation.deleteConfirmTitle\",\n\t\t\t\t\t\t\t\t\"Delete conversation\",\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{tr(\n\t\t\t\t\t\t\t\t\"CONVERSATION_DELETE_CONFIRM_DESCRIPTION\",\n\t\t\t\t\t\t\t\t\"aiChat.conversation.deleteConfirmDescription\",\n\t\t\t\t\t\t\t\t\"Are you sure you want to delete this conversation? This action cannot be undone.\",\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\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{tr(\n\t\t\t\t\t\t\t\t\"CONVERSATION_DELETE_CANCEL\",\n\t\t\t\t\t\t\t\t\"aiChat.conversation.deleteCancel\",\n\t\t\t\t\t\t\t\t\"Cancel\",\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\tevent.preventDefault();\n\t\t\t\t\t\t\t\tvoid handleDeleteConfirm();\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\tdisabled={deleteMutation.isPending}\n\t\t\t\t\t\t\tclassName=\"bg-destructive text-destructive-foreground hover:bg-destructive/90\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{tr(\n\t\t\t\t\t\t\t\t\"CONVERSATION_DELETE_CONFIRM_BUTTON\",\n\t\t\t\t\t\t\t\t\"aiChat.conversation.deleteConfirmButton\",\n\t\t\t\t\t\t\t\t\"Delete\",\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/ai-chat/client/components/chat-sidebar.tsx" }, { diff --git a/packages/stack/registry/btst-blog.json b/packages/stack/registry/btst-blog.json index 8bb9c1985..4404e1d08 100644 --- a/packages/stack/registry/btst-blog.json +++ b/packages/stack/registry/btst-blog.json @@ -370,7 +370,7 @@ { "path": "btst/blog/client/overrides.ts", "type": "registry:lib", - "content": "import type { SerializedPost } from \"../types\";\nimport type { ComponentType, ReactNode } from \"react\";\nimport type { BlogLocalization } from \"./localization\";\n\n/**\n * Props for the overridable blog featured image input component.\n */\nexport interface BlogImageInputFieldProps {\n\t/** Current image URL value */\n\tvalue: string;\n\t/** Called when the image URL changes */\n\tonChange: (value: string) => void;\n\t/** Whether the field is required */\n\tisRequired?: boolean;\n}\n\n/**\n * Context passed to lifecycle hooks\n */\nexport interface RouteContext {\n\t/** Current route path */\n\tpath: string;\n\t/** Route parameters (e.g., { slug: \"my-post\" }) */\n\tparams?: Record;\n\t/** Whether rendering on server (true) or client (false) */\n\tisSSR: boolean;\n\t/** Additional context properties */\n\t[key: string]: any;\n}\n\n/**\n * Overridable components and functions for the Blog plugin\n *\n * External consumers can provide their own implementations to customize\n * plugin-specific components and behavior.\n */\nexport interface BlogPluginOverrides {\n\t/**\n\t * Post card component for displaying a post\n\t */\n\tPostCard?: ComponentType<{\n\t\tpost: SerializedPost;\n\t}>;\n\t/**\n\t * Function used to upload a new image file and return its URL.\n\t * This is separate from `imagePicker`, which selects an existing asset URL.\n\t */\n\tuploadImage: (file: File) => Promise;\n\t/**\n\t * Optional custom component for the featured image field.\n\t *\n\t * When provided it replaces the default file-upload input entirely.\n\t * The component receives `value` (current URL string) and `onChange` (setter).\n\t *\n\t * Typical use case: render a preview when a value is set, and a media-picker\n\t * trigger when no value is set.\n\t *\n\t * @example\n\t * ```tsx\n\t * imageInputField: ({ value, onChange }) =>\n\t * value ? (\n\t *
\n\t * \"Preview\"\n\t * Change} accept={[\"image/*\"]}\n\t * onSelect={(assets) => onChange(assets[0].url)} />\n\t *
\n\t * ) : (\n\t * Browse media} accept={[\"image/*\"]}\n\t * onSelect={(assets) => onChange(assets[0].url)} />\n\t * )\n\t * ```\n\t */\n\timageInputField?: ComponentType;\n\n\t/**\n\t * Optional trigger component for a media picker.\n\t * When provided, it is rendered adjacent to the Markdown editor and allows\n\t * users to browse and select previously uploaded assets.\n\t * Receives `onSelect(url)` — insert the chosen URL into the editor.\n\t *\n\t * @example\n\t * ```tsx\n\t * imagePicker: ({ onSelect }) => (\n\t * Browse media}\n\t * accept={[\"image/*\"]}\n\t * onSelect={(assets) => onSelect(assets[0].url)}\n\t * />\n\t * )\n\t * ```\n\t */\n\timagePicker?: ComponentType<{ onSelect: (url: string) => void }>;\n\t/**\n\t * Localization object for the blog plugin\n\t */\n\tlocalization?: BlogLocalization;\n\t/**\n\t * Whether to show the attribution\n\t */\n\tshowAttribution?: boolean;\n\t// Lifecycle Hooks (optional)\n\t/**\n\t * Called when a route is rendered\n\t * @param routeName - Name of the route (e.g., 'posts', 'post', 'newPost')\n\t * @param context - Route context with path, params, etc.\n\t */\n\tonRouteRender?: (\n\t\trouteName: string,\n\t\tcontext: RouteContext,\n\t) => void | Promise;\n\n\t/**\n\t * Called when a route encounters an error\n\t * @param routeName - Name of the route\n\t * @param error - The error that occurred\n\t * @param context - Route context\n\t */\n\tonRouteError?: (\n\t\trouteName: string,\n\t\terror: Error,\n\t\tcontext: RouteContext,\n\t) => void | Promise;\n\n\t// ============ Slot Overrides ============\n\n\t/**\n\t * Optional slot rendered below the blog post body.\n\t * Use this to inject a comment thread or any custom content without\n\t * coupling the blog plugin to the comments plugin.\n\t *\n\t * @example\n\t * ```tsx\n\t * blog: {\n\t * postBottomSlot: (post) => (\n\t * \n\t * ),\n\t * }\n\t * ```\n\t */\n\tpostBottomSlot?: (post: SerializedPost) => ReactNode;\n}\n", + "content": "import type { SerializedPost } from \"../types\";\nimport type { ComponentType, ReactNode } from \"react\";\nimport type { BlogLocalization } from \"./localization\";\n\n/**\n * Props for the overridable blog featured image input component.\n */\nexport interface BlogImageInputFieldProps {\n\t/** Current image URL value */\n\tvalue: string;\n\t/** Called when the image URL changes */\n\tonChange: (value: string) => void;\n\t/** Whether the field is required */\n\tisRequired?: boolean;\n}\n\n/**\n * Context passed to lifecycle hooks\n */\nexport interface RouteContext {\n\t/** Current route path */\n\tpath: string;\n\t/** Route parameters (e.g., { slug: \"my-post\" }) */\n\tparams?: Record;\n\t/** Whether rendering on server (true) or client (false) */\n\tisSSR: boolean;\n\t/** Additional context properties */\n\t[key: string]: any;\n}\n\n/**\n * Overridable components and functions for the Blog plugin\n *\n * External consumers can provide their own implementations to customize\n * plugin-specific components and behavior.\n */\nexport interface BlogPluginOverrides {\n\t/**\n\t * Post card component for displaying a post\n\t */\n\tPostCard?: ComponentType<{\n\t\tpost: SerializedPost;\n\t}>;\n\t/**\n\t * Function used to upload a new image file and return its URL.\n\t * This is separate from `imagePicker`, which selects an existing asset URL.\n\t */\n\tuploadImage: (file: File) => Promise;\n\t/**\n\t * Optional custom component for the featured image field.\n\t *\n\t * When provided it replaces the default file-upload input entirely.\n\t * The component receives `value` (current URL string) and `onChange` (setter).\n\t *\n\t * Typical use case: render a preview when a value is set, and a media-picker\n\t * trigger when no value is set.\n\t *\n\t * @example\n\t * ```tsx\n\t * imageInputField: ({ value, onChange }) =>\n\t * value ? (\n\t *
\n\t * \"Preview\"\n\t * Change} accept={[\"image/*\"]}\n\t * onSelect={(assets) => onChange(assets[0].url)} />\n\t *
\n\t * ) : (\n\t * Browse media} accept={[\"image/*\"]}\n\t * onSelect={(assets) => onChange(assets[0].url)} />\n\t * )\n\t * ```\n\t */\n\timageInputField?: ComponentType;\n\n\t/**\n\t * Optional trigger component for a media picker.\n\t * When provided, it is rendered adjacent to the Markdown editor and allows\n\t * users to browse and select previously uploaded assets.\n\t * Receives `onSelect(url)` — insert the chosen URL into the editor.\n\t *\n\t * @example\n\t * ```tsx\n\t * imagePicker: ({ onSelect }) => (\n\t * Browse media}\n\t * accept={[\"image/*\"]}\n\t * onSelect={(assets) => onSelect(assets[0].url)}\n\t * />\n\t * )\n\t * ```\n\t */\n\timagePicker?: ComponentType<{ onSelect: (url: string) => void }>;\n\t/**\n\t * Localization object for the blog plugin\n\t */\n\tlocalization?: Partial;\n\t/**\n\t * Whether to show the attribution\n\t */\n\tshowAttribution?: boolean;\n\t// Lifecycle Hooks (optional)\n\t/**\n\t * Called when a route is rendered\n\t * @param routeName - Name of the route (e.g., 'posts', 'post', 'newPost')\n\t * @param context - Route context with path, params, etc.\n\t */\n\tonRouteRender?: (\n\t\trouteName: string,\n\t\tcontext: RouteContext,\n\t) => void | Promise;\n\n\t/**\n\t * Called when a route encounters an error\n\t * @param routeName - Name of the route\n\t * @param error - The error that occurred\n\t * @param context - Route context\n\t */\n\tonRouteError?: (\n\t\trouteName: string,\n\t\terror: Error,\n\t\tcontext: RouteContext,\n\t) => void | Promise;\n\n\t// ============ Slot Overrides ============\n\n\t/**\n\t * Optional slot rendered below the blog post body.\n\t * Use this to inject a comment thread or any custom content without\n\t * coupling the blog plugin to the comments plugin.\n\t *\n\t * @example\n\t * ```tsx\n\t * blog: {\n\t * postBottomSlot: (post) => (\n\t * \n\t * ),\n\t * }\n\t * ```\n\t */\n\tpostBottomSlot?: (post: SerializedPost) => ReactNode;\n}\n", "target": "src/components/btst/blog/client/overrides.ts" }, { diff --git a/packages/stack/registry/btst-cms.json b/packages/stack/registry/btst-cms.json index d9c4999fd..c81d9673f 100644 --- a/packages/stack/registry/btst-cms.json +++ b/packages/stack/registry/btst-cms.json @@ -73,7 +73,7 @@ { "path": "btst/cms/client/components/inverse-relations-panel.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { useState } from \"react\";\nimport {\n\tChevronDown,\n\tChevronRight,\n\tExternalLink,\n\tPlus,\n\tTrash2,\n} from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n\tCard,\n\tCardContent,\n\tCardHeader,\n\tCardTitle,\n} from \"@/components/ui/card\";\nimport {\n\tjoinBasePath,\n\tPermissionAccess,\n\tusePluginOverrides,\n\tuseStack,\n\tuseTranslate,\n\ttype TranslateFn,\n} from \"@btst/stack/context\";\nimport { cmsPermissions } from \"../../permissions\";\nimport {\n\tuseDeleteContent,\n\tuseInverseRelations,\n\tuseInverseRelationItems,\n} from \"@btst/stack/plugins/cms/client/hooks\";\nimport type { CMSPluginOverrides } from \"../overrides\";\nimport { CMS_PLUGIN_ID } from \"../constants\";\nimport type {\n\tInverseRelation,\n\tSerializedContentItemWithType,\n} from \"../../types\";\nimport {\n\tAlertDialog,\n\tAlertDialogAction,\n\tAlertDialogCancel,\n\tAlertDialogContent,\n\tAlertDialogDescription,\n\tAlertDialogFooter,\n\tAlertDialogHeader,\n\tAlertDialogTitle,\n} from \"@/components/ui/alert-dialog\";\n\ninterface InverseRelationsPanelProps {\n\tcontentTypeSlug: string;\n\titemId: string;\n}\n\n/**\n * Panel that shows content items that reference this item via belongsTo relations.\n * For example, when editing a Resource, this shows all Comments that belong to it.\n */\nexport function InverseRelationsPanel({\n\tcontentTypeSlug,\n\titemId,\n}: InverseRelationsPanelProps) {\n\tconst t = useTranslate();\n\tconst { localization } =\n\t\tusePluginOverrides(CMS_PLUGIN_ID);\n\tconst { router, plugins, basePath: legacyBasePath } = useStack();\n\tconst navigate = router?.navigate;\n\tconst Link = router?.Link;\n\tconst basePath = plugins?.[CMS_PLUGIN_ID]?.site.basePath ?? legacyBasePath;\n\n\t// Fetch inverse relations metadata\n\tconst { inverseRelations, isLoading } = useInverseRelations(\n\t\tcontentTypeSlug,\n\t\titemId,\n\t);\n\n\tif (isLoading) {\n\t\treturn (\n\t\t\t\n\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\tif (inverseRelations.length === 0) {\n\t\treturn null;\n\t}\n\n\t// When a single source content type has multiple belongsTo fields pointing\n\t// at this target type (e.g. StackSynergy has both compoundAId and\n\t// compoundBId → compound), the section title alone (\"Stack Synergy\") is\n\t// ambiguous — two cards would render with identical headings. Mark those\n\t// relations so we can disambiguate them by field name.\n\tconst sourceTypeCounts = new Map();\n\tfor (const rel of inverseRelations) {\n\t\tsourceTypeCounts.set(\n\t\t\trel.sourceType,\n\t\t\t(sourceTypeCounts.get(rel.sourceType) ?? 0) + 1,\n\t\t);\n\t}\n\n\treturn (\n\t\t
\n\t\t\t

\n\t\t\t\t{localization?.CMS_RELATED_ITEMS_TITLE ??\n\t\t\t\t\tt(\"cms.relations.relatedItemsTitle\", \"Related Items\")}\n\t\t\t

\n\t\t\t{inverseRelations.map((relation) => (\n\t\t\t\t 1}\n\t\t\t\t/>\n\t\t\t))}\n\t\t
\n\t);\n}\n\n/**\n * Turn a relation field name like `compoundAId` / `categoryIds` into a\n * friendlier label like `Compound A` / `Category` for display in the\n * inverse-relations panel when two sections would otherwise share a title.\n *\n * Strips a trailing `Id` or `Ids`, splits camelCase boundaries, and\n * title-cases the result. Leaves unrecognised shapes as-is so we never\n * produce an empty string.\n */\nfunction humanizeFieldName(fieldName: string): string {\n\tconst stripped = fieldName.replace(/Ids?$/, \"\") || fieldName;\n\tconst words = stripped\n\t\t.replace(/([a-z0-9])([A-Z])/g, \"$1 $2\")\n\t\t.replace(/[_-]+/g, \" \")\n\t\t.trim()\n\t\t.split(/\\s+/);\n\treturn words.map((w) => (w ? w[0]!.toUpperCase() + w.slice(1) : w)).join(\" \");\n}\n\ninterface InverseRelationSectionProps {\n\trelation: InverseRelation;\n\tcontentTypeSlug: string;\n\titemId: string;\n\tbasePath: string;\n\tnavigate?: (path: string) => void | Promise;\n\tLink?: React.ComponentType<{\n\t\thref?: string;\n\t\tchildren?: React.ReactNode;\n\t\tclassName?: string;\n\t}>;\n\tlocalization: CMSPluginOverrides[\"localization\"];\n\tt: TranslateFn;\n\t/**\n\t * True when another inverse relation from the same `sourceType` is also\n\t * being rendered — in which case the field-name suffix is shown so the\n\t * user can tell the two cards apart.\n\t */\n\tambiguous: boolean;\n}\n\nfunction InverseRelationSection({\n\trelation,\n\tcontentTypeSlug,\n\titemId,\n\tbasePath,\n\tnavigate,\n\tLink,\n\tlocalization,\n\tt,\n\tambiguous,\n}: InverseRelationSectionProps) {\n\tconst [isExpanded, setIsExpanded] = useState(true);\n\tconst [deleteItemId, setDeleteItemId] = useState(null);\n\tconst [deleteError, setDeleteError] = useState(null);\n\tconst deleteContent = useDeleteContent(relation.sourceType);\n\n\t// Fetch items for this inverse relation\n\tconst {\n\t\titems,\n\t\ttotal: fetchedTotal,\n\t\trefetch,\n\t} = useInverseRelationItems(\n\t\t{\n\t\t\tcontentTypeSlug,\n\t\t\tsourceType: relation.sourceType,\n\t\t\titemId,\n\t\t\tfieldName: relation.fieldName,\n\t\t},\n\t\t{ enabled: isExpanded },\n\t);\n\n\tconst total = fetchedTotal || relation.count;\n\n\tconst handleDelete = async () => {\n\t\tif (deleteItemId) {\n\t\t\tsetDeleteError(null);\n\t\t\ttry {\n\t\t\t\tawait deleteContent.mutateAsync(deleteItemId);\n\t\t\t\tsetDeleteItemId(null);\n\t\t\t\tvoid refetch();\n\t\t\t} catch (error) {\n\t\t\t\tconst message =\n\t\t\t\t\terror instanceof Error\n\t\t\t\t\t\t? error.message\n\t\t\t\t\t\t: (localization?.CMS_RELATED_DELETE_ERROR ??\n\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\"cms.relations.relatedDeleteError\",\n\t\t\t\t\t\t\t\t\"Failed to delete item. Please try again.\",\n\t\t\t\t\t\t\t));\n\t\t\t\tsetDeleteError(message);\n\t\t\t}\n\t\t}\n\t};\n\n\t// Create new item with pre-filled belongsTo field\n\tconst handleAddNew = () => {\n\t\t// Navigate to create page with query param to pre-fill the relation.\n\t\t// ContentEditorPage reads prefill_* query params and passes them to ContentForm as initialData.\n\t\tconst createUrl = joinBasePath(\n\t\t\tbasePath,\n\t\t\t`/cms/${relation.sourceType}/new?prefill_${relation.fieldName}=${itemId}`,\n\t\t);\n\t\tvoid navigate?.(createUrl);\n\t};\n\n\tconst LinkComponent = Link ?? \"a\";\n\tconst fieldLabel = ambiguous ? humanizeFieldName(relation.fieldName) : null;\n\n\treturn (\n\t\t\n\t\t\t\n\t\t\t\t setIsExpanded(!isExpanded)}\n\t\t\t\t\tclassName=\"flex items-center justify-between w-full text-left\"\n\t\t\t\t>\n\t\t\t\t\t\n\t\t\t\t\t\t{isExpanded ? (\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)}\n\t\t\t\t\t\t{relation.sourceTypeName}\n\t\t\t\t\t\t{fieldLabel && (\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t· {fieldLabel}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t)}\n\t\t\t\t\t\t({total})\n\t\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{items.length === 0 ? (\n\t\t\t\t\t\t

\n\t\t\t\t\t\t\t{(\n\t\t\t\t\t\t\t\tlocalization?.CMS_RELATED_EMPTY ??\n\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\"cms.relations.relatedEmpty\",\n\t\t\t\t\t\t\t\t\t\"No {sourceTypeName} items yet.\",\n\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t).replace(\n\t\t\t\t\t\t\t\t\"{sourceTypeName}\",\n\t\t\t\t\t\t\t\trelation.sourceTypeName.toLowerCase(),\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\t
    \n\t\t\t\t\t\t\t{items.map((item: SerializedContentItemWithType) => {\n\t\t\t\t\t\t\t\tconst displayValue = getDisplayValue(item);\n\t\t\t\t\t\t\t\tconst editUrl = joinBasePath(\n\t\t\t\t\t\t\t\t\tbasePath,\n\t\t\t\t\t\t\t\t\t`/cms/${relation.sourceType}/${item.id}`,\n\t\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\t\treturn (\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{displayValue}\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 setDeleteItemId(item.id)}\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\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\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{(\n\t\t\t\t\t\t\t\t\tlocalization?.CMS_RELATED_ADD ??\n\t\t\t\t\t\t\t\t\tt(\"cms.relations.relatedAdd\", \"Add {sourceTypeName}\")\n\t\t\t\t\t\t\t\t).replace(\"{sourceTypeName}\", relation.sourceTypeName)}\n\t\t\t\t\t\t\t\t{fieldLabel ? ` (${fieldLabel})` : \"\"}\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{/* Delete confirmation dialog */}\n\t\t\t {\n\t\t\t\t\tif (!open) {\n\t\t\t\t\t\tsetDeleteItemId(null);\n\t\t\t\t\t\tsetDeleteError(null);\n\t\t\t\t\t}\n\t\t\t\t}}\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\t{(\n\t\t\t\t\t\t\t\tlocalization?.CMS_RELATED_DELETE_TITLE ??\n\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\"cms.relations.relatedDeleteTitle\",\n\t\t\t\t\t\t\t\t\t\"Delete {sourceTypeName}?\",\n\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t).replace(\"{sourceTypeName}\", relation.sourceTypeName)}\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\tlocalization?.CMS_RELATED_DELETE_DESCRIPTION ??\n\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\"cms.relations.relatedDeleteDescription\",\n\t\t\t\t\t\t\t\t\t\"This action cannot be undone. This will permanently delete this {sourceTypeName}.\",\n\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t).replace(\n\t\t\t\t\t\t\t\t\"{sourceTypeName}\",\n\t\t\t\t\t\t\t\trelation.sourceTypeName.toLowerCase(),\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{deleteError && (\n\t\t\t\t\t\t

{deleteError}

\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{localization?.CMS_BUTTON_CANCEL ??\n\t\t\t\t\t\t\t\tt(\"cms.common.cancel\", \"Cancel\")}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.CMS_BUTTON_DELETE ??\n\t\t\t\t\t\t\t\tt(\"cms.common.delete\", \"Delete\")}\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 a display value from an item's parsedData\n */\nfunction getDisplayValue(item: SerializedContentItemWithType): string {\n\tconst data = item.parsedData as Record;\n\t// Try common display fields\n\tconst displayFields = [\"name\", \"title\", \"label\", \"content\", \"author\", \"slug\"];\n\tfor (const field of displayFields) {\n\t\tif (typeof data[field] === \"string\" && data[field]) {\n\t\t\tconst value = data[field] as string;\n\t\t\treturn value.length > 50 ? `${value.slice(0, 50)}...` : value;\n\t\t}\n\t}\n\treturn item.slug;\n}\n", + "content": "\"use client\";\n\nimport { useState } from \"react\";\nimport {\n\tChevronDown,\n\tChevronRight,\n\tExternalLink,\n\tPlus,\n\tTrash2,\n} from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n\tCard,\n\tCardContent,\n\tCardHeader,\n\tCardTitle,\n} from \"@/components/ui/card\";\nimport {\n\tjoinBasePath,\n\tPermissionAccess,\n\tusePluginOverrides,\n\tuseStack,\n\tuseTranslate,\n\ttype TranslateFn,\n} from \"@btst/stack/context\";\nimport { cmsPermissions } from \"../../permissions\";\nimport {\n\tuseDeleteContent,\n\tuseInverseRelations,\n\tuseInverseRelationItems,\n} from \"@btst/stack/plugins/cms/client/hooks\";\nimport type { CMSPluginOverrides } from \"../overrides\";\nimport { CMS_PLUGIN_ID } from \"../constants\";\nimport type {\n\tInverseRelation,\n\tSerializedContentItemWithType,\n} from \"../../types\";\nimport {\n\tAlertDialog,\n\tAlertDialogAction,\n\tAlertDialogCancel,\n\tAlertDialogContent,\n\tAlertDialogDescription,\n\tAlertDialogFooter,\n\tAlertDialogHeader,\n\tAlertDialogTitle,\n} from \"@/components/ui/alert-dialog\";\n\ninterface InverseRelationsPanelProps {\n\tcontentTypeSlug: string;\n\titemId: string;\n}\n\n/**\n * Panel that shows content items that reference this item via belongsTo relations.\n * For example, when editing a Resource, this shows all Comments that belong to it.\n */\nexport function InverseRelationsPanel({\n\tcontentTypeSlug,\n\titemId,\n}: InverseRelationsPanelProps) {\n\tconst t = useTranslate();\n\tconst { localization } =\n\t\tusePluginOverrides(CMS_PLUGIN_ID);\n\tconst { router, plugins, basePath: stackBasePath } = useStack();\n\tconst navigate = router?.navigate;\n\tconst Link = router?.Link;\n\tconst basePath = plugins?.[CMS_PLUGIN_ID]?.site.basePath ?? stackBasePath;\n\n\t// Fetch inverse relations metadata\n\tconst { inverseRelations, isLoading } = useInverseRelations(\n\t\tcontentTypeSlug,\n\t\titemId,\n\t);\n\n\tif (isLoading) {\n\t\treturn (\n\t\t\t\n\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\tif (inverseRelations.length === 0) {\n\t\treturn null;\n\t}\n\n\t// When a single source content type has multiple belongsTo fields pointing\n\t// at this target type (e.g. StackSynergy has both compoundAId and\n\t// compoundBId → compound), the section title alone (\"Stack Synergy\") is\n\t// ambiguous — two cards would render with identical headings. Mark those\n\t// relations so we can disambiguate them by field name.\n\tconst sourceTypeCounts = new Map();\n\tfor (const rel of inverseRelations) {\n\t\tsourceTypeCounts.set(\n\t\t\trel.sourceType,\n\t\t\t(sourceTypeCounts.get(rel.sourceType) ?? 0) + 1,\n\t\t);\n\t}\n\n\treturn (\n\t\t
\n\t\t\t

\n\t\t\t\t{localization?.CMS_RELATED_ITEMS_TITLE ??\n\t\t\t\t\tt(\"cms.relations.relatedItemsTitle\", \"Related Items\")}\n\t\t\t

\n\t\t\t{inverseRelations.map((relation) => (\n\t\t\t\t 1}\n\t\t\t\t/>\n\t\t\t))}\n\t\t
\n\t);\n}\n\n/**\n * Turn a relation field name like `compoundAId` / `categoryIds` into a\n * friendlier label like `Compound A` / `Category` for display in the\n * inverse-relations panel when two sections would otherwise share a title.\n *\n * Strips a trailing `Id` or `Ids`, splits camelCase boundaries, and\n * title-cases the result. Leaves unrecognised shapes as-is so we never\n * produce an empty string.\n */\nfunction humanizeFieldName(fieldName: string): string {\n\tconst stripped = fieldName.replace(/Ids?$/, \"\") || fieldName;\n\tconst words = stripped\n\t\t.replace(/([a-z0-9])([A-Z])/g, \"$1 $2\")\n\t\t.replace(/[_-]+/g, \" \")\n\t\t.trim()\n\t\t.split(/\\s+/);\n\treturn words.map((w) => (w ? w[0]!.toUpperCase() + w.slice(1) : w)).join(\" \");\n}\n\ninterface InverseRelationSectionProps {\n\trelation: InverseRelation;\n\tcontentTypeSlug: string;\n\titemId: string;\n\tbasePath: string;\n\tnavigate?: (path: string) => void | Promise;\n\tLink?: React.ComponentType<{\n\t\thref?: string;\n\t\tchildren?: React.ReactNode;\n\t\tclassName?: string;\n\t}>;\n\tlocalization: CMSPluginOverrides[\"localization\"];\n\tt: TranslateFn;\n\t/**\n\t * True when another inverse relation from the same `sourceType` is also\n\t * being rendered — in which case the field-name suffix is shown so the\n\t * user can tell the two cards apart.\n\t */\n\tambiguous: boolean;\n}\n\nfunction InverseRelationSection({\n\trelation,\n\tcontentTypeSlug,\n\titemId,\n\tbasePath,\n\tnavigate,\n\tLink,\n\tlocalization,\n\tt,\n\tambiguous,\n}: InverseRelationSectionProps) {\n\tconst [isExpanded, setIsExpanded] = useState(true);\n\tconst [deleteItemId, setDeleteItemId] = useState(null);\n\tconst [deleteError, setDeleteError] = useState(null);\n\tconst deleteContent = useDeleteContent(relation.sourceType);\n\n\t// Fetch items for this inverse relation\n\tconst {\n\t\titems,\n\t\ttotal: fetchedTotal,\n\t\trefetch,\n\t} = useInverseRelationItems(\n\t\t{\n\t\t\tcontentTypeSlug,\n\t\t\tsourceType: relation.sourceType,\n\t\t\titemId,\n\t\t\tfieldName: relation.fieldName,\n\t\t},\n\t\t{ enabled: isExpanded },\n\t);\n\n\tconst total = fetchedTotal || relation.count;\n\n\tconst handleDelete = async () => {\n\t\tif (deleteItemId) {\n\t\t\tsetDeleteError(null);\n\t\t\ttry {\n\t\t\t\tawait deleteContent.mutateAsync(deleteItemId);\n\t\t\t\tsetDeleteItemId(null);\n\t\t\t\tvoid refetch();\n\t\t\t} catch (error) {\n\t\t\t\tconst message =\n\t\t\t\t\terror instanceof Error\n\t\t\t\t\t\t? error.message\n\t\t\t\t\t\t: (localization?.CMS_RELATED_DELETE_ERROR ??\n\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\"cms.relations.relatedDeleteError\",\n\t\t\t\t\t\t\t\t\"Failed to delete item. Please try again.\",\n\t\t\t\t\t\t\t));\n\t\t\t\tsetDeleteError(message);\n\t\t\t}\n\t\t}\n\t};\n\n\t// Create new item with pre-filled belongsTo field\n\tconst handleAddNew = () => {\n\t\t// Navigate to create page with query param to pre-fill the relation.\n\t\t// ContentEditorPage reads prefill_* query params and passes them to ContentForm as initialData.\n\t\tconst createUrl = joinBasePath(\n\t\t\tbasePath,\n\t\t\t`/cms/${relation.sourceType}/new?prefill_${relation.fieldName}=${itemId}`,\n\t\t);\n\t\tvoid navigate?.(createUrl);\n\t};\n\n\tconst LinkComponent = Link ?? \"a\";\n\tconst fieldLabel = ambiguous ? humanizeFieldName(relation.fieldName) : null;\n\n\treturn (\n\t\t\n\t\t\t\n\t\t\t\t setIsExpanded(!isExpanded)}\n\t\t\t\t\tclassName=\"flex items-center justify-between w-full text-left\"\n\t\t\t\t>\n\t\t\t\t\t\n\t\t\t\t\t\t{isExpanded ? (\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)}\n\t\t\t\t\t\t{relation.sourceTypeName}\n\t\t\t\t\t\t{fieldLabel && (\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t· {fieldLabel}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t)}\n\t\t\t\t\t\t({total})\n\t\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{items.length === 0 ? (\n\t\t\t\t\t\t

\n\t\t\t\t\t\t\t{(\n\t\t\t\t\t\t\t\tlocalization?.CMS_RELATED_EMPTY ??\n\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\"cms.relations.relatedEmpty\",\n\t\t\t\t\t\t\t\t\t\"No {sourceTypeName} items yet.\",\n\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t).replace(\n\t\t\t\t\t\t\t\t\"{sourceTypeName}\",\n\t\t\t\t\t\t\t\trelation.sourceTypeName.toLowerCase(),\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\t
    \n\t\t\t\t\t\t\t{items.map((item: SerializedContentItemWithType) => {\n\t\t\t\t\t\t\t\tconst displayValue = getDisplayValue(item);\n\t\t\t\t\t\t\t\tconst editUrl = joinBasePath(\n\t\t\t\t\t\t\t\t\tbasePath,\n\t\t\t\t\t\t\t\t\t`/cms/${relation.sourceType}/${item.id}`,\n\t\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\t\treturn (\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{displayValue}\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 setDeleteItemId(item.id)}\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\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\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{(\n\t\t\t\t\t\t\t\t\tlocalization?.CMS_RELATED_ADD ??\n\t\t\t\t\t\t\t\t\tt(\"cms.relations.relatedAdd\", \"Add {sourceTypeName}\")\n\t\t\t\t\t\t\t\t).replace(\"{sourceTypeName}\", relation.sourceTypeName)}\n\t\t\t\t\t\t\t\t{fieldLabel ? ` (${fieldLabel})` : \"\"}\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{/* Delete confirmation dialog */}\n\t\t\t {\n\t\t\t\t\tif (!open) {\n\t\t\t\t\t\tsetDeleteItemId(null);\n\t\t\t\t\t\tsetDeleteError(null);\n\t\t\t\t\t}\n\t\t\t\t}}\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\t{(\n\t\t\t\t\t\t\t\tlocalization?.CMS_RELATED_DELETE_TITLE ??\n\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\"cms.relations.relatedDeleteTitle\",\n\t\t\t\t\t\t\t\t\t\"Delete {sourceTypeName}?\",\n\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t).replace(\"{sourceTypeName}\", relation.sourceTypeName)}\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\tlocalization?.CMS_RELATED_DELETE_DESCRIPTION ??\n\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\"cms.relations.relatedDeleteDescription\",\n\t\t\t\t\t\t\t\t\t\"This action cannot be undone. This will permanently delete this {sourceTypeName}.\",\n\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t).replace(\n\t\t\t\t\t\t\t\t\"{sourceTypeName}\",\n\t\t\t\t\t\t\t\trelation.sourceTypeName.toLowerCase(),\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{deleteError && (\n\t\t\t\t\t\t

{deleteError}

\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{localization?.CMS_BUTTON_CANCEL ??\n\t\t\t\t\t\t\t\tt(\"cms.common.cancel\", \"Cancel\")}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.CMS_BUTTON_DELETE ??\n\t\t\t\t\t\t\t\tt(\"cms.common.delete\", \"Delete\")}\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 a display value from an item's parsedData\n */\nfunction getDisplayValue(item: SerializedContentItemWithType): string {\n\tconst data = item.parsedData as Record;\n\t// Try common display fields\n\tconst displayFields = [\"name\", \"title\", \"label\", \"content\", \"author\", \"slug\"];\n\tfor (const field of displayFields) {\n\t\tif (typeof data[field] === \"string\" && data[field]) {\n\t\t\tconst value = data[field] as string;\n\t\t\treturn value.length > 50 ? `${value.slice(0, 50)}...` : value;\n\t\t}\n\t}\n\treturn item.slug;\n}\n", "target": "src/components/btst/cms/client/components/inverse-relations-panel.tsx" }, { @@ -103,13 +103,13 @@ { "path": "btst/cms/client/components/pages/404-page.tsx", "type": "registry:page", - "content": "\"use client\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n\tjoinBasePath,\n\tusePluginOverrides,\n\tuseStack,\n\tuseTranslate,\n} from \"@btst/stack/context\";\nimport type { CMSPluginOverrides } from \"../../overrides\";\nimport { CMS_PLUGIN_ID } from \"../../constants\";\n\nexport function NotFoundPage() {\n\tconst t = useTranslate();\n\tconst { localization } =\n\t\tusePluginOverrides(CMS_PLUGIN_ID);\n\tconst { router, plugins, basePath: legacyBasePath } = useStack();\n\tconst basePath = plugins?.[CMS_PLUGIN_ID]?.site.basePath ?? legacyBasePath;\n\n\tconst LinkComponent = router?.Link ?? \"a\";\n\n\treturn (\n\t\t
\n\t\t\t

404

\n\t\t\t

\n\t\t\t\t{localization?.CMS_404_TITLE ??\n\t\t\t\t\tt(\"cms.common.404Title\", \"Page not found\")}\n\t\t\t

\n\t\t\t

\n\t\t\t\t{localization?.CMS_404_DESCRIPTION ??\n\t\t\t\t\tt(\n\t\t\t\t\t\t\"cms.common.404Description\",\n\t\t\t\t\t\t\"The page you're looking for doesn't exist or has been moved.\",\n\t\t\t\t\t)}\n\t\t\t

\n\t\t\t\n\t\t
\n\t);\n}\n", + "content": "\"use client\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n\tjoinBasePath,\n\tusePluginOverrides,\n\tuseStack,\n\tuseTranslate,\n} from \"@btst/stack/context\";\nimport type { CMSPluginOverrides } from \"../../overrides\";\nimport { CMS_PLUGIN_ID } from \"../../constants\";\n\nexport function NotFoundPage() {\n\tconst t = useTranslate();\n\tconst { localization } =\n\t\tusePluginOverrides(CMS_PLUGIN_ID);\n\tconst { router, plugins, basePath: stackBasePath } = useStack();\n\tconst basePath = plugins?.[CMS_PLUGIN_ID]?.site.basePath ?? stackBasePath;\n\n\tconst LinkComponent = router?.Link ?? \"a\";\n\n\treturn (\n\t\t
\n\t\t\t

404

\n\t\t\t

\n\t\t\t\t{localization?.CMS_404_TITLE ??\n\t\t\t\t\tt(\"cms.common.404Title\", \"Page not found\")}\n\t\t\t

\n\t\t\t

\n\t\t\t\t{localization?.CMS_404_DESCRIPTION ??\n\t\t\t\t\tt(\n\t\t\t\t\t\t\"cms.common.404Description\",\n\t\t\t\t\t\t\"The page you're looking for doesn't exist or has been moved.\",\n\t\t\t\t\t)}\n\t\t\t

\n\t\t\t\n\t\t
\n\t);\n}\n", "target": "src/components/btst/cms/client/components/pages/404-page.tsx" }, { "path": "btst/cms/client/components/pages/content-editor-page.internal.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { useState, useEffect } from \"react\";\nimport { ArrowLeft } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n\tjoinBasePath,\n\tusePluginOverrides,\n\tuseStack,\n\tuseTranslate,\n} from \"@btst/stack/context\";\nimport { orderCMSContentTypes, type CMSPluginOverrides } from \"../../overrides\";\nimport { CMS_PLUGIN_ID } from \"../../constants\";\nimport {\n\tuseSuspenseContentTypes,\n\tuseContentItem,\n\tuseContentItemForm,\n} from \"@btst/stack/plugins/cms/client/hooks\";\nimport { ContentForm } from \"../forms/content-form\";\nimport { InverseRelationsPanel } from \"../inverse-relations-panel\";\nimport { EmptyState } from \"../shared/empty-state\";\nimport { PageWrapper } from \"../shared/page-wrapper\";\nimport { EditorSkeleton } from \"../loading/editor-skeleton\";\nimport { useRouteLifecycle } from \"@/hooks/use-route-lifecycle\";\n\n/**\n * Parse prefill query parameters from the URL.\n * Looks for query params with the format `prefill_=`\n * and returns a record of field names to values.\n *\n * Uses useState + useEffect pattern to work correctly with SSR/hydration.\n * During SSR, returns empty object. After hydration, parses URL params.\n * Also listens for popstate events to handle browser back/forward navigation.\n *\n * @example\n * URL: /cms/comment/new?prefill_resourceId=123&prefill_author=John\n * Returns: { resourceId: \"123\", author: \"John\" }\n */\nfunction usePrefillParams(): Record {\n\tconst [prefillData, setPrefillData] = useState>({});\n\n\tuseEffect(() => {\n\t\tif (typeof window === \"undefined\") {\n\t\t\treturn;\n\t\t}\n\n\t\tconst parseAndSetPrefillData = () => {\n\t\t\tconst params = new URLSearchParams(window.location.search);\n\t\t\tconst data: Record = {};\n\n\t\t\tfor (const [key, value] of params.entries()) {\n\t\t\t\tif (key.startsWith(\"prefill_\")) {\n\t\t\t\t\tconst fieldName = key.slice(\"prefill_\".length);\n\t\t\t\t\tif (fieldName) {\n\t\t\t\t\t\tdata[fieldName] = value;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Always update state to ensure stale data is cleared when navigating\n\t\t\t// to a URL without prefill params (e.g., via browser back/forward)\n\t\t\tsetPrefillData(data);\n\t\t};\n\n\t\t// Parse on mount\n\t\tparseAndSetPrefillData();\n\n\t\t// Listen for popstate events (browser back/forward navigation)\n\t\twindow.addEventListener(\"popstate\", parseAndSetPrefillData);\n\n\t\treturn () => {\n\t\t\twindow.removeEventListener(\"popstate\", parseAndSetPrefillData);\n\t\t};\n\t}, []);\n\n\treturn prefillData;\n}\n\ninterface JsonSchemaProperty {\n\tfieldType?: string;\n\trelation?: {\n\t\ttype: \"belongsTo\" | \"hasMany\" | \"manyToMany\";\n\t\ttargetType: string;\n\t};\n}\n\n/**\n * Convert prefill params to the correct format for the form.\n * Relation fields need special handling:\n * - belongsTo: value should be { id: \"uuid\" }\n * - hasMany/manyToMany: value should be [{ id: \"uuid\" }]\n *\n * @param prefillParams - Raw prefill params from URL\n * @param jsonSchema - The content type's JSON schema\n * @returns Converted data suitable for initialData\n */\nfunction convertPrefillToFormData(\n\tprefillParams: Record,\n\tjsonSchema: Record,\n): Record {\n\tconst properties = jsonSchema.properties as\n\t\t| Record\n\t\t| undefined;\n\n\tif (!properties) {\n\t\treturn prefillParams;\n\t}\n\n\tconst result: Record = {};\n\n\tfor (const [fieldName, value] of Object.entries(prefillParams)) {\n\t\tconst fieldSchema = properties[fieldName];\n\n\t\tif (fieldSchema?.fieldType === \"relation\" && fieldSchema.relation) {\n\t\t\t// Convert relation field value to the correct format\n\t\t\tif (fieldSchema.relation.type === \"belongsTo\") {\n\t\t\t\t// belongsTo expects { id: \"uuid\" }\n\t\t\t\tresult[fieldName] = { id: value };\n\t\t\t} else {\n\t\t\t\t// hasMany/manyToMany expect [{ id: \"uuid\" }]\n\t\t\t\tresult[fieldName] = [{ id: value }];\n\t\t\t}\n\t\t} else {\n\t\t\t// Non-relation fields: pass through as-is\n\t\t\tresult[fieldName] = value;\n\t\t}\n\t}\n\n\treturn result;\n}\n\ninterface ContentEditorPageProps {\n\ttypeSlug: string;\n\tid?: string;\n}\n\nexport function ContentEditorPage({ typeSlug, id }: ContentEditorPageProps) {\n\tconst t = useTranslate();\n\tconst overrides = usePluginOverrides(CMS_PLUGIN_ID);\n\tconst { localization } = overrides;\n\tconst { router, plugins, basePath: legacyBasePath } = useStack();\n\tconst navigate = router?.navigate;\n\tconst basePath = plugins?.[CMS_PLUGIN_ID]?.site.basePath ?? legacyBasePath;\n\n\t// Parse prefill query parameters for pre-populating fields when creating new items\n\t// This is used by the inverse relations panel to pre-fill the parent relation\n\tconst prefillParams = usePrefillParams();\n\n\t// Call route lifecycle hooks for telemetry and application behavior.\n\tuseRouteLifecycle({\n\t\trouteName: \"contentEditor\",\n\t\tcontext: {\n\t\t\tpath: id ? `/cms/${typeSlug}/${id}` : `/cms/${typeSlug}/new`,\n\t\t\tparams: id ? { typeSlug, id } : { typeSlug },\n\t\t\tisSSR: typeof window === \"undefined\",\n\t\t},\n\t\toverrides,\n\t});\n\n\tconst { contentTypes: serverContentTypes } = useSuspenseContentTypes();\n\tconst contentTypes = orderCMSContentTypes(\n\t\tserverContentTypes,\n\t\tplugins?.[CMS_PLUGIN_ID]?.config,\n\t);\n\tconst contentType = contentTypes.find((ct) => ct.slug === typeSlug);\n\n\tconst isEditing = !!id;\n\n\t// useContentItem has enabled: !!id built-in, so it won't fetch when creating new items\n\t// This avoids conditional hook calls which violate React's Rules of Hooks\n\tconst { item, isLoading: isLoadingItem } = useContentItem(typeSlug, id ?? \"\");\n\n\t// Core resource form: submits the right mutation, awaits invalidation,\n\t// notifies success/error via useNotify(), and exposes server validation\n\t// issues as fieldErrors for inline display.\n\tconst resourceForm = useContentItemForm<{\n\t\tslug: string;\n\t\tdata: Record;\n\t}>({\n\t\taction: isEditing ? \"edit\" : \"create\",\n\t\trecord: isEditing ? item : null,\n\t\tsuccessMessage: (_result, action) =>\n\t\t\taction === \"create\"\n\t\t\t\t? (localization?.CMS_TOAST_CREATE_SUCCESS ??\n\t\t\t\t\tt(\"cms.toasts.createSuccess\", \"Item created successfully\"))\n\t\t\t\t: (localization?.CMS_TOAST_UPDATE_SUCCESS ??\n\t\t\t\t\tt(\"cms.toasts.updateSuccess\", \"Item updated successfully\")),\n\t\ttoCreateVars: (values) => ({\n\t\t\ttypeSlug,\n\t\t\tslug: values.slug,\n\t\t\tdata: values.data,\n\t\t}),\n\t\ttoUpdateVars: (values) => ({\n\t\t\ttypeSlug,\n\t\t\tid: id ?? \"\",\n\t\t\tdata: { slug: values.slug, data: values.data },\n\t\t}),\n\t\tonSuccess: () => {\n\t\t\tvoid navigate?.(joinBasePath(basePath, `/cms/${typeSlug}`));\n\t\t},\n\t});\n\n\tif (!contentType) {\n\t\treturn (\n\t\t\t\n\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\t// Show loading skeleton while fetching item in edit mode\n\tif (isEditing && isLoadingItem) {\n\t\treturn (\n\t\t\t\n\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\tif (isEditing && !item) {\n\t\treturn (\n\t\t\t\n\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\t// resourceForm.submit never throws: success notifies + navigates via the\n\t// config above; errors land on resourceForm.error / fieldErrors.\n\tconst handleSubmit = async (data: {\n\t\tslug: string;\n\t\tdata: Record;\n\t}) => {\n\t\tawait resourceForm.submit(data);\n\t};\n\n\tconst hasFieldErrors = Object.keys(resourceForm.fieldErrors).length > 0;\n\n\tconst title = isEditing\n\t\t? (\n\t\t\t\tlocalization?.CMS_EDITOR_TITLE_EDIT ??\n\t\t\t\tt(\"cms.editor.titleEdit\", \"Edit {typeName}\")\n\t\t\t).replace(\"{typeName}\", contentType.name)\n\t\t: (\n\t\t\t\tlocalization?.CMS_EDITOR_TITLE_NEW ??\n\t\t\t\tt(\"cms.editor.titleNew\", \"New {typeName}\")\n\t\t\t).replace(\"{typeName}\", contentType.name);\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\t\t\tvoid navigate?.(joinBasePath(basePath, `/cms/${typeSlug}`))\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

{title}

\n\t\t\t\t
\n\n\t\t\t\t 0\n\t\t\t\t\t\t\t\t? convertPrefillToFormData(\n\t\t\t\t\t\t\t\t\t\tprefillParams,\n\t\t\t\t\t\t\t\t\t\tJSON.parse(contentType.jsonSchema),\n\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t: undefined\n\t\t\t\t\t}\n\t\t\t\t\tinitialSlug={item?.slug}\n\t\t\t\t\tisEditing={isEditing}\n\t\t\t\t\tonSubmit={handleSubmit}\n\t\t\t\t\tonCancel={() =>\n\t\t\t\t\t\tvoid navigate?.(joinBasePath(basePath, `/cms/${typeSlug}`))\n\t\t\t\t\t}\n\t\t\t\t\tfieldErrors={resourceForm.fieldErrors}\n\t\t\t\t\terrorMessage={\n\t\t\t\t\t\thasFieldErrors ? undefined : resourceForm.error?.message\n\t\t\t\t\t}\n\t\t\t\t\tisSubmitting={resourceForm.isSubmitting}\n\t\t\t\t/>\n\n\t\t\t\t{/* Show inverse relations panel when editing (not creating) */}\n\t\t\t\t{isEditing && id && (\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t
\n\t\t
\n\t);\n}\n", + "content": "\"use client\";\n\nimport { useState, useEffect } from \"react\";\nimport { ArrowLeft } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n\tjoinBasePath,\n\tusePluginOverrides,\n\tuseStack,\n\tuseTranslate,\n} from \"@btst/stack/context\";\nimport { orderCMSContentTypes, type CMSPluginOverrides } from \"../../overrides\";\nimport { CMS_PLUGIN_ID } from \"../../constants\";\nimport {\n\tuseSuspenseContentTypes,\n\tuseContentItem,\n\tuseContentItemForm,\n} from \"@btst/stack/plugins/cms/client/hooks\";\nimport { ContentForm } from \"../forms/content-form\";\nimport { InverseRelationsPanel } from \"../inverse-relations-panel\";\nimport { EmptyState } from \"../shared/empty-state\";\nimport { PageWrapper } from \"../shared/page-wrapper\";\nimport { EditorSkeleton } from \"../loading/editor-skeleton\";\nimport { useRouteLifecycle } from \"@/hooks/use-route-lifecycle\";\n\n/**\n * Parse prefill query parameters from the URL.\n * Looks for query params with the format `prefill_=`\n * and returns a record of field names to values.\n *\n * Uses useState + useEffect pattern to work correctly with SSR/hydration.\n * During SSR, returns empty object. After hydration, parses URL params.\n * Also listens for popstate events to handle browser back/forward navigation.\n *\n * @example\n * URL: /cms/comment/new?prefill_resourceId=123&prefill_author=John\n * Returns: { resourceId: \"123\", author: \"John\" }\n */\nfunction usePrefillParams(): Record {\n\tconst [prefillData, setPrefillData] = useState>({});\n\n\tuseEffect(() => {\n\t\tif (typeof window === \"undefined\") {\n\t\t\treturn;\n\t\t}\n\n\t\tconst parseAndSetPrefillData = () => {\n\t\t\tconst params = new URLSearchParams(window.location.search);\n\t\t\tconst data: Record = {};\n\n\t\t\tfor (const [key, value] of params.entries()) {\n\t\t\t\tif (key.startsWith(\"prefill_\")) {\n\t\t\t\t\tconst fieldName = key.slice(\"prefill_\".length);\n\t\t\t\t\tif (fieldName) {\n\t\t\t\t\t\tdata[fieldName] = value;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Always update state to ensure stale data is cleared when navigating\n\t\t\t// to a URL without prefill params (e.g., via browser back/forward)\n\t\t\tsetPrefillData(data);\n\t\t};\n\n\t\t// Parse on mount\n\t\tparseAndSetPrefillData();\n\n\t\t// Listen for popstate events (browser back/forward navigation)\n\t\twindow.addEventListener(\"popstate\", parseAndSetPrefillData);\n\n\t\treturn () => {\n\t\t\twindow.removeEventListener(\"popstate\", parseAndSetPrefillData);\n\t\t};\n\t}, []);\n\n\treturn prefillData;\n}\n\ninterface JsonSchemaProperty {\n\tfieldType?: string;\n\trelation?: {\n\t\ttype: \"belongsTo\" | \"hasMany\" | \"manyToMany\";\n\t\ttargetType: string;\n\t};\n}\n\n/**\n * Convert prefill params to the correct format for the form.\n * Relation fields need special handling:\n * - belongsTo: value should be { id: \"uuid\" }\n * - hasMany/manyToMany: value should be [{ id: \"uuid\" }]\n *\n * @param prefillParams - Raw prefill params from URL\n * @param jsonSchema - The content type's JSON schema\n * @returns Converted data suitable for initialData\n */\nfunction convertPrefillToFormData(\n\tprefillParams: Record,\n\tjsonSchema: Record,\n): Record {\n\tconst properties = jsonSchema.properties as\n\t\t| Record\n\t\t| undefined;\n\n\tif (!properties) {\n\t\treturn prefillParams;\n\t}\n\n\tconst result: Record = {};\n\n\tfor (const [fieldName, value] of Object.entries(prefillParams)) {\n\t\tconst fieldSchema = properties[fieldName];\n\n\t\tif (fieldSchema?.fieldType === \"relation\" && fieldSchema.relation) {\n\t\t\t// Convert relation field value to the correct format\n\t\t\tif (fieldSchema.relation.type === \"belongsTo\") {\n\t\t\t\t// belongsTo expects { id: \"uuid\" }\n\t\t\t\tresult[fieldName] = { id: value };\n\t\t\t} else {\n\t\t\t\t// hasMany/manyToMany expect [{ id: \"uuid\" }]\n\t\t\t\tresult[fieldName] = [{ id: value }];\n\t\t\t}\n\t\t} else {\n\t\t\t// Non-relation fields: pass through as-is\n\t\t\tresult[fieldName] = value;\n\t\t}\n\t}\n\n\treturn result;\n}\n\ninterface ContentEditorPageProps {\n\ttypeSlug: string;\n\tid?: string;\n}\n\nexport function ContentEditorPage({ typeSlug, id }: ContentEditorPageProps) {\n\tconst t = useTranslate();\n\tconst overrides = usePluginOverrides(CMS_PLUGIN_ID);\n\tconst { localization } = overrides;\n\tconst { router, plugins, basePath: stackBasePath } = useStack();\n\tconst navigate = router?.navigate;\n\tconst basePath = plugins?.[CMS_PLUGIN_ID]?.site.basePath ?? stackBasePath;\n\n\t// Parse prefill query parameters for pre-populating fields when creating new items\n\t// This is used by the inverse relations panel to pre-fill the parent relation\n\tconst prefillParams = usePrefillParams();\n\n\t// Call route lifecycle hooks for telemetry and application behavior.\n\tuseRouteLifecycle({\n\t\trouteName: \"contentEditor\",\n\t\tcontext: {\n\t\t\tpath: id ? `/cms/${typeSlug}/${id}` : `/cms/${typeSlug}/new`,\n\t\t\tparams: id ? { typeSlug, id } : { typeSlug },\n\t\t\tisSSR: typeof window === \"undefined\",\n\t\t},\n\t\toverrides,\n\t});\n\n\tconst { contentTypes: serverContentTypes } = useSuspenseContentTypes();\n\tconst contentTypes = orderCMSContentTypes(\n\t\tserverContentTypes,\n\t\tplugins?.[CMS_PLUGIN_ID]?.config,\n\t);\n\tconst contentType = contentTypes.find((ct) => ct.slug === typeSlug);\n\n\tconst isEditing = !!id;\n\n\t// useContentItem has enabled: !!id built-in, so it won't fetch when creating new items\n\t// This avoids conditional hook calls which violate React's Rules of Hooks\n\tconst { item, isLoading: isLoadingItem } = useContentItem(typeSlug, id ?? \"\");\n\n\t// Core resource form: submits the right mutation, awaits invalidation,\n\t// notifies success/error via useNotify(), and exposes server validation\n\t// issues as fieldErrors for inline display.\n\tconst resourceForm = useContentItemForm<{\n\t\tslug: string;\n\t\tdata: Record;\n\t}>({\n\t\taction: isEditing ? \"edit\" : \"create\",\n\t\trecord: isEditing ? item : null,\n\t\tsuccessMessage: (_result, action) =>\n\t\t\taction === \"create\"\n\t\t\t\t? (localization?.CMS_TOAST_CREATE_SUCCESS ??\n\t\t\t\t\tt(\"cms.toasts.createSuccess\", \"Item created successfully\"))\n\t\t\t\t: (localization?.CMS_TOAST_UPDATE_SUCCESS ??\n\t\t\t\t\tt(\"cms.toasts.updateSuccess\", \"Item updated successfully\")),\n\t\ttoCreateVars: (values) => ({\n\t\t\ttypeSlug,\n\t\t\tslug: values.slug,\n\t\t\tdata: values.data,\n\t\t}),\n\t\ttoUpdateVars: (values) => ({\n\t\t\ttypeSlug,\n\t\t\tid: id ?? \"\",\n\t\t\tdata: { slug: values.slug, data: values.data },\n\t\t}),\n\t\tonSuccess: () => {\n\t\t\tvoid navigate?.(joinBasePath(basePath, `/cms/${typeSlug}`));\n\t\t},\n\t});\n\n\tif (!contentType) {\n\t\treturn (\n\t\t\t\n\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\t// Show loading skeleton while fetching item in edit mode\n\tif (isEditing && isLoadingItem) {\n\t\treturn (\n\t\t\t\n\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\tif (isEditing && !item) {\n\t\treturn (\n\t\t\t\n\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\t// resourceForm.submit never throws: success notifies + navigates via the\n\t// config above; errors land on resourceForm.error / fieldErrors.\n\tconst handleSubmit = async (data: {\n\t\tslug: string;\n\t\tdata: Record;\n\t}) => {\n\t\tawait resourceForm.submit(data);\n\t};\n\n\tconst hasFieldErrors = Object.keys(resourceForm.fieldErrors).length > 0;\n\n\tconst title = isEditing\n\t\t? (\n\t\t\t\tlocalization?.CMS_EDITOR_TITLE_EDIT ??\n\t\t\t\tt(\"cms.editor.titleEdit\", \"Edit {typeName}\")\n\t\t\t).replace(\"{typeName}\", contentType.name)\n\t\t: (\n\t\t\t\tlocalization?.CMS_EDITOR_TITLE_NEW ??\n\t\t\t\tt(\"cms.editor.titleNew\", \"New {typeName}\")\n\t\t\t).replace(\"{typeName}\", contentType.name);\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\t\t\tvoid navigate?.(joinBasePath(basePath, `/cms/${typeSlug}`))\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

{title}

\n\t\t\t\t
\n\n\t\t\t\t 0\n\t\t\t\t\t\t\t\t? convertPrefillToFormData(\n\t\t\t\t\t\t\t\t\t\tprefillParams,\n\t\t\t\t\t\t\t\t\t\tJSON.parse(contentType.jsonSchema),\n\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t: undefined\n\t\t\t\t\t}\n\t\t\t\t\tinitialSlug={item?.slug}\n\t\t\t\t\tisEditing={isEditing}\n\t\t\t\t\tonSubmit={handleSubmit}\n\t\t\t\t\tonCancel={() =>\n\t\t\t\t\t\tvoid navigate?.(joinBasePath(basePath, `/cms/${typeSlug}`))\n\t\t\t\t\t}\n\t\t\t\t\tfieldErrors={resourceForm.fieldErrors}\n\t\t\t\t\terrorMessage={\n\t\t\t\t\t\thasFieldErrors ? undefined : resourceForm.error?.message\n\t\t\t\t\t}\n\t\t\t\t\tisSubmitting={resourceForm.isSubmitting}\n\t\t\t\t/>\n\n\t\t\t\t{/* Show inverse relations panel when editing (not creating) */}\n\t\t\t\t{isEditing && id && (\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/cms/client/components/pages/content-editor-page.internal.tsx" }, { @@ -121,7 +121,7 @@ { "path": "btst/cms/client/components/pages/content-list-page.internal.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport { Plus, ArrowLeft, Pencil, Trash2, Loader2, Search } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport {\n\tTable,\n\tTableBody,\n\tTableCell,\n\tTableHead,\n\tTableHeader,\n\tTableRow,\n} from \"@/components/ui/table\";\nimport {\n\tjoinBasePath,\n\tPermissionAccess,\n\tuseNotify,\n\tusePluginOverrides,\n\tuseStack,\n\tuseTranslate,\n\ttype TranslateFn,\n} from \"@btst/stack/context\";\nimport { cmsPermissions } from \"../../../permissions\";\nimport { useListState, type ListStateSchema } from \"@btst/stack/client/hooks\";\nimport { orderCMSContentTypes, type CMSPluginOverrides } from \"../../overrides\";\nimport { CMS_PLUGIN_ID } from \"../../constants\";\nimport type { SerializedContentItemWithType } from \"../../../types\";\nimport {\n\tuseContent,\n\tuseSuspenseContent,\n\tuseSuspenseContentTypes,\n\tuseDeleteContent,\n} from \"@btst/stack/plugins/cms/client/hooks\";\nimport { EmptyState } from \"../shared/empty-state\";\nimport { PageWrapper } from \"../shared/page-wrapper\";\nimport { useRouteLifecycle } from \"@/hooks/use-route-lifecycle\";\n\ninterface ContentListPageProps {\n\ttypeSlug: string;\n}\n\n// URL-synced search state: `?q=...` while typing (history: replace), clean\n// URL when the query is empty (the default is omitted from the URL).\nconst LIST_STATE_SCHEMA = {\n\tq: { type: \"string\", default: \"\", history: \"replace\" },\n} as const satisfies ListStateSchema;\n\nconst SEARCH_DEBOUNCE_MS = 300;\n\nexport function ContentListPage({ typeSlug }: ContentListPageProps) {\n\tconst t = useTranslate();\n\tconst notify = useNotify();\n\tconst overrides = usePluginOverrides(CMS_PLUGIN_ID);\n\tconst { localization } = overrides;\n\tconst { router, plugins, basePath: legacyBasePath } = useStack();\n\tconst navigate = router?.navigate;\n\tconst Link = router?.Link;\n\tconst basePath = plugins?.[CMS_PLUGIN_ID]?.site.basePath ?? legacyBasePath;\n\n\t// Call route lifecycle hooks for telemetry and application behavior.\n\tuseRouteLifecycle({\n\t\trouteName: \"contentList\",\n\t\tcontext: {\n\t\t\tpath: `/cms/${typeSlug}`,\n\t\t\tparams: { typeSlug },\n\t\t\tisSSR: typeof window === \"undefined\",\n\t\t},\n\t\toverrides,\n\t});\n\n\tconst limit = 20;\n\n\tconst [{ q: search }, setListState] = useListState(\n\t\t`cms-${typeSlug}`,\n\t\tLIST_STATE_SCHEMA,\n\t);\n\n\t// Local input state debounced into the URL-synced query, so the list\n\t// query (and URL) only update after the user pauses typing.\n\tconst [searchInput, setSearchInput] = useState(search);\n\n\t// External `q` changes (hydration after SSR-empty search params,\n\t// back/forward navigation) re-seed the input instead of being clobbered\n\t// by the debounced write below, which only reflects user edits.\n\tconst lastSyncedSearch = useRef(search);\n\tuseEffect(() => {\n\t\tif (search !== lastSyncedSearch.current) {\n\t\t\tlastSyncedSearch.current = search;\n\t\t\tsetSearchInput(search);\n\t\t}\n\t}, [search]);\n\n\tuseEffect(() => {\n\t\tif (searchInput === search) return;\n\t\tconst timeout = setTimeout(() => {\n\t\t\tlastSyncedSearch.current = searchInput;\n\t\t\tsetListState({ q: searchInput });\n\t\t}, SEARCH_DEBOUNCE_MS);\n\t\treturn () => clearTimeout(timeout);\n\t}, [searchInput, search, setListState]);\n\n\tconst hasSearch = search.trim().length > 0;\n\n\tconst { contentTypes: serverContentTypes } = useSuspenseContentTypes();\n\tconst contentTypes = orderCMSContentTypes(\n\t\tserverContentTypes,\n\t\tplugins?.[CMS_PLUGIN_ID]?.config,\n\t);\n\tconst contentType = contentTypes.find((ct) => ct.slug === typeSlug);\n\n\t// The default (unsearched) list stays on the suspense hook so SSR/SSG\n\t// hydration works; the searched list uses the non-suspense hook so\n\t// typing shows an inline loading state instead of suspending the page.\n\tconst defaultList = useSuspenseContent(typeSlug, { limit });\n\tconst searchedList = useContent(typeSlug, {\n\t\tlimit,\n\t\tsearch,\n\t\tenabled: hasSearch,\n\t});\n\n\tconst activeList = hasSearch ? searchedList : defaultList;\n\tconst { items, total, loadMore, hasMore, isLoadingMore } = activeList;\n\tconst isSearchLoading = hasSearch && searchedList.isLoading;\n\n\tconst deleteContent = useDeleteContent(typeSlug);\n\n\tconst LinkComponent = Link || \"a\";\n\n\tconst handleDelete = async (id: string) => {\n\t\ttry {\n\t\t\tawait deleteContent.mutateAsync(id);\n\t\t} catch {\n\t\t\tnotify.error(\n\t\t\t\tlocalization?.CMS_TOAST_ERROR ??\n\t\t\t\t\tt(\"cms.toasts.error\", \"An error occurred. Please try again.\"),\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\t\tnotify.success(\n\t\t\tlocalization?.CMS_TOAST_DELETE_SUCCESS ??\n\t\t\t\tt(\"cms.toasts.deleteSuccess\", \"Item deleted successfully\"),\n\t\t);\n\t};\n\n\tconst formatDate = (dateString: string) => {\n\t\treturn new Date(dateString).toLocaleDateString();\n\t};\n\n\tif (!contentType) {\n\t\treturn (\n\t\t\t\n\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\treturn (\n\t\t\n\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t\t void navigate?.(joinBasePath(basePath, \"/cms\"))}\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{contentType.name}\n\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t{contentType.description && (\n\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t{contentType.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\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\tvoid navigate?.(joinBasePath(basePath, `/cms/${typeSlug}/new`))\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{localization?.CMS_BUTTON_NEW_ITEM ??\n\t\t\t\t\t\t\t\tt(\"cms.common.newItem\", \"New Item\")}\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t
\n\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t setSearchInput(e.target.value)}\n\t\t\t\t\t\tplaceholder={\n\t\t\t\t\t\t\tlocalization?.CMS_LIST_SEARCH_PLACEHOLDER ??\n\t\t\t\t\t\t\tt(\"cms.list.searchPlaceholder\", \"Search items...\")\n\t\t\t\t\t\t}\n\t\t\t\t\t\tclassName=\"pl-9\"\n\t\t\t\t\t/>\n\t\t\t\t\t{isSearchLoading && (\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t
\n\n\t\t\t\t{items.length === 0 ? (\n\t\t\t\t\tisSearchLoading ? null : hasSearch ? (\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\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\tvoid navigate?.(\n\t\t\t\t\t\t\t\t\t\t\t\tjoinBasePath(basePath, `/cms/${typeSlug}/new`),\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\t\n\t\t\t\t\t\t\t\t\t\t{localization?.CMS_BUTTON_CREATE ??\n\t\t\t\t\t\t\t\t\t\t\tt(\"cms.common.create\", \"Create\")}\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\t\t\n\t\t\t\t)}\n\t\t\t
\n\t\t
\n\t);\n}\n\nfunction ContentTable({\n\titems,\n\ttotal,\n\ttypeSlug,\n\tbasePath,\n\tLinkComponent,\n\tnavigate,\n\tonDelete,\n\tisDeleting,\n\tformatDate,\n\tloadMore,\n\thasMore,\n\tisLoadingMore,\n\tlocalization,\n\tt,\n}: {\n\titems: SerializedContentItemWithType[];\n\ttotal: number;\n\ttypeSlug: string;\n\tbasePath: string;\n\tLinkComponent: React.ElementType;\n\tnavigate?: (path: string) => void | Promise;\n\tonDelete: (id: string) => void;\n\tisDeleting: boolean;\n\tformatDate: (dateString: string) => string;\n\tloadMore: () => void;\n\thasMore: boolean;\n\tisLoadingMore: boolean;\n\tlocalization: CMSPluginOverrides[\"localization\"];\n\tt: TranslateFn;\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\t\t\n\t\t\t\t\t\t\t{localization?.CMS_LIST_COLUMN_SLUG ??\n\t\t\t\t\t\t\t\tt(\"cms.list.columnSlug\", \"Slug\")}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.CMS_LIST_COLUMN_CREATED ??\n\t\t\t\t\t\t\t\tt(\"cms.list.columnCreated\", \"Created\")}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.CMS_LIST_COLUMN_UPDATED ??\n\t\t\t\t\t\t\t\tt(\"cms.list.columnUpdated\", \"Updated\")}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.CMS_LIST_COLUMN_ACTIONS ??\n\t\t\t\t\t\t\t\tt(\"cms.list.columnActions\", \"Actions\")}\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t{items.map((item) => (\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{item.slug}\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{formatDate(item.createdAt)}\n\t\t\t\t\t\t\t{formatDate(item.updatedAt)}\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\n\t\t\t\t\t\t\t\t\t\t\t\tvoid navigate?.(\n\t\t\t\t\t\t\t\t\t\t\t\t\tjoinBasePath(basePath, `/cms/${typeSlug}/${item.id}`),\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\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 onDelete(item.id)}\n\t\t\t\t\t\t\t\t\t\t\tdisabled={isDeleting}\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\t\t\t
\n\t\t\t{/* Load More and pagination info */}\n\t\t\t
\n\t\t\t\t

\n\t\t\t\t\t{(\n\t\t\t\t\t\tlocalization?.CMS_LIST_PAGINATION_SHOWING ??\n\t\t\t\t\t\tt(\"cms.list.paginationShowing\", \"Showing {from}-{to} of {total}\")\n\t\t\t\t\t)\n\t\t\t\t\t\t.replace(\"{from}\", \"1\")\n\t\t\t\t\t\t.replace(\"{to}\", String(items.length))\n\t\t\t\t\t\t.replace(\"{total}\", String(total))}\n\t\t\t\t

\n\t\t\t\t{hasMore && (\n\t\t\t\t\t loadMore()}\n\t\t\t\t\t\tdisabled={isLoadingMore}\n\t\t\t\t\t>\n\t\t\t\t\t\t{isLoadingMore && }\n\t\t\t\t\t\t{localization?.CMS_LIST_PAGINATION_NEXT ??\n\t\t\t\t\t\t\tt(\"cms.list.paginationNext\", \"Next\")}\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t
\n\t\t
\n\t);\n}\n", + "content": "\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport { Plus, ArrowLeft, Pencil, Trash2, Loader2, Search } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport {\n\tTable,\n\tTableBody,\n\tTableCell,\n\tTableHead,\n\tTableHeader,\n\tTableRow,\n} from \"@/components/ui/table\";\nimport {\n\tjoinBasePath,\n\tPermissionAccess,\n\tuseNotify,\n\tusePluginOverrides,\n\tuseStack,\n\tuseTranslate,\n\ttype TranslateFn,\n} from \"@btst/stack/context\";\nimport { cmsPermissions } from \"../../../permissions\";\nimport { useListState, type ListStateSchema } from \"@btst/stack/client/hooks\";\nimport { orderCMSContentTypes, type CMSPluginOverrides } from \"../../overrides\";\nimport { CMS_PLUGIN_ID } from \"../../constants\";\nimport type { SerializedContentItemWithType } from \"../../../types\";\nimport {\n\tuseContent,\n\tuseSuspenseContent,\n\tuseSuspenseContentTypes,\n\tuseDeleteContent,\n} from \"@btst/stack/plugins/cms/client/hooks\";\nimport { EmptyState } from \"../shared/empty-state\";\nimport { PageWrapper } from \"../shared/page-wrapper\";\nimport { useRouteLifecycle } from \"@/hooks/use-route-lifecycle\";\n\ninterface ContentListPageProps {\n\ttypeSlug: string;\n}\n\n// URL-synced search state: `?q=...` while typing (history: replace), clean\n// URL when the query is empty (the default is omitted from the URL).\nconst LIST_STATE_SCHEMA = {\n\tq: { type: \"string\", default: \"\", history: \"replace\" },\n} as const satisfies ListStateSchema;\n\nconst SEARCH_DEBOUNCE_MS = 300;\n\nexport function ContentListPage({ typeSlug }: ContentListPageProps) {\n\tconst t = useTranslate();\n\tconst notify = useNotify();\n\tconst overrides = usePluginOverrides(CMS_PLUGIN_ID);\n\tconst { localization } = overrides;\n\tconst { router, plugins, basePath: stackBasePath } = useStack();\n\tconst navigate = router?.navigate;\n\tconst Link = router?.Link;\n\tconst basePath = plugins?.[CMS_PLUGIN_ID]?.site.basePath ?? stackBasePath;\n\n\t// Call route lifecycle hooks for telemetry and application behavior.\n\tuseRouteLifecycle({\n\t\trouteName: \"contentList\",\n\t\tcontext: {\n\t\t\tpath: `/cms/${typeSlug}`,\n\t\t\tparams: { typeSlug },\n\t\t\tisSSR: typeof window === \"undefined\",\n\t\t},\n\t\toverrides,\n\t});\n\n\tconst limit = 20;\n\n\tconst [{ q: search }, setListState] = useListState(\n\t\t`cms-${typeSlug}`,\n\t\tLIST_STATE_SCHEMA,\n\t);\n\n\t// Local input state debounced into the URL-synced query, so the list\n\t// query (and URL) only update after the user pauses typing.\n\tconst [searchInput, setSearchInput] = useState(search);\n\n\t// External `q` changes (hydration after SSR-empty search params,\n\t// back/forward navigation) re-seed the input instead of being clobbered\n\t// by the debounced write below, which only reflects user edits.\n\tconst lastSyncedSearch = useRef(search);\n\tuseEffect(() => {\n\t\tif (search !== lastSyncedSearch.current) {\n\t\t\tlastSyncedSearch.current = search;\n\t\t\tsetSearchInput(search);\n\t\t}\n\t}, [search]);\n\n\tuseEffect(() => {\n\t\tif (searchInput === search) return;\n\t\tconst timeout = setTimeout(() => {\n\t\t\tlastSyncedSearch.current = searchInput;\n\t\t\tsetListState({ q: searchInput });\n\t\t}, SEARCH_DEBOUNCE_MS);\n\t\treturn () => clearTimeout(timeout);\n\t}, [searchInput, search, setListState]);\n\n\tconst hasSearch = search.trim().length > 0;\n\n\tconst { contentTypes: serverContentTypes } = useSuspenseContentTypes();\n\tconst contentTypes = orderCMSContentTypes(\n\t\tserverContentTypes,\n\t\tplugins?.[CMS_PLUGIN_ID]?.config,\n\t);\n\tconst contentType = contentTypes.find((ct) => ct.slug === typeSlug);\n\n\t// The default (unsearched) list stays on the suspense hook so SSR/SSG\n\t// hydration works; the searched list uses the non-suspense hook so\n\t// typing shows an inline loading state instead of suspending the page.\n\tconst defaultList = useSuspenseContent(typeSlug, { limit });\n\tconst searchedList = useContent(typeSlug, {\n\t\tlimit,\n\t\tsearch,\n\t\tenabled: hasSearch,\n\t});\n\n\tconst activeList = hasSearch ? searchedList : defaultList;\n\tconst { items, total, loadMore, hasMore, isLoadingMore } = activeList;\n\tconst isSearchLoading = hasSearch && searchedList.isLoading;\n\n\tconst deleteContent = useDeleteContent(typeSlug);\n\n\tconst LinkComponent = Link || \"a\";\n\n\tconst handleDelete = async (id: string) => {\n\t\ttry {\n\t\t\tawait deleteContent.mutateAsync(id);\n\t\t} catch {\n\t\t\tnotify.error(\n\t\t\t\tlocalization?.CMS_TOAST_ERROR ??\n\t\t\t\t\tt(\"cms.toasts.error\", \"An error occurred. Please try again.\"),\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\t\tnotify.success(\n\t\t\tlocalization?.CMS_TOAST_DELETE_SUCCESS ??\n\t\t\t\tt(\"cms.toasts.deleteSuccess\", \"Item deleted successfully\"),\n\t\t);\n\t};\n\n\tconst formatDate = (dateString: string) => {\n\t\treturn new Date(dateString).toLocaleDateString();\n\t};\n\n\tif (!contentType) {\n\t\treturn (\n\t\t\t\n\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\treturn (\n\t\t\n\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t\t void navigate?.(joinBasePath(basePath, \"/cms\"))}\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{contentType.name}\n\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t{contentType.description && (\n\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t{contentType.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\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\tvoid navigate?.(joinBasePath(basePath, `/cms/${typeSlug}/new`))\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{localization?.CMS_BUTTON_NEW_ITEM ??\n\t\t\t\t\t\t\t\tt(\"cms.common.newItem\", \"New Item\")}\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t
\n\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t setSearchInput(e.target.value)}\n\t\t\t\t\t\tplaceholder={\n\t\t\t\t\t\t\tlocalization?.CMS_LIST_SEARCH_PLACEHOLDER ??\n\t\t\t\t\t\t\tt(\"cms.list.searchPlaceholder\", \"Search items...\")\n\t\t\t\t\t\t}\n\t\t\t\t\t\tclassName=\"pl-9\"\n\t\t\t\t\t/>\n\t\t\t\t\t{isSearchLoading && (\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t
\n\n\t\t\t\t{items.length === 0 ? (\n\t\t\t\t\tisSearchLoading ? null : hasSearch ? (\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\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\tvoid navigate?.(\n\t\t\t\t\t\t\t\t\t\t\t\tjoinBasePath(basePath, `/cms/${typeSlug}/new`),\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\t\n\t\t\t\t\t\t\t\t\t\t{localization?.CMS_BUTTON_CREATE ??\n\t\t\t\t\t\t\t\t\t\t\tt(\"cms.common.create\", \"Create\")}\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\t\t\n\t\t\t\t)}\n\t\t\t
\n\t\t
\n\t);\n}\n\nfunction ContentTable({\n\titems,\n\ttotal,\n\ttypeSlug,\n\tbasePath,\n\tLinkComponent,\n\tnavigate,\n\tonDelete,\n\tisDeleting,\n\tformatDate,\n\tloadMore,\n\thasMore,\n\tisLoadingMore,\n\tlocalization,\n\tt,\n}: {\n\titems: SerializedContentItemWithType[];\n\ttotal: number;\n\ttypeSlug: string;\n\tbasePath: string;\n\tLinkComponent: React.ElementType;\n\tnavigate?: (path: string) => void | Promise;\n\tonDelete: (id: string) => void;\n\tisDeleting: boolean;\n\tformatDate: (dateString: string) => string;\n\tloadMore: () => void;\n\thasMore: boolean;\n\tisLoadingMore: boolean;\n\tlocalization: CMSPluginOverrides[\"localization\"];\n\tt: TranslateFn;\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\t\t\n\t\t\t\t\t\t\t{localization?.CMS_LIST_COLUMN_SLUG ??\n\t\t\t\t\t\t\t\tt(\"cms.list.columnSlug\", \"Slug\")}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.CMS_LIST_COLUMN_CREATED ??\n\t\t\t\t\t\t\t\tt(\"cms.list.columnCreated\", \"Created\")}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.CMS_LIST_COLUMN_UPDATED ??\n\t\t\t\t\t\t\t\tt(\"cms.list.columnUpdated\", \"Updated\")}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.CMS_LIST_COLUMN_ACTIONS ??\n\t\t\t\t\t\t\t\tt(\"cms.list.columnActions\", \"Actions\")}\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t{items.map((item) => (\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{item.slug}\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{formatDate(item.createdAt)}\n\t\t\t\t\t\t\t{formatDate(item.updatedAt)}\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\n\t\t\t\t\t\t\t\t\t\t\t\tvoid navigate?.(\n\t\t\t\t\t\t\t\t\t\t\t\t\tjoinBasePath(basePath, `/cms/${typeSlug}/${item.id}`),\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\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 onDelete(item.id)}\n\t\t\t\t\t\t\t\t\t\t\tdisabled={isDeleting}\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\t\t\t
\n\t\t\t{/* Load More and pagination info */}\n\t\t\t
\n\t\t\t\t

\n\t\t\t\t\t{(\n\t\t\t\t\t\tlocalization?.CMS_LIST_PAGINATION_SHOWING ??\n\t\t\t\t\t\tt(\"cms.list.paginationShowing\", \"Showing {from}-{to} of {total}\")\n\t\t\t\t\t)\n\t\t\t\t\t\t.replace(\"{from}\", \"1\")\n\t\t\t\t\t\t.replace(\"{to}\", String(items.length))\n\t\t\t\t\t\t.replace(\"{total}\", String(total))}\n\t\t\t\t

\n\t\t\t\t{hasMore && (\n\t\t\t\t\t loadMore()}\n\t\t\t\t\t\tdisabled={isLoadingMore}\n\t\t\t\t\t>\n\t\t\t\t\t\t{isLoadingMore && }\n\t\t\t\t\t\t{localization?.CMS_LIST_PAGINATION_NEXT ??\n\t\t\t\t\t\t\tt(\"cms.list.paginationNext\", \"Next\")}\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/cms/client/components/pages/content-list-page.internal.tsx" }, { @@ -133,7 +133,7 @@ { "path": "btst/cms/client/components/pages/dashboard-page.internal.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { FileText } from \"lucide-react\";\nimport {\n\tCard,\n\tCardContent,\n\tCardHeader,\n\tCardTitle,\n} from \"@/components/ui/card\";\nimport {\n\tjoinBasePath,\n\tusePluginOverrides,\n\tuseStack,\n\tuseTranslate,\n} from \"@btst/stack/context\";\nimport { orderCMSContentTypes, type CMSPluginOverrides } from \"../../overrides\";\nimport { CMS_PLUGIN_ID } from \"../../constants\";\nimport { useSuspenseContentTypes } from \"@btst/stack/plugins/cms/client/hooks\";\nimport { EmptyState } from \"../shared/empty-state\";\nimport { PageWrapper } from \"../shared/page-wrapper\";\nimport { useRouteLifecycle } from \"@/hooks/use-route-lifecycle\";\n\nexport function DashboardPage() {\n\tconst t = useTranslate();\n\tconst overrides = usePluginOverrides(CMS_PLUGIN_ID);\n\tconst { localization } = overrides;\n\tconst { router, plugins, basePath: legacyBasePath } = useStack();\n\tconst navigate = router?.navigate;\n\tconst basePath = plugins?.[CMS_PLUGIN_ID]?.site.basePath ?? legacyBasePath;\n\n\t// Call route lifecycle hooks for telemetry and application behavior.\n\tuseRouteLifecycle({\n\t\trouteName: \"dashboard\",\n\t\tcontext: {\n\t\t\tpath: \"/cms\",\n\t\t\tisSSR: typeof window === \"undefined\",\n\t\t},\n\t\toverrides,\n\t});\n\tconst { contentTypes: serverContentTypes } = useSuspenseContentTypes();\n\tconst contentTypes = orderCMSContentTypes(\n\t\tserverContentTypes,\n\t\tplugins?.[CMS_PLUGIN_ID]?.config,\n\t);\n\n\tconst title =\n\t\tlocalization?.CMS_DASHBOARD_TITLE ?? t(\"cms.dashboard.title\", \"Content\");\n\tconst subtitle =\n\t\tlocalization?.CMS_DASHBOARD_SUBTITLE ??\n\t\tt(\"cms.dashboard.subtitle\", \"Manage your content types\");\n\n\tif (contentTypes.length === 0) {\n\t\treturn (\n\t\t\t\n\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t\t

{title}

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

{subtitle}

\n\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\tconst formatItemCount = (count: number) => {\n\t\tif (count === 0)\n\t\t\treturn (\n\t\t\t\tlocalization?.CMS_DASHBOARD_ITEMS_COUNT_ZERO ??\n\t\t\t\tt(\"cms.dashboard.itemsCountZero\", \"No items\")\n\t\t\t);\n\t\tif (count === 1)\n\t\t\treturn (\n\t\t\t\tlocalization?.CMS_DASHBOARD_ITEMS_COUNT_ONE ??\n\t\t\t\tt(\"cms.dashboard.itemsCountOne\", \"1 item\")\n\t\t\t);\n\t\treturn (\n\t\t\tlocalization?.CMS_DASHBOARD_ITEMS_COUNT ??\n\t\t\tt(\"cms.dashboard.itemsCount\", \"{count} items\")\n\t\t).replace(\"{count}\", String(count));\n\t};\n\n\treturn (\n\t\t\n\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t

{title}

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

{subtitle}

\n\t\t\t\t
\n\n\t\t\t\t
\n\t\t\t\t\t{contentTypes.map((ct) => (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\tvoid navigate?.(joinBasePath(basePath, `/cms/${ct.slug}`))\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{ct.name}\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
{ct.itemCount}
\n\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t{formatItemCount(ct.itemCount)}\n\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t{ct.description && (\n\t\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t\t{ct.description}\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", + "content": "\"use client\";\n\nimport { FileText } from \"lucide-react\";\nimport {\n\tCard,\n\tCardContent,\n\tCardHeader,\n\tCardTitle,\n} from \"@/components/ui/card\";\nimport {\n\tjoinBasePath,\n\tusePluginOverrides,\n\tuseStack,\n\tuseTranslate,\n} from \"@btst/stack/context\";\nimport { orderCMSContentTypes, type CMSPluginOverrides } from \"../../overrides\";\nimport { CMS_PLUGIN_ID } from \"../../constants\";\nimport { useSuspenseContentTypes } from \"@btst/stack/plugins/cms/client/hooks\";\nimport { EmptyState } from \"../shared/empty-state\";\nimport { PageWrapper } from \"../shared/page-wrapper\";\nimport { useRouteLifecycle } from \"@/hooks/use-route-lifecycle\";\n\nexport function DashboardPage() {\n\tconst t = useTranslate();\n\tconst overrides = usePluginOverrides(CMS_PLUGIN_ID);\n\tconst { localization } = overrides;\n\tconst { router, plugins, basePath: stackBasePath } = useStack();\n\tconst navigate = router?.navigate;\n\tconst basePath = plugins?.[CMS_PLUGIN_ID]?.site.basePath ?? stackBasePath;\n\n\t// Call route lifecycle hooks for telemetry and application behavior.\n\tuseRouteLifecycle({\n\t\trouteName: \"dashboard\",\n\t\tcontext: {\n\t\t\tpath: \"/cms\",\n\t\t\tisSSR: typeof window === \"undefined\",\n\t\t},\n\t\toverrides,\n\t});\n\tconst { contentTypes: serverContentTypes } = useSuspenseContentTypes();\n\tconst contentTypes = orderCMSContentTypes(\n\t\tserverContentTypes,\n\t\tplugins?.[CMS_PLUGIN_ID]?.config,\n\t);\n\n\tconst title =\n\t\tlocalization?.CMS_DASHBOARD_TITLE ?? t(\"cms.dashboard.title\", \"Content\");\n\tconst subtitle =\n\t\tlocalization?.CMS_DASHBOARD_SUBTITLE ??\n\t\tt(\"cms.dashboard.subtitle\", \"Manage your content types\");\n\n\tif (contentTypes.length === 0) {\n\t\treturn (\n\t\t\t\n\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t\t

{title}

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

{subtitle}

\n\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\tconst formatItemCount = (count: number) => {\n\t\tif (count === 0)\n\t\t\treturn (\n\t\t\t\tlocalization?.CMS_DASHBOARD_ITEMS_COUNT_ZERO ??\n\t\t\t\tt(\"cms.dashboard.itemsCountZero\", \"No items\")\n\t\t\t);\n\t\tif (count === 1)\n\t\t\treturn (\n\t\t\t\tlocalization?.CMS_DASHBOARD_ITEMS_COUNT_ONE ??\n\t\t\t\tt(\"cms.dashboard.itemsCountOne\", \"1 item\")\n\t\t\t);\n\t\treturn (\n\t\t\tlocalization?.CMS_DASHBOARD_ITEMS_COUNT ??\n\t\t\tt(\"cms.dashboard.itemsCount\", \"{count} items\")\n\t\t).replace(\"{count}\", String(count));\n\t};\n\n\treturn (\n\t\t\n\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t

{title}

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

{subtitle}

\n\t\t\t\t
\n\n\t\t\t\t
\n\t\t\t\t\t{contentTypes.map((ct) => (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\tvoid navigate?.(joinBasePath(basePath, `/cms/${ct.slug}`))\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{ct.name}\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
{ct.itemCount}
\n\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t{formatItemCount(ct.itemCount)}\n\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t{ct.description && (\n\t\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t\t{ct.description}\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/cms/client/components/pages/dashboard-page.internal.tsx" }, { @@ -211,7 +211,7 @@ { "path": "btst/cms/client/overrides.ts", "type": "registry:lib", - "content": "import type { ComponentType } from \"react\";\nimport type { CMSLocalization } from \"./localization\";\nimport type { AutoFormInputComponentProps } from \"@/components/ui/auto-form/types\";\nimport type { ContentTypeConfig } from \"../types\";\n\n/** Browser-safe CMS factory values carried by the resolved client stack. */\nexport interface CMSProviderConfig {\n\t/** Declared content types, used to preserve application-defined UI order. */\n\treadonly contentTypes?: readonly ContentTypeConfig[];\n}\n\n/**\n * Apply the registered content-type order while keeping HTTP data authoritative.\n * Types not declared by this client are appended in their server-returned order.\n */\nexport function orderCMSContentTypes(\n\tcontentTypes: readonly T[],\n\tproviderConfig: Readonly> | undefined,\n): T[] {\n\tconst configured = providerConfig?.contentTypes;\n\tif (!Array.isArray(configured) || configured.length === 0) {\n\t\treturn [...contentTypes];\n\t}\n\tconst positions = new Map();\n\tfor (const [index, value] of configured.entries()) {\n\t\tif (\n\t\t\tvalue !== null &&\n\t\t\ttypeof value === \"object\" &&\n\t\t\t\"slug\" in value &&\n\t\t\ttypeof value.slug === \"string\"\n\t\t) {\n\t\t\tpositions.set(value.slug, index);\n\t\t}\n\t}\n\treturn contentTypes\n\t\t.map((value, index) => ({ value, index }))\n\t\t.sort((left, right) => {\n\t\t\tconst leftPosition = positions.get(left.value.slug);\n\t\t\tconst rightPosition = positions.get(right.value.slug);\n\t\t\tif (leftPosition === undefined && rightPosition === undefined) {\n\t\t\t\treturn left.index - right.index;\n\t\t\t}\n\t\t\tif (leftPosition === undefined) return 1;\n\t\t\tif (rightPosition === undefined) return -1;\n\t\t\treturn leftPosition - rightPosition;\n\t\t})\n\t\t.map(({ value }) => value);\n}\n\n/**\n * Props for the overridable CMS image input field component.\n */\nexport interface CmsImageInputFieldProps {\n\t/** Current image URL value */\n\tvalue: string;\n\t/** Called when the image URL changes */\n\tonChange: (value: string) => void;\n\t/** Whether the field is required */\n\tisRequired?: boolean;\n}\n\n/**\n * Context passed to lifecycle hooks\n */\nexport interface RouteContext {\n\t/** Current route path */\n\tpath: string;\n\t/** Route parameters (e.g., { typeSlug: \"product\", id: \"123\" }) */\n\tparams?: Record;\n\t/** Whether rendering on server (true) or client (false) */\n\tisSSR: boolean;\n\t/** Additional context properties */\n\t[key: string]: unknown;\n}\n\n/**\n * Overridable components and functions for the CMS plugin\n *\n * External consumers can provide their own implementations to customize\n * plugin-specific components and behavior.\n */\nexport interface CMSPluginOverrides {\n\t/**\n\t * Function used to upload a new image file and return its URL.\n\t * Used by the default \"file\" field component when not selecting an existing\n\t * asset via `imagePicker` or `imageInputField`.\n\t */\n\tuploadImage?: (file: File) => Promise;\n\n\t/**\n\t * Optional custom component for image fields (fieldType: \"file\").\n\t *\n\t * When provided it replaces the default file-upload input entirely.\n\t * The component receives `value` (current URL string) and `onChange` (setter).\n\t *\n\t * @example\n\t * ```tsx\n\t * imageInputField: ({ value, onChange }) =>\n\t * value ? (\n\t *
\n\t * \"Preview\"\n\t * Change} accept={[\"image/*\"]}\n\t * onSelect={(assets) => onChange(assets[0].url)} />\n\t *
\n\t * ) : (\n\t * Browse media} accept={[\"image/*\"]}\n\t * onSelect={(assets) => onChange(assets[0].url)} />\n\t * )\n\t * ```\n\t */\n\timageInputField?: ComponentType;\n\n\t/**\n\t * Optional trigger component for a media picker.\n\t * When provided, it is rendered inside the default \"file\" field component as a\n\t * \"Browse media\" option, letting users select a previously uploaded asset.\n\t * Receives `onSelect(url)` — the URL is set as the field value.\n\t *\n\t * @example\n\t * ```tsx\n\t * imagePicker: ({ onSelect }) => (\n\t * Browse media}\n\t * accept={[\"image/*\"]}\n\t * onSelect={(assets) => onSelect(assets[0].url)}\n\t * />\n\t * )\n\t * ```\n\t */\n\timagePicker?: ComponentType<{ onSelect: (url: string) => void }>;\n\n\t/**\n\t * Custom field components for AutoForm fields.\n\t *\n\t * These map field type names to React components. Use these to:\n\t * - Override built-in field types (checkbox, date, select, radio, switch, textarea, file, number, fallback)\n\t * - Add custom field types for your content types\n\t *\n\t * The component receives AutoFormInputComponentProps which includes:\n\t * - field: react-hook-form field controller\n\t * - label: the field label\n\t * - isRequired: whether the field is required\n\t * - fieldConfigItem: the field config (description, inputProps, etc.)\n\t * - fieldProps: additional props from fieldConfig.inputProps\n\t * - zodItem: the Zod schema for this field\n\t *\n\t * @example\n\t * ```tsx\n\t * fieldComponents: {\n\t * // Override the file type with custom S3 upload\n\t * file: ({ field, label, isRequired }) => (\n\t * \n\t * ),\n\t * // Add a custom rich text editor\n\t * richText: ({ field, label }) => (\n\t * \n\t * ),\n\t * }\n\t * ```\n\t */\n\tfieldComponents?: Record>;\n\n\t/**\n\t * Localization object for the CMS plugin\n\t */\n\tlocalization?: CMSLocalization;\n\n\t/**\n\t * Whether to show the attribution\n\t */\n\tshowAttribution?: boolean;\n\n\t// Lifecycle Hooks (optional)\n\n\t/**\n\t * Called when a route is rendered\n\t * @param routeName - Name of the route (e.g., 'dashboard', 'contentList', 'contentEditor')\n\t * @param context - Route context with path, params, etc.\n\t */\n\tonRouteRender?: (\n\t\trouteName: string,\n\t\tcontext: RouteContext,\n\t) => void | Promise;\n\n\t/**\n\t * Called when a route encounters an error\n\t * @param routeName - Name of the route\n\t * @param error - The error that occurred\n\t * @param context - Route context\n\t */\n\tonRouteError?: (\n\t\trouteName: string,\n\t\terror: Error,\n\t\tcontext: RouteContext,\n\t) => void | Promise;\n}\n", + "content": "import type { ComponentType } from \"react\";\nimport type { CMSLocalization } from \"./localization\";\nimport type { AutoFormInputComponentProps } from \"@/components/ui/auto-form/types\";\nimport type { ContentTypeConfig } from \"../types\";\n\n/** Browser-safe CMS factory values carried by the resolved client stack. */\nexport interface CMSProviderConfig {\n\t/** Declared content types, used to preserve application-defined UI order. */\n\treadonly contentTypes?: readonly ContentTypeConfig[];\n}\n\n/**\n * Apply the registered content-type order while keeping HTTP data authoritative.\n * Types not declared by this client are appended in their server-returned order.\n */\nexport function orderCMSContentTypes(\n\tcontentTypes: readonly T[],\n\tproviderConfig: Readonly> | undefined,\n): T[] {\n\tconst configured = providerConfig?.contentTypes;\n\tif (!Array.isArray(configured) || configured.length === 0) {\n\t\treturn [...contentTypes];\n\t}\n\tconst positions = new Map();\n\tfor (const [index, value] of configured.entries()) {\n\t\tif (\n\t\t\tvalue !== null &&\n\t\t\ttypeof value === \"object\" &&\n\t\t\t\"slug\" in value &&\n\t\t\ttypeof value.slug === \"string\"\n\t\t) {\n\t\t\tpositions.set(value.slug, index);\n\t\t}\n\t}\n\treturn contentTypes\n\t\t.map((value, index) => ({ value, index }))\n\t\t.sort((left, right) => {\n\t\t\tconst leftPosition = positions.get(left.value.slug);\n\t\t\tconst rightPosition = positions.get(right.value.slug);\n\t\t\tif (leftPosition === undefined && rightPosition === undefined) {\n\t\t\t\treturn left.index - right.index;\n\t\t\t}\n\t\t\tif (leftPosition === undefined) return 1;\n\t\t\tif (rightPosition === undefined) return -1;\n\t\t\treturn leftPosition - rightPosition;\n\t\t})\n\t\t.map(({ value }) => value);\n}\n\n/**\n * Props for the overridable CMS image input field component.\n */\nexport interface CmsImageInputFieldProps {\n\t/** Current image URL value */\n\tvalue: string;\n\t/** Called when the image URL changes */\n\tonChange: (value: string) => void;\n\t/** Whether the field is required */\n\tisRequired?: boolean;\n}\n\n/**\n * Context passed to lifecycle hooks\n */\nexport interface RouteContext {\n\t/** Current route path */\n\tpath: string;\n\t/** Route parameters (e.g., { typeSlug: \"product\", id: \"123\" }) */\n\tparams?: Record;\n\t/** Whether rendering on server (true) or client (false) */\n\tisSSR: boolean;\n\t/** Additional context properties */\n\t[key: string]: unknown;\n}\n\n/**\n * Overridable components and functions for the CMS plugin\n *\n * External consumers can provide their own implementations to customize\n * plugin-specific components and behavior.\n */\nexport interface CMSPluginOverrides {\n\t/**\n\t * Function used to upload a new image file and return its URL.\n\t * Used by the default \"file\" field component when not selecting an existing\n\t * asset via `imagePicker` or `imageInputField`.\n\t */\n\tuploadImage?: (file: File) => Promise;\n\n\t/**\n\t * Optional custom component for image fields (fieldType: \"file\").\n\t *\n\t * When provided it replaces the default file-upload input entirely.\n\t * The component receives `value` (current URL string) and `onChange` (setter).\n\t *\n\t * @example\n\t * ```tsx\n\t * imageInputField: ({ value, onChange }) =>\n\t * value ? (\n\t *
\n\t * \"Preview\"\n\t * Change} accept={[\"image/*\"]}\n\t * onSelect={(assets) => onChange(assets[0].url)} />\n\t *
\n\t * ) : (\n\t * Browse media} accept={[\"image/*\"]}\n\t * onSelect={(assets) => onChange(assets[0].url)} />\n\t * )\n\t * ```\n\t */\n\timageInputField?: ComponentType;\n\n\t/**\n\t * Optional trigger component for a media picker.\n\t * When provided, it is rendered inside the default \"file\" field component as a\n\t * \"Browse media\" option, letting users select a previously uploaded asset.\n\t * Receives `onSelect(url)` — the URL is set as the field value.\n\t *\n\t * @example\n\t * ```tsx\n\t * imagePicker: ({ onSelect }) => (\n\t * Browse media}\n\t * accept={[\"image/*\"]}\n\t * onSelect={(assets) => onSelect(assets[0].url)}\n\t * />\n\t * )\n\t * ```\n\t */\n\timagePicker?: ComponentType<{ onSelect: (url: string) => void }>;\n\n\t/**\n\t * Custom field components for AutoForm fields.\n\t *\n\t * These map field type names to React components. Use these to:\n\t * - Override built-in field types (checkbox, date, select, radio, switch, textarea, file, number, fallback)\n\t * - Add custom field types for your content types\n\t *\n\t * The component receives AutoFormInputComponentProps which includes:\n\t * - field: react-hook-form field controller\n\t * - label: the field label\n\t * - isRequired: whether the field is required\n\t * - fieldConfigItem: the field config (description, inputProps, etc.)\n\t * - fieldProps: additional props from fieldConfig.inputProps\n\t * - zodItem: the Zod schema for this field\n\t *\n\t * @example\n\t * ```tsx\n\t * fieldComponents: {\n\t * // Override the file type with custom S3 upload\n\t * file: ({ field, label, isRequired }) => (\n\t * \n\t * ),\n\t * // Add a custom rich text editor\n\t * richText: ({ field, label }) => (\n\t * \n\t * ),\n\t * }\n\t * ```\n\t */\n\tfieldComponents?: Record>;\n\n\t/**\n\t * Localization object for the CMS plugin\n\t */\n\tlocalization?: Partial;\n\n\t/**\n\t * Whether to show the attribution\n\t */\n\tshowAttribution?: boolean;\n\n\t// Lifecycle Hooks (optional)\n\n\t/**\n\t * Called when a route is rendered\n\t * @param routeName - Name of the route (e.g., 'dashboard', 'contentList', 'contentEditor')\n\t * @param context - Route context with path, params, etc.\n\t */\n\tonRouteRender?: (\n\t\trouteName: string,\n\t\tcontext: RouteContext,\n\t) => void | Promise;\n\n\t/**\n\t * Called when a route encounters an error\n\t * @param routeName - Name of the route\n\t * @param error - The error that occurred\n\t * @param context - Route context\n\t */\n\tonRouteError?: (\n\t\trouteName: string,\n\t\terror: Error,\n\t\tcontext: RouteContext,\n\t) => void | Promise;\n}\n", "target": "src/components/btst/cms/client/overrides.ts" }, { diff --git a/packages/stack/registry/btst-form-builder.json b/packages/stack/registry/btst-form-builder.json index c6ad6fb95..9d0c3a9b9 100644 --- a/packages/stack/registry/btst-form-builder.json +++ b/packages/stack/registry/btst-form-builder.json @@ -199,7 +199,7 @@ { "path": "btst/form-builder/client/overrides.ts", "type": "registry:lib", - "content": "import type { ComponentType } from \"react\";\nimport type { FormBuilderLocalization } from \"./localization\";\nimport type { AutoFormInputComponentProps } from \"@/components/ui/auto-form/types\";\n\n/**\n * Context passed to lifecycle hooks\n */\nexport interface RouteContext {\n\t/** Current route path */\n\tpath: string;\n\t/** Route parameters (e.g., { id: \"123\" }) */\n\tparams?: Record;\n\t/** Whether rendering on server (true) or client (false) */\n\tisSSR: boolean;\n\t/** Additional context properties */\n\t[key: string]: unknown;\n}\n\n/**\n * Overridable components and functions for the Form Builder plugin\n *\n * External consumers can provide their own implementations to customize\n * plugin-specific components and behavior.\n */\nexport interface FormBuilderPluginOverrides {\n\t/**\n\t * Custom field components for AutoForm fields.\n\t *\n\t * These map field type names to React components. Use these to:\n\t * - Override built-in field types (checkbox, date, select, radio, switch, textarea, file, number, fallback)\n\t * - Add custom field types for your forms\n\t *\n\t * @example\n\t * ```tsx\n\t * fieldComponents: {\n\t * // Override the file type with custom S3 upload\n\t * file: ({ field, label, isRequired }) => (\n\t * \n\t * ),\n\t * // Add a custom rich text editor\n\t * richText: ({ field, label }) => (\n\t * \n\t * ),\n\t * }\n\t * ```\n\t */\n\tfieldComponents?: Record>;\n\n\t/**\n\t * Localization object for the Form Builder plugin\n\t */\n\tlocalization?: FormBuilderLocalization;\n\n\t/**\n\t * Whether to show the attribution\n\t */\n\tshowAttribution?: boolean;\n\n\t// Lifecycle Hooks (optional)\n\n\t/**\n\t * Called when a route is rendered\n\t * @param routeName - Name of the route (e.g., 'formList', 'formBuilder', 'submissions')\n\t * @param context - Route context with path, params, etc.\n\t */\n\tonRouteRender?: (\n\t\trouteName: string,\n\t\tcontext: RouteContext,\n\t) => void | Promise;\n\n\t/**\n\t * Called when a route encounters an error\n\t * @param routeName - Name of the route\n\t * @param error - The error that occurred\n\t * @param context - Route context\n\t */\n\tonRouteError?: (\n\t\trouteName: string,\n\t\terror: Error,\n\t\tcontext: RouteContext,\n\t) => void | Promise;\n}\n", + "content": "import type { ComponentType } from \"react\";\nimport type { FormBuilderLocalization } from \"./localization\";\nimport type { AutoFormInputComponentProps } from \"@/components/ui/auto-form/types\";\n\n/**\n * Context passed to lifecycle hooks\n */\nexport interface RouteContext {\n\t/** Current route path */\n\tpath: string;\n\t/** Route parameters (e.g., { id: \"123\" }) */\n\tparams?: Record;\n\t/** Whether rendering on server (true) or client (false) */\n\tisSSR: boolean;\n\t/** Additional context properties */\n\t[key: string]: unknown;\n}\n\n/**\n * Overridable components and functions for the Form Builder plugin\n *\n * External consumers can provide their own implementations to customize\n * plugin-specific components and behavior.\n */\nexport interface FormBuilderPluginOverrides {\n\t/**\n\t * Custom field components for AutoForm fields.\n\t *\n\t * These map field type names to React components. Use these to:\n\t * - Override built-in field types (checkbox, date, select, radio, switch, textarea, file, number, fallback)\n\t * - Add custom field types for your forms\n\t *\n\t * @example\n\t * ```tsx\n\t * fieldComponents: {\n\t * // Override the file type with custom S3 upload\n\t * file: ({ field, label, isRequired }) => (\n\t * \n\t * ),\n\t * // Add a custom rich text editor\n\t * richText: ({ field, label }) => (\n\t * \n\t * ),\n\t * }\n\t * ```\n\t */\n\tfieldComponents?: Record>;\n\n\t/**\n\t * Localization object for the Form Builder plugin\n\t */\n\tlocalization?: Partial;\n\n\t/**\n\t * Whether to show the attribution\n\t */\n\tshowAttribution?: boolean;\n\n\t// Lifecycle Hooks (optional)\n\n\t/**\n\t * Called when a route is rendered\n\t * @param routeName - Name of the route (e.g., 'formList', 'formBuilder', 'submissions')\n\t * @param context - Route context with path, params, etc.\n\t */\n\tonRouteRender?: (\n\t\trouteName: string,\n\t\tcontext: RouteContext,\n\t) => void | Promise;\n\n\t/**\n\t * Called when a route encounters an error\n\t * @param routeName - Name of the route\n\t * @param error - The error that occurred\n\t * @param context - Route context\n\t */\n\tonRouteError?: (\n\t\trouteName: string,\n\t\terror: Error,\n\t\tcontext: RouteContext,\n\t) => void | Promise;\n}\n", "target": "src/components/btst/form-builder/client/overrides.ts" }, { diff --git a/packages/stack/registry/btst-ui-builder.json b/packages/stack/registry/btst-ui-builder.json index 5097db250..418a95630 100644 --- a/packages/stack/registry/btst-ui-builder.json +++ b/packages/stack/registry/btst-ui-builder.json @@ -58,7 +58,7 @@ { "path": "btst/ui-builder/client/components/pages/page-builder-page.internal.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { useState, useCallback } from \"react\";\nimport {\n\tjoinBasePath,\n\tPermissionAccess,\n\tuseNotify,\n\tusePluginOverrides,\n\tuseStack,\n\tuseTranslate,\n} from \"@btst/stack/context\";\nimport { cmsPermissions } from \"@btst/stack/plugins/cms/permissions\";\nimport { UI_BUILDER_TYPE_SLUG } from \"@btst/stack/plugins/ui-builder\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport {\n\tSelect,\n\tSelectContent,\n\tSelectItem,\n\tSelectTrigger,\n\tSelectValue,\n} from \"@/components/ui/select\";\nimport {\n\tPopover,\n\tPopoverContent,\n\tPopoverTrigger,\n} from \"@/components/ui/popover\";\nimport { Label } from \"@/components/ui/label\";\nimport { ArrowLeft, Save, Settings2 } from \"lucide-react\";\nimport UIBuilder from \"@/components/ui/ui-builder\";\nimport type {\n\tComponentLayer,\n\tComponentRegistry,\n\tVariable,\n} from \"@/components/ui/ui-builder/types\";\n\nimport { useLayerStore } from \"@/lib/ui-builder/store/layer-store\";\nimport { useRegisterPageAIContext } from \"@btst/stack/plugins/ai-chat/client/context\";\nimport {\n\tuseSuspenseUIBuilderPage,\n\tuseUIBuilderPageForm,\n} from \"@btst/stack/plugins/ui-builder/client/hooks\";\nimport {\n\tresolveUIBuilderComponents,\n\ttype UIBuilderPluginOverrides,\n} from \"../../overrides\";\nimport { UI_BUILDER_PLUGIN_ID } from \"../../constants\";\nimport { uiBuilderLocalization } from \"../../localization\";\nimport { defaultComponentRegistry } from \"../../registry\";\nimport type { SerializedUIBuilderPage } from \"../../../types\";\n\nexport interface PageBuilderPageProps {\n\tid?: string;\n}\n\n/**\n * Generate a concise AI-readable description of the available components\n * in the component registry, including their prop names.\n */\nfunction buildRegistryDescription(registry: ComponentRegistry): string {\n\tconst lines: string[] = [];\n\tfor (const [name, entry] of Object.entries(registry) as [\n\t\tstring,\n\t\t{ schema?: unknown },\n\t][]) {\n\t\tlet propsLine = \"\";\n\t\ttry {\n\t\t\tconst shape = (entry.schema as any)?.shape as\n\t\t\t\t| Record\n\t\t\t\t| undefined;\n\t\t\tif (shape) {\n\t\t\t\tconst fields = Object.keys(shape).join(\", \");\n\t\t\t\tpropsLine = ` — props: ${fields}`;\n\t\t\t}\n\t\t} catch {\n\t\t\t// ignore schema introspection errors\n\t\t}\n\t\tlines.push(`- ${name}${propsLine}`);\n\t}\n\treturn lines.join(\"\\n\");\n}\n\n/**\n * Build the full page description string for the AI context.\n * Stays within the 8,000-character pageContext limit.\n */\nfunction buildPageDescription(\n\tid: string | undefined,\n\tslug: string,\n\tlayers: ComponentLayer[],\n\tregistry: ComponentRegistry,\n): string {\n\tconst header = id\n\t\t? `UI Builder — editing page (slug: \"${slug}\")`\n\t\t: \"UI Builder — creating new page\";\n\n\tconst layersJson = JSON.stringify(layers, null, 2);\n\n\tconst registryDesc = buildRegistryDescription(registry);\n\n\tconst layerFormat = `Each layer: { id: string, type: string, name: string, props: Record, children?: ComponentLayer[] | string }`;\n\n\tconst full = [\n\t\theader,\n\t\t\"\",\n\t\t`## Current Layers (${layers.length})`,\n\t\tlayersJson,\n\t\t\"\",\n\t\t`## Available Component Types`,\n\t\tregistryDesc,\n\t\t\"\",\n\t\t`## ComponentLayer format`,\n\t\tlayerFormat,\n\t].join(\"\\n\");\n\n\t// Trim to fit the 16,000-char server-side limit, cutting the layers JSON if needed\n\tif (full.length <= 16000) return full;\n\n\t// Re-build with truncated layers JSON\n\tconst overhead =\n\t\t[\n\t\t\theader,\n\t\t\t\"\",\n\t\t\t`## Current Layers (${layers.length})`,\n\t\t\t\"\",\n\t\t\t\"\",\n\t\t\t`## Available Component Types`,\n\t\t\tregistryDesc,\n\t\t\t\"\",\n\t\t\t`## ComponentLayer format`,\n\t\t\tlayerFormat,\n\t\t].join(\"\\n\").length + 30; // 30-char buffer for \"...(truncated)\"\n\n\tconst budget = Math.max(0, 16000 - overhead);\n\tconst truncatedLayers =\n\t\tlayersJson.length > budget\n\t\t\t? layersJson.slice(0, budget) + \"\\n...(truncated)\"\n\t\t\t: layersJson;\n\n\treturn [\n\t\theader,\n\t\t\"\",\n\t\t`## Current Layers (${layers.length})`,\n\t\ttruncatedLayers,\n\t\t\"\",\n\t\t`## Available Component Types`,\n\t\tregistryDesc,\n\t\t\"\",\n\t\t`## ComponentLayer format`,\n\t\tlayerFormat,\n\t].join(\"\\n\");\n}\n\n/**\n * Slugify a string for URL-friendly slugs\n */\nfunction slugify(str: string): string {\n\treturn str\n\t\t.toLowerCase()\n\t\t.trim()\n\t\t.replace(/[^\\w\\s-]/g, \"\")\n\t\t.replace(/[\\s_-]+/g, \"-\")\n\t\t.replace(/^-+|-+$/g, \"\");\n}\n\n/**\n * Entry point component that conditionally renders the appropriate\n * sub-component based on whether we're creating or editing a page.\n * This avoids conditional hook calls which violate React's Rules of Hooks.\n */\nexport function PageBuilderPage({ id }: PageBuilderPageProps) {\n\tif (id) {\n\t\treturn ;\n\t}\n\treturn ;\n}\n\n/**\n * Component for editing an existing page.\n * Uses useSuspenseUIBuilderPage unconditionally since id is always defined.\n */\nfunction EditPageBuilderPage({ id }: { id: string }) {\n\tconst { page: existingPage } = useSuspenseUIBuilderPage(id);\n\treturn ;\n}\n\n/**\n * Component for creating a new page.\n * No data fetching needed.\n */\nfunction CreatePageBuilderPage() {\n\treturn ;\n}\n\ninterface PageBuilderPageContentProps {\n\tid?: string;\n\texistingPage?: SerializedUIBuilderPage | null;\n}\n\ninterface PageBuilderFormValues {\n\tslug: string;\n\tlayers: ComponentLayer[];\n\tvariables: Variable[];\n\tstatus: \"published\" | \"draft\" | \"archived\";\n}\n\n/**\n * Parse JSON strings safely\n */\nfunction parseLayers(layersJson?: string): ComponentLayer[] {\n\tif (!layersJson) return [];\n\ttry {\n\t\treturn JSON.parse(layersJson) as ComponentLayer[];\n\t} catch {\n\t\treturn [];\n\t}\n}\n\nfunction parseVariables(variablesJson?: string): Variable[] {\n\tif (!variablesJson) return [];\n\ttry {\n\t\treturn JSON.parse(variablesJson) as Variable[];\n\t} catch {\n\t\treturn [];\n\t}\n}\n\nfunction PageBuilderPageContent({\n\tid,\n\texistingPage,\n}: PageBuilderPageContentProps) {\n\tconst t = useTranslate();\n\tconst notify = useNotify();\n\tconst { functionRegistry, localization } =\n\t\tusePluginOverrides(UI_BUILDER_PLUGIN_ID);\n\tconst { router, plugins, basePath: legacyBasePath } = useStack();\n\tconst basePath =\n\t\tplugins?.[UI_BUILDER_PLUGIN_ID]?.site.basePath ?? legacyBasePath;\n\tconst LinkComponent = router?.Link ?? \"a\";\n\tconst componentRegistry =\n\t\tresolveUIBuilderComponents(plugins?.[UI_BUILDER_PLUGIN_ID]?.config) ??\n\t\tdefaultComponentRegistry;\n\tconst localized = (\n\t\toverride: string | undefined,\n\t\tkey: string,\n\t\tfallback: string,\n\t) => override ?? t(key, fallback);\n\tconst savedMessage = localized(\n\t\tlocalization?.pageBuilder?.saved,\n\t\t\"uiBuilder.pageBuilder.saved\",\n\t\tuiBuilderLocalization.pageBuilder.saved,\n\t);\n\tconst saveErrorMessage = localized(\n\t\tlocalization?.pageBuilder?.saveError,\n\t\t\"uiBuilder.pageBuilder.saveError\",\n\t\tuiBuilderLocalization.pageBuilder.saveError,\n\t);\n\tconst duplicateSlugMessage = localized(\n\t\tlocalization?.pageBuilder?.duplicateSlug,\n\t\t\"uiBuilder.pageBuilder.duplicateSlug\",\n\t\tuiBuilderLocalization.pageBuilder.duplicateSlug,\n\t);\n\tconst loc = {\n\t\tpageBuilder: {\n\t\t\tslugLabel: localized(\n\t\t\t\tlocalization?.pageBuilder?.slugLabel,\n\t\t\t\t\"uiBuilder.pageBuilder.slugLabel\",\n\t\t\t\tuiBuilderLocalization.pageBuilder.slugLabel,\n\t\t\t),\n\t\t\tslugPlaceholder: localized(\n\t\t\t\tlocalization?.pageBuilder?.slugPlaceholder,\n\t\t\t\t\"uiBuilder.pageBuilder.slugPlaceholder\",\n\t\t\t\tuiBuilderLocalization.pageBuilder.slugPlaceholder,\n\t\t\t),\n\t\t\tstatusLabel: localized(\n\t\t\t\tlocalization?.pageBuilder?.statusLabel,\n\t\t\t\t\"uiBuilder.pageBuilder.statusLabel\",\n\t\t\t\tuiBuilderLocalization.pageBuilder.statusLabel,\n\t\t\t),\n\t\t\tsettingsTitle: localized(\n\t\t\t\tlocalization?.pageBuilder?.settingsTitle,\n\t\t\t\t\"uiBuilder.pageBuilder.settingsTitle\",\n\t\t\t\tuiBuilderLocalization.pageBuilder.settingsTitle,\n\t\t\t),\n\t\t\tsettingsDescription: localized(\n\t\t\t\tlocalization?.pageBuilder?.settingsDescription,\n\t\t\t\t\"uiBuilder.pageBuilder.settingsDescription\",\n\t\t\t\tuiBuilderLocalization.pageBuilder.settingsDescription,\n\t\t\t),\n\t\t\tsave: localized(\n\t\t\t\tlocalization?.pageBuilder?.save,\n\t\t\t\t\"uiBuilder.pageBuilder.save\",\n\t\t\t\tuiBuilderLocalization.pageBuilder.save,\n\t\t\t),\n\t\t\tsaving: localized(\n\t\t\t\tlocalization?.pageBuilder?.saving,\n\t\t\t\t\"uiBuilder.pageBuilder.saving\",\n\t\t\t\tuiBuilderLocalization.pageBuilder.saving,\n\t\t\t),\n\t\t\tstatusOptions: {\n\t\t\t\tdraft: localized(\n\t\t\t\t\tlocalization?.pageBuilder?.statusOptions?.draft,\n\t\t\t\t\t\"uiBuilder.pageBuilder.statusOptions.draft\",\n\t\t\t\t\tuiBuilderLocalization.pageBuilder.statusOptions.draft,\n\t\t\t\t),\n\t\t\t\tpublished: localized(\n\t\t\t\t\tlocalization?.pageBuilder?.statusOptions?.published,\n\t\t\t\t\t\"uiBuilder.pageBuilder.statusOptions.published\",\n\t\t\t\t\tuiBuilderLocalization.pageBuilder.statusOptions.published,\n\t\t\t\t),\n\t\t\t\tarchived: localized(\n\t\t\t\t\tlocalization?.pageBuilder?.statusOptions?.archived,\n\t\t\t\t\t\"uiBuilder.pageBuilder.statusOptions.archived\",\n\t\t\t\t\tuiBuilderLocalization.pageBuilder.statusOptions.archived,\n\t\t\t\t),\n\t\t\t},\n\t\t},\n\t};\n\n\t// Parse existing page data\n\tconst existingLayers = parseLayers(existingPage?.parsedData?.layers);\n\tconst existingVariables = parseVariables(existingPage?.parsedData?.variables);\n\n\t// Form state\n\tconst [slug, setSlug] = useState(existingPage?.slug || \"\");\n\tconst [status, setStatus] = useState<\"published\" | \"draft\" | \"archived\">(\n\t\texistingPage?.parsedData?.status || \"draft\",\n\t);\n\tconst [layers, setLayers] = useState(existingLayers);\n\tconst [variables, setVariables] = useState(existingVariables);\n\tconst pageForm = useUIBuilderPageForm({\n\t\taction: id ? \"edit\" : \"create\",\n\t\tid,\n\t\trecord: existingPage,\n\t\ttoCreateVars: (values) => values,\n\t\ttoUpdateVars: (values) => ({\n\t\t\tid: id!,\n\t\t\tdata: {\n\t\t\t\tlayers: values.layers,\n\t\t\t\tvariables: values.variables,\n\t\t\t\tstatus: values.status,\n\t\t\t},\n\t\t}),\n\t\tsuccessMessage: savedMessage,\n\t\terrorMessage: (error) =>\n\t\t\terror.message.includes(\"slug already exists\")\n\t\t\t\t? duplicateSlugMessage\n\t\t\t\t: saveErrorMessage,\n\t\tredirect: (page, action) =>\n\t\t\taction === \"create\"\n\t\t\t\t? joinBasePath(basePath, `/ui-builder/${page.id}/edit`)\n\t\t\t\t: false,\n\t});\n\n\t// Auto-generate slug from first page name\n\tconst [autoSlug, setAutoSlug] = useState(!id);\n\n\t// Register AI context so the chat can update the page layout\n\tuseRegisterPageAIContext({\n\t\trouteName: id ? \"ui-builder-edit-page\" : \"ui-builder-new-page\",\n\t\tpageDescription: buildPageDescription(id, slug, layers, componentRegistry),\n\t\tsuggestions: [\n\t\t\t\"Add a hero section\",\n\t\t\t\"Add a 3-column feature grid\",\n\t\t\t\"Make the layout full-width\",\n\t\t\t\"Add a card with a title, description, and button\",\n\t\t\t\"Replace the layout with a centered single-column design\",\n\t\t],\n\t\tclientTools: {\n\t\t\tupdatePageLayers: async ({ layers: newLayers }) => {\n\t\t\t\t// Drive the UIBuilder's Zustand store directly so the editor\n\t\t\t\t// and layers panel update immediately. The store's onChange\n\t\t\t\t// callback will propagate back to the parent's `layers` state.\n\t\t\t\tconst store = useLayerStore.getState();\n\t\t\t\tstore.initialize(\n\t\t\t\t\tnewLayers,\n\t\t\t\t\tstore.selectedPageId || newLayers[0]?.id,\n\t\t\t\t\tundefined,\n\t\t\t\t\tstore.variables,\n\t\t\t\t);\n\t\t\t\treturn {\n\t\t\t\t\tsuccess: true,\n\t\t\t\t\tmessage: `Applied ${newLayers.length} layer(s) to the page`,\n\t\t\t\t};\n\t\t\t},\n\t\t},\n\t});\n\n\t// Handle layers change from UIBuilder\n\tconst handleLayersChange = useCallback(\n\t\t(newLayers: ComponentLayer[]) => {\n\t\t\tsetLayers(newLayers);\n\n\t\t\t// Auto-generate slug from first page name if creating new page\n\t\t\tif (autoSlug && newLayers.length > 0 && newLayers[0]?.name) {\n\t\t\t\tsetSlug(slugify(newLayers[0].name));\n\t\t\t}\n\t\t},\n\t\t[autoSlug],\n\t);\n\n\t// Handle variables change from UIBuilder\n\tconst handleVariablesChange = useCallback((newVariables: Variable[]) => {\n\t\tsetVariables(newVariables);\n\t}, []);\n\n\tconst handleSave = async () => {\n\t\tif (!slug.trim()) {\n\t\t\tnotify.error(\n\t\t\t\tlocalized(\n\t\t\t\t\tlocalization?.pageBuilder?.validation?.slugRequired,\n\t\t\t\t\t\"uiBuilder.pageBuilder.validation.slugRequired\",\n\t\t\t\t\tuiBuilderLocalization.pageBuilder.validation.slugRequired,\n\t\t\t\t),\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\n\t\tif (!/^[a-z0-9-]+$/.test(slug)) {\n\t\t\tnotify.error(\n\t\t\t\tlocalized(\n\t\t\t\t\tlocalization?.pageBuilder?.validation?.slugFormat,\n\t\t\t\t\t\"uiBuilder.pageBuilder.validation.slugFormat\",\n\t\t\t\t\tuiBuilderLocalization.pageBuilder.validation.slugFormat,\n\t\t\t\t),\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\n\t\tif (layers.length === 0) {\n\t\t\tnotify.error(\n\t\t\t\tlocalized(\n\t\t\t\t\tlocalization?.pageBuilder?.validation?.layersRequired,\n\t\t\t\t\t\"uiBuilder.pageBuilder.validation.layersRequired\",\n\t\t\t\t\tuiBuilderLocalization.pageBuilder.validation.layersRequired,\n\t\t\t\t),\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\n\t\tawait pageForm.submit({ slug, layers, variables, status });\n\t};\n\n\tconst isSaving = pageForm.isSubmitting;\n\tconst slugFieldError = pageForm.fieldErrors.slug;\n\tconst slugErrorMessage = Array.isArray(slugFieldError)\n\t\t? slugFieldError[0]\n\t\t: slugFieldError;\n\n\t// Shared form fields - used in both mobile popover and desktop inline\n\tconst pageSettingsFields = (isMobile: boolean) => (\n\t\t\n\t\t\t
\n\t\t\t\t{isMobile && (\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t\t {\n\t\t\t\t\t\tsetSlug(e.target.value);\n\t\t\t\t\t\tsetAutoSlug(false);\n\t\t\t\t\t\tpageForm.clearErrors();\n\t\t\t\t\t}}\n\t\t\t\t\tplaceholder={loc.pageBuilder.slugPlaceholder}\n\t\t\t\t\tclassName={\n\t\t\t\t\t\tisMobile ? \"h-9 font-mono text-sm\" : \"h-8 w-48 font-mono text-sm\"\n\t\t\t\t\t}\n\t\t\t\t\tdisabled={!!id}\n\t\t\t\t\taria-invalid={!!slugErrorMessage}\n\t\t\t\t/>\n\t\t\t\t{slugErrorMessage && (\n\t\t\t\t\t

\n\t\t\t\t\t\t{slugErrorMessage}\n\t\t\t\t\t

\n\t\t\t\t)}\n\t\t\t
\n\n\t\t\t
\n\t\t\t\t{isMobile && (\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t\t setStatus(v as typeof status)}\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\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{loc.pageBuilder.statusOptions.draft}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{loc.pageBuilder.statusOptions.published}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{loc.pageBuilder.statusOptions.archived}\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\t// NavBar left children - back button, mobile popover, desktop inline fields\n\tconst navLeftChildren = (\n\t\t
\n\t\t\t\n\n\t\t\t{/* Mobile: Popover with settings */}\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\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\t

\n\t\t\t\t\t\t\t\t\t{loc.pageBuilder.settingsTitle}\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{loc.pageBuilder.settingsDescription}\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{pageSettingsFields(true)}\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{/* Desktop: Inline fields */}\n\t\t\t
\n\t\t\t\t{pageSettingsFields(false)}\n\t\t\t
\n\t\t
\n\t);\n\n\t// NavBar right children - save button (icon only on mobile, with text on desktop)\n\tconst navRightChildren = (\n\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t{isSaving ? loc.pageBuilder.saving : loc.pageBuilder.save}\n\t\t\t\t\n\t\t\t\n\t\t\n\t);\n\n\treturn (\n\t\t
\n\t\t\t 0 ? existingLayers : undefined}\n\t\t\t\tonChange={handleLayersChange}\n\t\t\t\tinitialVariables={\n\t\t\t\t\texistingVariables.length > 0 ? existingVariables : undefined\n\t\t\t\t}\n\t\t\t\tonVariablesChange={handleVariablesChange}\n\t\t\t\tcomponentRegistry={componentRegistry}\n\t\t\t\tfunctionRegistry={functionRegistry}\n\t\t\t\tpersistLayerStore={false}\n\t\t\t\tallowVariableEditing={true}\n\t\t\t\tallowPagesCreation={false}\n\t\t\t\tallowPagesDeletion={false}\n\t\t\t\tshowExport={false}\n\t\t\t\tnavLeftChildren={navLeftChildren}\n\t\t\t\tnavRightChildren={navRightChildren}\n\t\t\t/>\n\t\t
\n\t);\n}\n", + "content": "\"use client\";\n\nimport { useState, useCallback } from \"react\";\nimport {\n\tjoinBasePath,\n\tPermissionAccess,\n\tuseNotify,\n\tusePluginOverrides,\n\tuseStack,\n\tuseTranslate,\n} from \"@btst/stack/context\";\nimport { cmsPermissions } from \"@btst/stack/plugins/cms/permissions\";\nimport { UI_BUILDER_TYPE_SLUG } from \"@btst/stack/plugins/ui-builder\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport {\n\tSelect,\n\tSelectContent,\n\tSelectItem,\n\tSelectTrigger,\n\tSelectValue,\n} from \"@/components/ui/select\";\nimport {\n\tPopover,\n\tPopoverContent,\n\tPopoverTrigger,\n} from \"@/components/ui/popover\";\nimport { Label } from \"@/components/ui/label\";\nimport { ArrowLeft, Save, Settings2 } from \"lucide-react\";\nimport UIBuilder from \"@/components/ui/ui-builder\";\nimport type {\n\tComponentLayer,\n\tComponentRegistry,\n\tVariable,\n} from \"@/components/ui/ui-builder/types\";\n\nimport { useLayerStore } from \"@/lib/ui-builder/store/layer-store\";\nimport { useRegisterPageAIContext } from \"@btst/stack/plugins/ai-chat/client/context\";\nimport {\n\tuseSuspenseUIBuilderPage,\n\tuseUIBuilderPageForm,\n} from \"@btst/stack/plugins/ui-builder/client/hooks\";\nimport {\n\tresolveUIBuilderComponents,\n\ttype UIBuilderPluginOverrides,\n} from \"../../overrides\";\nimport { UI_BUILDER_PLUGIN_ID } from \"../../constants\";\nimport { uiBuilderLocalization } from \"../../localization\";\nimport { defaultComponentRegistry } from \"../../registry\";\nimport type { SerializedUIBuilderPage } from \"../../../types\";\n\nexport interface PageBuilderPageProps {\n\tid?: string;\n}\n\n/**\n * Generate a concise AI-readable description of the available components\n * in the component registry, including their prop names.\n */\nfunction buildRegistryDescription(registry: ComponentRegistry): string {\n\tconst lines: string[] = [];\n\tfor (const [name, entry] of Object.entries(registry) as [\n\t\tstring,\n\t\t{ schema?: unknown },\n\t][]) {\n\t\tlet propsLine = \"\";\n\t\ttry {\n\t\t\tconst shape = (entry.schema as any)?.shape as\n\t\t\t\t| Record\n\t\t\t\t| undefined;\n\t\t\tif (shape) {\n\t\t\t\tconst fields = Object.keys(shape).join(\", \");\n\t\t\t\tpropsLine = ` — props: ${fields}`;\n\t\t\t}\n\t\t} catch {\n\t\t\t// ignore schema introspection errors\n\t\t}\n\t\tlines.push(`- ${name}${propsLine}`);\n\t}\n\treturn lines.join(\"\\n\");\n}\n\n/**\n * Build the full page description string for the AI context.\n * Stays within the 8,000-character pageContext limit.\n */\nfunction buildPageDescription(\n\tid: string | undefined,\n\tslug: string,\n\tlayers: ComponentLayer[],\n\tregistry: ComponentRegistry,\n): string {\n\tconst header = id\n\t\t? `UI Builder — editing page (slug: \"${slug}\")`\n\t\t: \"UI Builder — creating new page\";\n\n\tconst layersJson = JSON.stringify(layers, null, 2);\n\n\tconst registryDesc = buildRegistryDescription(registry);\n\n\tconst layerFormat = `Each layer: { id: string, type: string, name: string, props: Record, children?: ComponentLayer[] | string }`;\n\n\tconst full = [\n\t\theader,\n\t\t\"\",\n\t\t`## Current Layers (${layers.length})`,\n\t\tlayersJson,\n\t\t\"\",\n\t\t`## Available Component Types`,\n\t\tregistryDesc,\n\t\t\"\",\n\t\t`## ComponentLayer format`,\n\t\tlayerFormat,\n\t].join(\"\\n\");\n\n\t// Trim to fit the 16,000-char server-side limit, cutting the layers JSON if needed\n\tif (full.length <= 16000) return full;\n\n\t// Re-build with truncated layers JSON\n\tconst overhead =\n\t\t[\n\t\t\theader,\n\t\t\t\"\",\n\t\t\t`## Current Layers (${layers.length})`,\n\t\t\t\"\",\n\t\t\t\"\",\n\t\t\t`## Available Component Types`,\n\t\t\tregistryDesc,\n\t\t\t\"\",\n\t\t\t`## ComponentLayer format`,\n\t\t\tlayerFormat,\n\t\t].join(\"\\n\").length + 30; // 30-char buffer for \"...(truncated)\"\n\n\tconst budget = Math.max(0, 16000 - overhead);\n\tconst truncatedLayers =\n\t\tlayersJson.length > budget\n\t\t\t? layersJson.slice(0, budget) + \"\\n...(truncated)\"\n\t\t\t: layersJson;\n\n\treturn [\n\t\theader,\n\t\t\"\",\n\t\t`## Current Layers (${layers.length})`,\n\t\ttruncatedLayers,\n\t\t\"\",\n\t\t`## Available Component Types`,\n\t\tregistryDesc,\n\t\t\"\",\n\t\t`## ComponentLayer format`,\n\t\tlayerFormat,\n\t].join(\"\\n\");\n}\n\n/**\n * Slugify a string for URL-friendly slugs\n */\nfunction slugify(str: string): string {\n\treturn str\n\t\t.toLowerCase()\n\t\t.trim()\n\t\t.replace(/[^\\w\\s-]/g, \"\")\n\t\t.replace(/[\\s_-]+/g, \"-\")\n\t\t.replace(/^-+|-+$/g, \"\");\n}\n\n/**\n * Entry point component that conditionally renders the appropriate\n * sub-component based on whether we're creating or editing a page.\n * This avoids conditional hook calls which violate React's Rules of Hooks.\n */\nexport function PageBuilderPage({ id }: PageBuilderPageProps) {\n\tif (id) {\n\t\treturn ;\n\t}\n\treturn ;\n}\n\n/**\n * Component for editing an existing page.\n * Uses useSuspenseUIBuilderPage unconditionally since id is always defined.\n */\nfunction EditPageBuilderPage({ id }: { id: string }) {\n\tconst { page: existingPage } = useSuspenseUIBuilderPage(id);\n\treturn ;\n}\n\n/**\n * Component for creating a new page.\n * No data fetching needed.\n */\nfunction CreatePageBuilderPage() {\n\treturn ;\n}\n\ninterface PageBuilderPageContentProps {\n\tid?: string;\n\texistingPage?: SerializedUIBuilderPage | null;\n}\n\ninterface PageBuilderFormValues {\n\tslug: string;\n\tlayers: ComponentLayer[];\n\tvariables: Variable[];\n\tstatus: \"published\" | \"draft\" | \"archived\";\n}\n\n/**\n * Parse JSON strings safely\n */\nfunction parseLayers(layersJson?: string): ComponentLayer[] {\n\tif (!layersJson) return [];\n\ttry {\n\t\treturn JSON.parse(layersJson) as ComponentLayer[];\n\t} catch {\n\t\treturn [];\n\t}\n}\n\nfunction parseVariables(variablesJson?: string): Variable[] {\n\tif (!variablesJson) return [];\n\ttry {\n\t\treturn JSON.parse(variablesJson) as Variable[];\n\t} catch {\n\t\treturn [];\n\t}\n}\n\nfunction PageBuilderPageContent({\n\tid,\n\texistingPage,\n}: PageBuilderPageContentProps) {\n\tconst t = useTranslate();\n\tconst notify = useNotify();\n\tconst { functionRegistry, localization } =\n\t\tusePluginOverrides(UI_BUILDER_PLUGIN_ID);\n\tconst { router, plugins, basePath: stackBasePath } = useStack();\n\tconst basePath =\n\t\tplugins?.[UI_BUILDER_PLUGIN_ID]?.site.basePath ?? stackBasePath;\n\tconst LinkComponent = router?.Link ?? \"a\";\n\tconst componentRegistry =\n\t\tresolveUIBuilderComponents(plugins?.[UI_BUILDER_PLUGIN_ID]?.config) ??\n\t\tdefaultComponentRegistry;\n\tconst localized = (\n\t\toverride: string | undefined,\n\t\tkey: string,\n\t\tfallback: string,\n\t) => override ?? t(key, fallback);\n\tconst savedMessage = localized(\n\t\tlocalization?.pageBuilder?.saved,\n\t\t\"uiBuilder.pageBuilder.saved\",\n\t\tuiBuilderLocalization.pageBuilder.saved,\n\t);\n\tconst saveErrorMessage = localized(\n\t\tlocalization?.pageBuilder?.saveError,\n\t\t\"uiBuilder.pageBuilder.saveError\",\n\t\tuiBuilderLocalization.pageBuilder.saveError,\n\t);\n\tconst duplicateSlugMessage = localized(\n\t\tlocalization?.pageBuilder?.duplicateSlug,\n\t\t\"uiBuilder.pageBuilder.duplicateSlug\",\n\t\tuiBuilderLocalization.pageBuilder.duplicateSlug,\n\t);\n\tconst loc = {\n\t\tpageBuilder: {\n\t\t\tslugLabel: localized(\n\t\t\t\tlocalization?.pageBuilder?.slugLabel,\n\t\t\t\t\"uiBuilder.pageBuilder.slugLabel\",\n\t\t\t\tuiBuilderLocalization.pageBuilder.slugLabel,\n\t\t\t),\n\t\t\tslugPlaceholder: localized(\n\t\t\t\tlocalization?.pageBuilder?.slugPlaceholder,\n\t\t\t\t\"uiBuilder.pageBuilder.slugPlaceholder\",\n\t\t\t\tuiBuilderLocalization.pageBuilder.slugPlaceholder,\n\t\t\t),\n\t\t\tstatusLabel: localized(\n\t\t\t\tlocalization?.pageBuilder?.statusLabel,\n\t\t\t\t\"uiBuilder.pageBuilder.statusLabel\",\n\t\t\t\tuiBuilderLocalization.pageBuilder.statusLabel,\n\t\t\t),\n\t\t\tsettingsTitle: localized(\n\t\t\t\tlocalization?.pageBuilder?.settingsTitle,\n\t\t\t\t\"uiBuilder.pageBuilder.settingsTitle\",\n\t\t\t\tuiBuilderLocalization.pageBuilder.settingsTitle,\n\t\t\t),\n\t\t\tsettingsDescription: localized(\n\t\t\t\tlocalization?.pageBuilder?.settingsDescription,\n\t\t\t\t\"uiBuilder.pageBuilder.settingsDescription\",\n\t\t\t\tuiBuilderLocalization.pageBuilder.settingsDescription,\n\t\t\t),\n\t\t\tsave: localized(\n\t\t\t\tlocalization?.pageBuilder?.save,\n\t\t\t\t\"uiBuilder.pageBuilder.save\",\n\t\t\t\tuiBuilderLocalization.pageBuilder.save,\n\t\t\t),\n\t\t\tsaving: localized(\n\t\t\t\tlocalization?.pageBuilder?.saving,\n\t\t\t\t\"uiBuilder.pageBuilder.saving\",\n\t\t\t\tuiBuilderLocalization.pageBuilder.saving,\n\t\t\t),\n\t\t\tstatusOptions: {\n\t\t\t\tdraft: localized(\n\t\t\t\t\tlocalization?.pageBuilder?.statusOptions?.draft,\n\t\t\t\t\t\"uiBuilder.pageBuilder.statusOptions.draft\",\n\t\t\t\t\tuiBuilderLocalization.pageBuilder.statusOptions.draft,\n\t\t\t\t),\n\t\t\t\tpublished: localized(\n\t\t\t\t\tlocalization?.pageBuilder?.statusOptions?.published,\n\t\t\t\t\t\"uiBuilder.pageBuilder.statusOptions.published\",\n\t\t\t\t\tuiBuilderLocalization.pageBuilder.statusOptions.published,\n\t\t\t\t),\n\t\t\t\tarchived: localized(\n\t\t\t\t\tlocalization?.pageBuilder?.statusOptions?.archived,\n\t\t\t\t\t\"uiBuilder.pageBuilder.statusOptions.archived\",\n\t\t\t\t\tuiBuilderLocalization.pageBuilder.statusOptions.archived,\n\t\t\t\t),\n\t\t\t},\n\t\t},\n\t};\n\n\t// Parse existing page data\n\tconst existingLayers = parseLayers(existingPage?.parsedData?.layers);\n\tconst existingVariables = parseVariables(existingPage?.parsedData?.variables);\n\n\t// Form state\n\tconst [slug, setSlug] = useState(existingPage?.slug || \"\");\n\tconst [status, setStatus] = useState<\"published\" | \"draft\" | \"archived\">(\n\t\texistingPage?.parsedData?.status || \"draft\",\n\t);\n\tconst [layers, setLayers] = useState(existingLayers);\n\tconst [variables, setVariables] = useState(existingVariables);\n\tconst pageForm = useUIBuilderPageForm({\n\t\taction: id ? \"edit\" : \"create\",\n\t\tid,\n\t\trecord: existingPage,\n\t\ttoCreateVars: (values) => values,\n\t\ttoUpdateVars: (values) => ({\n\t\t\tid: id!,\n\t\t\tdata: {\n\t\t\t\tlayers: values.layers,\n\t\t\t\tvariables: values.variables,\n\t\t\t\tstatus: values.status,\n\t\t\t},\n\t\t}),\n\t\tsuccessMessage: savedMessage,\n\t\terrorMessage: (error) =>\n\t\t\terror.message.includes(\"slug already exists\")\n\t\t\t\t? duplicateSlugMessage\n\t\t\t\t: saveErrorMessage,\n\t\tredirect: (page, action) =>\n\t\t\taction === \"create\"\n\t\t\t\t? joinBasePath(basePath, `/ui-builder/${page.id}/edit`)\n\t\t\t\t: false,\n\t});\n\n\t// Auto-generate slug from first page name\n\tconst [autoSlug, setAutoSlug] = useState(!id);\n\n\t// Register AI context so the chat can update the page layout\n\tuseRegisterPageAIContext({\n\t\trouteName: id ? \"ui-builder-edit-page\" : \"ui-builder-new-page\",\n\t\tpageDescription: buildPageDescription(id, slug, layers, componentRegistry),\n\t\tsuggestions: [\n\t\t\t\"Add a hero section\",\n\t\t\t\"Add a 3-column feature grid\",\n\t\t\t\"Make the layout full-width\",\n\t\t\t\"Add a card with a title, description, and button\",\n\t\t\t\"Replace the layout with a centered single-column design\",\n\t\t],\n\t\tclientTools: {\n\t\t\tupdatePageLayers: async ({ layers: newLayers }) => {\n\t\t\t\t// Drive the UIBuilder's Zustand store directly so the editor\n\t\t\t\t// and layers panel update immediately. The store's onChange\n\t\t\t\t// callback will propagate back to the parent's `layers` state.\n\t\t\t\tconst store = useLayerStore.getState();\n\t\t\t\tstore.initialize(\n\t\t\t\t\tnewLayers,\n\t\t\t\t\tstore.selectedPageId || newLayers[0]?.id,\n\t\t\t\t\tundefined,\n\t\t\t\t\tstore.variables,\n\t\t\t\t);\n\t\t\t\treturn {\n\t\t\t\t\tsuccess: true,\n\t\t\t\t\tmessage: `Applied ${newLayers.length} layer(s) to the page`,\n\t\t\t\t};\n\t\t\t},\n\t\t},\n\t});\n\n\t// Handle layers change from UIBuilder\n\tconst handleLayersChange = useCallback(\n\t\t(newLayers: ComponentLayer[]) => {\n\t\t\tsetLayers(newLayers);\n\n\t\t\t// Auto-generate slug from first page name if creating new page\n\t\t\tif (autoSlug && newLayers.length > 0 && newLayers[0]?.name) {\n\t\t\t\tsetSlug(slugify(newLayers[0].name));\n\t\t\t}\n\t\t},\n\t\t[autoSlug],\n\t);\n\n\t// Handle variables change from UIBuilder\n\tconst handleVariablesChange = useCallback((newVariables: Variable[]) => {\n\t\tsetVariables(newVariables);\n\t}, []);\n\n\tconst handleSave = async () => {\n\t\tif (!slug.trim()) {\n\t\t\tnotify.error(\n\t\t\t\tlocalized(\n\t\t\t\t\tlocalization?.pageBuilder?.validation?.slugRequired,\n\t\t\t\t\t\"uiBuilder.pageBuilder.validation.slugRequired\",\n\t\t\t\t\tuiBuilderLocalization.pageBuilder.validation.slugRequired,\n\t\t\t\t),\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\n\t\tif (!/^[a-z0-9-]+$/.test(slug)) {\n\t\t\tnotify.error(\n\t\t\t\tlocalized(\n\t\t\t\t\tlocalization?.pageBuilder?.validation?.slugFormat,\n\t\t\t\t\t\"uiBuilder.pageBuilder.validation.slugFormat\",\n\t\t\t\t\tuiBuilderLocalization.pageBuilder.validation.slugFormat,\n\t\t\t\t),\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\n\t\tif (layers.length === 0) {\n\t\t\tnotify.error(\n\t\t\t\tlocalized(\n\t\t\t\t\tlocalization?.pageBuilder?.validation?.layersRequired,\n\t\t\t\t\t\"uiBuilder.pageBuilder.validation.layersRequired\",\n\t\t\t\t\tuiBuilderLocalization.pageBuilder.validation.layersRequired,\n\t\t\t\t),\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\n\t\tawait pageForm.submit({ slug, layers, variables, status });\n\t};\n\n\tconst isSaving = pageForm.isSubmitting;\n\tconst slugFieldError = pageForm.fieldErrors.slug;\n\tconst slugErrorMessage = Array.isArray(slugFieldError)\n\t\t? slugFieldError[0]\n\t\t: slugFieldError;\n\n\t// Shared form fields - used in both mobile popover and desktop inline\n\tconst pageSettingsFields = (isMobile: boolean) => (\n\t\t\n\t\t\t
\n\t\t\t\t{isMobile && (\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t\t {\n\t\t\t\t\t\tsetSlug(e.target.value);\n\t\t\t\t\t\tsetAutoSlug(false);\n\t\t\t\t\t\tpageForm.clearErrors();\n\t\t\t\t\t}}\n\t\t\t\t\tplaceholder={loc.pageBuilder.slugPlaceholder}\n\t\t\t\t\tclassName={\n\t\t\t\t\t\tisMobile ? \"h-9 font-mono text-sm\" : \"h-8 w-48 font-mono text-sm\"\n\t\t\t\t\t}\n\t\t\t\t\tdisabled={!!id}\n\t\t\t\t\taria-invalid={!!slugErrorMessage}\n\t\t\t\t/>\n\t\t\t\t{slugErrorMessage && (\n\t\t\t\t\t

\n\t\t\t\t\t\t{slugErrorMessage}\n\t\t\t\t\t

\n\t\t\t\t)}\n\t\t\t
\n\n\t\t\t
\n\t\t\t\t{isMobile && (\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t\t setStatus(v as typeof status)}\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\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{loc.pageBuilder.statusOptions.draft}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{loc.pageBuilder.statusOptions.published}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{loc.pageBuilder.statusOptions.archived}\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\t// NavBar left children - back button, mobile popover, desktop inline fields\n\tconst navLeftChildren = (\n\t\t
\n\t\t\t\n\n\t\t\t{/* Mobile: Popover with settings */}\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\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\t

\n\t\t\t\t\t\t\t\t\t{loc.pageBuilder.settingsTitle}\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{loc.pageBuilder.settingsDescription}\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{pageSettingsFields(true)}\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{/* Desktop: Inline fields */}\n\t\t\t
\n\t\t\t\t{pageSettingsFields(false)}\n\t\t\t
\n\t\t
\n\t);\n\n\t// NavBar right children - save button (icon only on mobile, with text on desktop)\n\tconst navRightChildren = (\n\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t{isSaving ? loc.pageBuilder.saving : loc.pageBuilder.save}\n\t\t\t\t\n\t\t\t\n\t\t\n\t);\n\n\treturn (\n\t\t
\n\t\t\t 0 ? existingLayers : undefined}\n\t\t\t\tonChange={handleLayersChange}\n\t\t\t\tinitialVariables={\n\t\t\t\t\texistingVariables.length > 0 ? existingVariables : undefined\n\t\t\t\t}\n\t\t\t\tonVariablesChange={handleVariablesChange}\n\t\t\t\tcomponentRegistry={componentRegistry}\n\t\t\t\tfunctionRegistry={functionRegistry}\n\t\t\t\tpersistLayerStore={false}\n\t\t\t\tallowVariableEditing={true}\n\t\t\t\tallowPagesCreation={false}\n\t\t\t\tallowPagesDeletion={false}\n\t\t\t\tshowExport={false}\n\t\t\t\tnavLeftChildren={navLeftChildren}\n\t\t\t\tnavRightChildren={navRightChildren}\n\t\t\t/>\n\t\t
\n\t);\n}\n", "target": "src/components/btst/ui-builder/client/components/pages/page-builder-page.internal.tsx" }, { @@ -70,7 +70,7 @@ { "path": "btst/ui-builder/client/components/pages/page-list-page.internal.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { useState } from \"react\";\nimport {\n\tjoinBasePath,\n\tPermissionAccess,\n\tuseNotify,\n\tusePluginOverrides,\n\tuseStack,\n\tuseTranslate,\n} from \"@btst/stack/context\";\nimport { cmsPermissions } from \"@btst/stack/plugins/cms/permissions\";\nimport { UI_BUILDER_TYPE_SLUG } from \"@btst/stack/plugins/ui-builder\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n\tTable,\n\tTableBody,\n\tTableCell,\n\tTableHead,\n\tTableHeader,\n\tTableRow,\n} from \"@/components/ui/table\";\nimport {\n\tDropdownMenu,\n\tDropdownMenuContent,\n\tDropdownMenuItem,\n\tDropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport {\n\tAlertDialog,\n\tAlertDialogAction,\n\tAlertDialogCancel,\n\tAlertDialogContent,\n\tAlertDialogDescription,\n\tAlertDialogFooter,\n\tAlertDialogHeader,\n\tAlertDialogTitle,\n} from \"@/components/ui/alert-dialog\";\nimport { MoreHorizontal, Plus, Pencil, Trash2 } from \"lucide-react\";\n\nimport {\n\tuseSuspenseUIBuilderPages,\n\tuseDeleteUIBuilderPage,\n} from \"@btst/stack/plugins/ui-builder/client/hooks\";\nimport type { UIBuilderPluginOverrides } from \"../../overrides\";\nimport { UI_BUILDER_PLUGIN_ID } from \"../../constants\";\nimport { uiBuilderLocalization } from \"../../localization\";\nimport { PageWrapper } from \"../shared/page-wrapper\";\nimport { EmptyState } from \"../shared/empty-state\";\nimport { Pagination } from \"../shared/pagination\";\n\nexport function PageListPage() {\n\tconst t = useTranslate();\n\tconst notify = useNotify();\n\tconst { localization } =\n\t\tusePluginOverrides(UI_BUILDER_PLUGIN_ID);\n\tconst { router, plugins, basePath: legacyBasePath } = useStack();\n\tconst navigate = router?.navigate;\n\tconst basePath =\n\t\tplugins?.[UI_BUILDER_PLUGIN_ID]?.site.basePath ?? legacyBasePath;\n\tconst { pages, total, hasMore, isLoadingMore, loadMore } =\n\t\tuseSuspenseUIBuilderPages();\n\tconst deleteMutation = useDeleteUIBuilderPage();\n\n\tconst [deleteId, setDeleteId] = useState(null);\n\n\tconst LinkComponent = router?.Link ?? \"a\";\n\n\tconst handleDelete = async () => {\n\t\tif (!deleteId) return;\n\n\t\ttry {\n\t\t\tawait deleteMutation.mutateAsync(deleteId);\n\t\t} catch {\n\t\t\tnotify.error(\n\t\t\t\tlocalization?.pageList?.deleteError ??\n\t\t\t\t\tt(\n\t\t\t\t\t\t\"uiBuilder.pageList.deleteError\",\n\t\t\t\t\t\tuiBuilderLocalization.pageList.deleteError,\n\t\t\t\t\t),\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\t\tnotify.success(\n\t\t\tlocalization?.pageList?.deleteSuccess ??\n\t\t\t\tt(\n\t\t\t\t\t\"uiBuilder.pageList.deleteSuccess\",\n\t\t\t\t\tuiBuilderLocalization.pageList.deleteSuccess,\n\t\t\t\t),\n\t\t);\n\t\tsetDeleteId(null);\n\t};\n\n\tconst getStatusBadge = (status: string) => {\n\t\tconst colors: Record = {\n\t\t\tpublished:\n\t\t\t\t\"bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200\",\n\t\t\tdraft:\n\t\t\t\t\"bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200\",\n\t\t\tarchived: \"bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200\",\n\t\t};\n\t\treturn (\n\t\t\t\n\t\t\t\t{localization?.pageBuilder?.statusOptions?.[\n\t\t\t\t\tstatus as keyof typeof uiBuilderLocalization.pageBuilder.statusOptions\n\t\t\t\t] ??\n\t\t\t\t\tt(\n\t\t\t\t\t\t`uiBuilder.pageBuilder.statusOptions.${status}`,\n\t\t\t\t\t\tuiBuilderLocalization.pageBuilder.statusOptions[\n\t\t\t\t\t\t\tstatus as keyof typeof uiBuilderLocalization.pageBuilder.statusOptions\n\t\t\t\t\t\t] ?? status,\n\t\t\t\t\t)}\n\t\t\t\n\t\t);\n\t};\n\n\tconst createButton = (\n\t\t\n\t\t\t\n\t\t\n\t);\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\t\t

\n\t\t\t\t\t\t\t{localization?.pageList?.title ??\n\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\"uiBuilder.pageList.title\",\n\t\t\t\t\t\t\t\t\tuiBuilderLocalization.pageList.title,\n\t\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{localization?.pageList?.description ??\n\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\"uiBuilder.pageList.description\",\n\t\t\t\t\t\t\t\t\tuiBuilderLocalization.pageList.description,\n\t\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{createButton}\n\t\t\t\t
\n\n\t\t\t\t{pages.length === 0 ? (\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\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{localization?.pageList?.columns?.slug ??\n\t\t\t\t\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\"uiBuilder.pageList.columns.slug\",\n\t\t\t\t\t\t\t\t\t\t\t\t\tuiBuilderLocalization.pageList.columns.slug,\n\t\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{localization?.pageList?.columns?.status ??\n\t\t\t\t\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\"uiBuilder.pageList.columns.status\",\n\t\t\t\t\t\t\t\t\t\t\t\t\tuiBuilderLocalization.pageList.columns.status,\n\t\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{localization?.pageList?.columns?.updatedAt ??\n\t\t\t\t\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\"uiBuilder.pageList.columns.updatedAt\",\n\t\t\t\t\t\t\t\t\t\t\t\t\tuiBuilderLocalization.pageList.columns.updatedAt,\n\t\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{localization?.pageList?.columns?.actions ??\n\t\t\t\t\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\"uiBuilder.pageList.columns.actions\",\n\t\t\t\t\t\t\t\t\t\t\t\t\tuiBuilderLocalization.pageList.columns.actions,\n\t\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\t\n\t\t\t\t\t\t\t\t\t{pages.map((page) => (\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{page.slug}\n\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{getStatusBadge(page.parsedData.status)}\n\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{new Date(page.updatedAt).toLocaleDateString()}\n\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\t\t\n\t\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\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\t\n\t\t\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\t\t\t\tnavigate?.(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tjoinBasePath(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tbasePath,\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t`/ui-builder/${page.id}/edit`,\n\t\t\t\t\t\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\t\t\t\t)\n\t\t\t\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\t\t>\n\t\t\t\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\t\t\t{localization?.pageList?.actions?.edit ??\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"uiBuilder.pageList.actions.edit\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tuiBuilderLocalization.pageList.actions.edit,\n\t\t\t\t\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\t\t\n\t\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\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t setDeleteId(page.id)}\n\t\t\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\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{localization?.pageList?.actions?.delete ??\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"uiBuilder.pageList.actions.delete\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tuiBuilderLocalization.pageList.actions\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t.delete,\n\t\t\t\t\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\t\t\n\t\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\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\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{/* Delete confirmation dialog */}\n\t\t\t setDeleteId(null)}>\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{localization?.pageList?.deleteDialog?.title ??\n\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\"uiBuilder.pageList.deleteDialog.title\",\n\t\t\t\t\t\t\t\t\tuiBuilderLocalization.pageList.deleteDialog.title,\n\t\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{localization?.pageList?.deleteDialog?.description ??\n\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\"uiBuilder.pageList.deleteDialog.description\",\n\t\t\t\t\t\t\t\t\tuiBuilderLocalization.pageList.deleteDialog.description,\n\t\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\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.pageList?.deleteDialog?.cancel ??\n\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\"uiBuilder.pageList.deleteDialog.cancel\",\n\t\t\t\t\t\t\t\t\tuiBuilderLocalization.pageList.deleteDialog.cancel,\n\t\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{deleteMutation.isPending\n\t\t\t\t\t\t\t\t? (localization?.pageList?.deleteDialog?.deleting ??\n\t\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\t\"uiBuilder.pageList.deleteDialog.deleting\",\n\t\t\t\t\t\t\t\t\t\tuiBuilderLocalization.pageList.deleteDialog.deleting,\n\t\t\t\t\t\t\t\t\t))\n\t\t\t\t\t\t\t\t: (localization?.pageList?.deleteDialog?.confirm ??\n\t\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\t\"uiBuilder.pageList.deleteDialog.confirm\",\n\t\t\t\t\t\t\t\t\t\tuiBuilderLocalization.pageList.deleteDialog.confirm,\n\t\t\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", + "content": "\"use client\";\n\nimport { useState } from \"react\";\nimport {\n\tjoinBasePath,\n\tPermissionAccess,\n\tuseNotify,\n\tusePluginOverrides,\n\tuseStack,\n\tuseTranslate,\n} from \"@btst/stack/context\";\nimport { cmsPermissions } from \"@btst/stack/plugins/cms/permissions\";\nimport { UI_BUILDER_TYPE_SLUG } from \"@btst/stack/plugins/ui-builder\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n\tTable,\n\tTableBody,\n\tTableCell,\n\tTableHead,\n\tTableHeader,\n\tTableRow,\n} from \"@/components/ui/table\";\nimport {\n\tDropdownMenu,\n\tDropdownMenuContent,\n\tDropdownMenuItem,\n\tDropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport {\n\tAlertDialog,\n\tAlertDialogAction,\n\tAlertDialogCancel,\n\tAlertDialogContent,\n\tAlertDialogDescription,\n\tAlertDialogFooter,\n\tAlertDialogHeader,\n\tAlertDialogTitle,\n} from \"@/components/ui/alert-dialog\";\nimport { MoreHorizontal, Plus, Pencil, Trash2 } from \"lucide-react\";\n\nimport {\n\tuseSuspenseUIBuilderPages,\n\tuseDeleteUIBuilderPage,\n} from \"@btst/stack/plugins/ui-builder/client/hooks\";\nimport type { UIBuilderPluginOverrides } from \"../../overrides\";\nimport { UI_BUILDER_PLUGIN_ID } from \"../../constants\";\nimport { uiBuilderLocalization } from \"../../localization\";\nimport { PageWrapper } from \"../shared/page-wrapper\";\nimport { EmptyState } from \"../shared/empty-state\";\nimport { Pagination } from \"../shared/pagination\";\n\nexport function PageListPage() {\n\tconst t = useTranslate();\n\tconst notify = useNotify();\n\tconst { localization } =\n\t\tusePluginOverrides(UI_BUILDER_PLUGIN_ID);\n\tconst { router, plugins, basePath: stackBasePath } = useStack();\n\tconst navigate = router?.navigate;\n\tconst basePath =\n\t\tplugins?.[UI_BUILDER_PLUGIN_ID]?.site.basePath ?? stackBasePath;\n\tconst { pages, total, hasMore, isLoadingMore, loadMore } =\n\t\tuseSuspenseUIBuilderPages();\n\tconst deleteMutation = useDeleteUIBuilderPage();\n\n\tconst [deleteId, setDeleteId] = useState(null);\n\n\tconst LinkComponent = router?.Link ?? \"a\";\n\n\tconst handleDelete = async () => {\n\t\tif (!deleteId) return;\n\n\t\ttry {\n\t\t\tawait deleteMutation.mutateAsync(deleteId);\n\t\t} catch {\n\t\t\tnotify.error(\n\t\t\t\tlocalization?.pageList?.deleteError ??\n\t\t\t\t\tt(\n\t\t\t\t\t\t\"uiBuilder.pageList.deleteError\",\n\t\t\t\t\t\tuiBuilderLocalization.pageList.deleteError,\n\t\t\t\t\t),\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\t\tnotify.success(\n\t\t\tlocalization?.pageList?.deleteSuccess ??\n\t\t\t\tt(\n\t\t\t\t\t\"uiBuilder.pageList.deleteSuccess\",\n\t\t\t\t\tuiBuilderLocalization.pageList.deleteSuccess,\n\t\t\t\t),\n\t\t);\n\t\tsetDeleteId(null);\n\t};\n\n\tconst getStatusBadge = (status: string) => {\n\t\tconst colors: Record = {\n\t\t\tpublished:\n\t\t\t\t\"bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200\",\n\t\t\tdraft:\n\t\t\t\t\"bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200\",\n\t\t\tarchived: \"bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200\",\n\t\t};\n\t\treturn (\n\t\t\t\n\t\t\t\t{localization?.pageBuilder?.statusOptions?.[\n\t\t\t\t\tstatus as keyof typeof uiBuilderLocalization.pageBuilder.statusOptions\n\t\t\t\t] ??\n\t\t\t\t\tt(\n\t\t\t\t\t\t`uiBuilder.pageBuilder.statusOptions.${status}`,\n\t\t\t\t\t\tuiBuilderLocalization.pageBuilder.statusOptions[\n\t\t\t\t\t\t\tstatus as keyof typeof uiBuilderLocalization.pageBuilder.statusOptions\n\t\t\t\t\t\t] ?? status,\n\t\t\t\t\t)}\n\t\t\t\n\t\t);\n\t};\n\n\tconst createButton = (\n\t\t\n\t\t\t\n\t\t\n\t);\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\t\t

\n\t\t\t\t\t\t\t{localization?.pageList?.title ??\n\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\"uiBuilder.pageList.title\",\n\t\t\t\t\t\t\t\t\tuiBuilderLocalization.pageList.title,\n\t\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{localization?.pageList?.description ??\n\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\"uiBuilder.pageList.description\",\n\t\t\t\t\t\t\t\t\tuiBuilderLocalization.pageList.description,\n\t\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{createButton}\n\t\t\t\t
\n\n\t\t\t\t{pages.length === 0 ? (\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\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{localization?.pageList?.columns?.slug ??\n\t\t\t\t\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\"uiBuilder.pageList.columns.slug\",\n\t\t\t\t\t\t\t\t\t\t\t\t\tuiBuilderLocalization.pageList.columns.slug,\n\t\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{localization?.pageList?.columns?.status ??\n\t\t\t\t\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\"uiBuilder.pageList.columns.status\",\n\t\t\t\t\t\t\t\t\t\t\t\t\tuiBuilderLocalization.pageList.columns.status,\n\t\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{localization?.pageList?.columns?.updatedAt ??\n\t\t\t\t\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\"uiBuilder.pageList.columns.updatedAt\",\n\t\t\t\t\t\t\t\t\t\t\t\t\tuiBuilderLocalization.pageList.columns.updatedAt,\n\t\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{localization?.pageList?.columns?.actions ??\n\t\t\t\t\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\"uiBuilder.pageList.columns.actions\",\n\t\t\t\t\t\t\t\t\t\t\t\t\tuiBuilderLocalization.pageList.columns.actions,\n\t\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\t\n\t\t\t\t\t\t\t\t\t{pages.map((page) => (\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{page.slug}\n\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{getStatusBadge(page.parsedData.status)}\n\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{new Date(page.updatedAt).toLocaleDateString()}\n\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\t\t\n\t\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\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\t\n\t\t\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\t\t\t\tnavigate?.(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tjoinBasePath(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tbasePath,\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t`/ui-builder/${page.id}/edit`,\n\t\t\t\t\t\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\t\t\t\t)\n\t\t\t\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\t\t>\n\t\t\t\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\t\t\t{localization?.pageList?.actions?.edit ??\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"uiBuilder.pageList.actions.edit\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tuiBuilderLocalization.pageList.actions.edit,\n\t\t\t\t\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\t\t\n\t\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\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t setDeleteId(page.id)}\n\t\t\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\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{localization?.pageList?.actions?.delete ??\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"uiBuilder.pageList.actions.delete\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tuiBuilderLocalization.pageList.actions\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t.delete,\n\t\t\t\t\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\t\t\n\t\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\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\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{/* Delete confirmation dialog */}\n\t\t\t setDeleteId(null)}>\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{localization?.pageList?.deleteDialog?.title ??\n\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\"uiBuilder.pageList.deleteDialog.title\",\n\t\t\t\t\t\t\t\t\tuiBuilderLocalization.pageList.deleteDialog.title,\n\t\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{localization?.pageList?.deleteDialog?.description ??\n\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\"uiBuilder.pageList.deleteDialog.description\",\n\t\t\t\t\t\t\t\t\tuiBuilderLocalization.pageList.deleteDialog.description,\n\t\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\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.pageList?.deleteDialog?.cancel ??\n\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\"uiBuilder.pageList.deleteDialog.cancel\",\n\t\t\t\t\t\t\t\t\tuiBuilderLocalization.pageList.deleteDialog.cancel,\n\t\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{deleteMutation.isPending\n\t\t\t\t\t\t\t\t? (localization?.pageList?.deleteDialog?.deleting ??\n\t\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\t\"uiBuilder.pageList.deleteDialog.deleting\",\n\t\t\t\t\t\t\t\t\t\tuiBuilderLocalization.pageList.deleteDialog.deleting,\n\t\t\t\t\t\t\t\t\t))\n\t\t\t\t\t\t\t\t: (localization?.pageList?.deleteDialog?.confirm ??\n\t\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\t\"uiBuilder.pageList.deleteDialog.confirm\",\n\t\t\t\t\t\t\t\t\t\tuiBuilderLocalization.pageList.deleteDialog.confirm,\n\t\t\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/ui-builder/client/components/pages/page-list-page.internal.tsx" }, { diff --git a/packages/stack/src/__tests__/ai-chat-client-runtime.typecheck.tsx b/packages/stack/src/__tests__/ai-chat-client-runtime.typecheck.tsx index 01c24d547..7c15c943f 100644 --- a/packages/stack/src/__tests__/ai-chat-client-runtime.typecheck.tsx +++ b/packages/stack/src/__tests__/ai-chat-client-runtime.typecheck.tsx @@ -59,11 +59,11 @@ type ChatInputMode = ChatInputProps["mode"]; />; // Mode is a client-plugin factory concern, not a presentation override. -// @ts-expect-error Configure mode once in aiChatClientPlugin(). ; -// @ts-expect-error Transport headers are stack endpoint configuration. + {children} ); diff --git a/packages/stack/src/__tests__/authorization-client.test.tsx b/packages/stack/src/__tests__/authorization-client.test.tsx index bc8611b3c..b5b03658c 100644 --- a/packages/stack/src/__tests__/authorization-client.test.tsx +++ b/packages/stack/src/__tests__/authorization-client.test.tsx @@ -17,6 +17,7 @@ import { } from "../authorization/remote"; import { StackProvider, useIdentity } from "../context"; import { createIdentityTestAuth } from "./auth-test-utils"; +import { createEmptyTestClientStack } from "./client-stack-test-utils"; (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; @@ -74,7 +75,7 @@ describe("createClientAuth", () => { } await render( - + , ); @@ -102,7 +103,7 @@ describe("createClientAuth", () => { await render( @@ -136,7 +137,11 @@ describe("createClientAuth", () => { } await render( - + , ); @@ -168,7 +173,7 @@ describe("createClientAuth", () => { await render( @@ -182,7 +187,11 @@ describe("createClientAuth", () => { expect(canState).toEqual({ can: true, isPending: false }); await render( - + , ); @@ -192,7 +201,7 @@ describe("createClientAuth", () => { await render( @@ -233,7 +242,7 @@ describe("createClientAuth", () => { await render( @@ -244,7 +253,7 @@ describe("createClientAuth", () => { await render( @@ -286,7 +295,7 @@ describe("createClientAuth", () => { } await render( - + , ); @@ -295,7 +304,7 @@ describe("createClientAuth", () => { await render( @@ -344,7 +353,7 @@ describe("createClientAuth", () => { } await render( - + , ); @@ -391,7 +400,7 @@ describe("createClientAuth", () => { await render( @@ -405,7 +414,7 @@ describe("createClientAuth", () => { await render( @@ -414,7 +423,7 @@ describe("createClientAuth", () => { ); await render( @@ -447,7 +456,7 @@ describe("createClientAuth", () => { await render( @@ -475,7 +484,7 @@ describe("createClientAuth", () => { await render( @@ -516,7 +525,7 @@ describe("createClientAuth", () => { } await render( - + , ); @@ -562,7 +571,7 @@ describe("createClientAuth", () => { } function App({ postId, unrelated }: { postId: string; unrelated: number }) { return ( - + {unrelated} @@ -608,7 +617,7 @@ describe("createClientAuth", () => { } await render( - + , ); @@ -648,7 +657,7 @@ describe("createClientAuth", () => { } await render( - + , ); @@ -682,7 +691,7 @@ describe("createClientAuth", () => { } await render( - + , ); @@ -713,7 +722,7 @@ describe("createClientAuth", () => { } await render( - + , ); @@ -748,7 +757,7 @@ describe("createClientAuth", () => { } await render( - + , ); diff --git a/packages/stack/src/__tests__/authorization.test.ts b/packages/stack/src/__tests__/authorization.test.ts index 79ff6a30e..695aedc82 100644 --- a/packages/stack/src/__tests__/authorization.test.ts +++ b/packages/stack/src/__tests__/authorization.test.ts @@ -18,7 +18,7 @@ import { defineOperation, definePassthroughOperation, } from "../plugins/api"; -import { stack } from "../api"; +import { createBackendStack } from "../api"; const blogPermissions = definePermissions("blog", { post: { @@ -443,12 +443,12 @@ describe("schema-backed authorization", () => { expect(operation.access).toBe("authorized"); expect(operation.resultMode).toBe("immutable"); const navigationPlugin = defineBackendPlugin({ - name: "navigation", + id: "navigation", dbPlugin: createDbPlugin("navigation", {}), operations: () => ({ visit: operation }), routes: () => ({}), }); - const backend = stack({ + const backend = createBackendStack({ basePath: "/api", plugins: { navigation: navigationPlugin }, adapter: (db: DatabaseDefinition) => createMemoryAdapter(db)({}), @@ -490,12 +490,12 @@ describe("schema-backed authorization", () => { }, }); const plugin = defineBackendPlugin({ - name: "publicStream", + id: "publicStream", dbPlugin: createDbPlugin("publicStream", {}), operations: () => ({ start: operation }), routes: () => ({}), }); - const backend = stack({ + const backend = createBackendStack({ basePath: "/api", plugins: { publicStream: plugin }, adapter: (db: DatabaseDefinition) => createMemoryAdapter(db)({}), @@ -559,12 +559,12 @@ describe("schema-backed authorization", () => { execute: ({ input }) => input.target.id, }); const guardedPlugin = defineBackendPlugin({ - name: "guarded", + id: "guarded", dbPlugin: createDbPlugin("guarded", {}), operations: () => ({ update: guardedOperation }), routes: () => ({}), }); - const backend = stack({ + const backend = createBackendStack({ basePath: "/api", plugins: { guarded: guardedPlugin }, adapter: (db: DatabaseDefinition) => createMemoryAdapter(db)({}), @@ -611,12 +611,12 @@ describe("schema-backed authorization", () => { }, }); const compoundPlugin = defineBackendPlugin({ - name: "compound", + id: "compound", dbPlugin: createDbPlugin("compound", {}), operations: () => ({ create: compoundOperation }), routes: () => ({}), }); - const backend = stack({ + const backend = createBackendStack({ basePath: "/api", plugins: { compound: compoundPlugin }, adapter: (db: DatabaseDefinition) => createMemoryAdapter(db)({}), diff --git a/packages/stack/src/__tests__/authorization.typecheck.ts b/packages/stack/src/__tests__/authorization.typecheck.ts index 7b7bf60d4..01d0f46b7 100644 --- a/packages/stack/src/__tests__/authorization.typecheck.ts +++ b/packages/stack/src/__tests__/authorization.typecheck.ts @@ -7,7 +7,7 @@ import { import { createClientAuth } from "../authorization/client"; import { createServerAuth } from "../authorization/server"; import { createNextLayout } from "../next/server"; -import { stack } from "../api"; +import { createBackendStack } from "../api"; import { createDbPlugin, type DeepReadonly, @@ -349,12 +349,12 @@ const passthroughOperation = definePassthroughOperation({ }, }); const passthroughPlugin = defineBackendPlugin({ - name: "passthrough-fixture", + id: "passthrough", dbPlugin: createDbPlugin("passthrough-fixture", {}), operations: () => ({ stream: passthroughOperation }), routes: () => ({}), }); -const passthroughStack = stack({ +const passthroughStack = createBackendStack({ basePath: "/api", plugins: { passthrough: passthroughPlugin }, adapter: fakeAdapter, @@ -377,7 +377,7 @@ const blogServerAuth = createServerAuth({ getIdentity: () => ({ id: "user-1", role: "user" as const }), }); -const blogStack = stack({ +const blogStack = createBackendStack({ basePath: "/api", plugins: { blog: blogBackendPlugin() }, adapter: fakeAdapter, @@ -481,7 +481,7 @@ const commentsServerAuth = createServerAuth({ authorization: commentsAuthorization, getIdentity: () => ({ id: "user-1", role: "user" as const }), }); -const commentsStack = stack({ +const commentsStack = createBackendStack({ basePath: "/api", plugins: { comments: commentsBackendPlugin() }, adapter: fakeAdapter, @@ -537,7 +537,7 @@ commentsStack.trusted.comments.prefetchForRoute("/comments"); declare const aiChatPlugin: ReturnType; declare const cmsPlugin: ReturnType; declare const mediaPlugin: ReturnType; -const contractedStack = stack({ +const contractedStack = createBackendStack({ basePath: "/api", plugins: { aiChat: aiChatPlugin, @@ -571,7 +571,7 @@ contractedStack.raw.kanban.listBoards; // @ts-expect-error Media stack.raw exposes only prefetchForRoute contractedStack.raw.media.listAssets; -stack({ +createBackendStack({ basePath: "/api", plugins: { comments: commentsBackendPlugin() }, adapter: fakeAdapter, @@ -615,7 +615,7 @@ const unregisteredServerAuth = createServerAuth({ getIdentity: () => ({ id: "user-1" }), }); -stack({ +createBackendStack({ basePath: "/api", plugins: { blog: blogBackendPlugin() }, adapter: fakeAdapter, @@ -623,7 +623,7 @@ stack({ auth: unregisteredServerAuth, }); -stack({ +createBackendStack({ basePath: "/api", plugins: { blog: blogBackendPlugin() }, adapter: fakeAdapter, @@ -632,13 +632,13 @@ stack({ }); const operationPlugin = defineBackendPlugin({ - name: "operation-fixture", + id: "operationFixture", dbPlugin: createDbPlugin("operation-fixture", {}), operations: () => ({ deleteArticle: operation }), routes: () => ({}), }); -stack({ +createBackendStack({ basePath: "/api", plugins: { operationFixture: operationPlugin }, adapter: fakeAdapter, @@ -662,7 +662,7 @@ const incompatibleServerAuth = createServerAuth({ getIdentity: () => ({ id: "user-1" }), }); -stack({ +createBackendStack({ basePath: "/api", plugins: { operationFixture: operationPlugin }, adapter: fakeAdapter, diff --git a/packages/stack/src/__tests__/client-runtime.test.ts b/packages/stack/src/__tests__/client-runtime.test.ts index a1793f11e..79488e411 100644 --- a/packages/stack/src/__tests__/client-runtime.test.ts +++ b/packages/stack/src/__tests__/client-runtime.test.ts @@ -15,11 +15,12 @@ function headersRecord(headers: HeadersInit | undefined) { return Object.fromEntries(new Headers(headers).entries()); } -function createProbePlugin( +function createProbePlugin( onResolve: (runtime: ResolvedClientPluginRuntime) => void, + id = "probe" as TId, ) { return defineClientPlugin({ - name: "probe", + id, resolve(runtime) { onResolve(runtime); return { @@ -112,7 +113,7 @@ function createResourceProbePlugin( onResolve: (seam: ProbeResourceSeam) => void, ) { return defineClientPlugin({ - name: "probeResource", + id: "probe", resolve(runtime) { const seam = createProbeResourceSeam(runtime); onResolve(seam); @@ -620,6 +621,18 @@ describe("resolved client runtime", () => { }, }; + expect(() => + createClientStack({ + ...baseConfig, + baseURL: "https://ignored.example.com", + } as any), + ).toThrowError(/top-level baseURL.*api or site/i); + expect(() => + createClientStack({ + ...baseConfig, + basePath: "/ignored", + } as any), + ).toThrowError(/top-level basePath.*api or site/i); expect(() => createClientStack({ ...baseConfig, @@ -733,7 +746,7 @@ describe("resolved client runtime", () => { let contextOwnsPlugin = false; let runtime: ResolvedClientPluginRuntime | undefined; const prototypePlugin = defineClientPlugin({ - name: "prototypePlugin", + id: "__proto__", resolve(value) { runtime = value; return { @@ -808,7 +821,7 @@ describe("resolved client runtime", () => { plugins: { [registeredName]: createProbePlugin((value) => { runtime = value; - }), + }, registeredName), }, endpoints: {}, }); @@ -818,20 +831,17 @@ describe("resolved client runtime", () => { } }); - it("ignores inherited runtime and plugin-definition discriminator fields", () => { - const legacyPlugin = Object.assign( + it("requires canonical runtime and plugin-definition fields to be own properties", () => { + let inheritedResolveCalled = false; + const inheritedDefinition = Object.assign( Object.create({ resolve: () => { - throw new Error("inherited resolve must not run"); + inheritedResolveCalled = true; + return { routes: () => ({}) }; }, }), { - name: "legacyWithPrototype", - routes: () => ({ - legacyWithPrototype: createRoute("/legacy-with-prototype", () => ({ - PageComponent: () => null, - })), - }), + id: "inheritedDefinition", }, ); const inheritedCanonicalRuntime = { @@ -839,15 +849,21 @@ describe("resolved client runtime", () => { site: { baseURL: "https://inherited.example.com", basePath: "/pages" }, queryClient: new QueryClient(), }; - const legacyConfig = Object.assign( + const inheritedConfig = Object.assign( Object.create(inheritedCanonicalRuntime), - { plugins: { legacyWithPrototype: legacyPlugin } }, + { plugins: { inheritedDefinition } }, ); - const stack = createClientStack(legacyConfig as any); - - expect(stack.router.getRoute("/legacy-with-prototype")).toBeTruthy(); - expect("provider" in stack).toBe(false); + expect(() => createClientStack(inheritedConfig as any)).toThrowError( + /api endpoint is required/i, + ); + expect(() => + createClientStack({ + ...inheritedCanonicalRuntime, + plugins: { inheritedDefinition }, + } as any), + ).toThrowError(/own resolve\(\) function/i); + expect(inheritedResolveCalled).toBe(false); }); it("ignores prototype-polluted transport and nested endpoint fields", () => { @@ -946,22 +962,24 @@ describe("resolved client runtime", () => { } }); - it("keeps legacy client plugins working during first-party migration", async () => { - const legacy = defineClientPlugin({ - name: "legacy", + it("rejects already-resolved client plugins", () => { + const resolved = { + id: "resolved", routes: () => ({ - legacy: createRoute("/legacy", () => ({ + resolved: createRoute("/resolved", () => ({ PageComponent: () => null, })), }), - sitemap: () => [{ url: "https://legacy.example.com/legacy" }], - }); - const stack = createClientStack({ plugins: { legacy } }); + }; - expect(stack.router.getRoute("/legacy")).toBeTruthy(); - await expect(stack.generateSitemap()).resolves.toEqual([ - { url: "https://legacy.example.com/legacy" }, - ]); + expect(() => + createClientStack({ + api: { baseURL: "https://app.example.com", basePath: "/api/data" }, + site: { baseURL: "https://app.example.com", basePath: "/pages" }, + queryClient: new QueryClient(), + plugins: { resolved }, + } as any), + ).toThrowError(/resolve/i); }); it("keeps definition and request stack modules server-import-safe", async () => { diff --git a/packages/stack/src/__tests__/client-runtime.typecheck.ts b/packages/stack/src/__tests__/client-runtime.typecheck.ts index 4efb3b9a8..f02c07c79 100644 --- a/packages/stack/src/__tests__/client-runtime.typecheck.ts +++ b/packages/stack/src/__tests__/client-runtime.typecheck.ts @@ -4,7 +4,7 @@ import { createClientStack } from "../client"; import { defineClientPlugin } from "../plugins/client"; const probe = defineClientPlugin({ - name: "probe", + id: "probe", resolve: (runtime) => ({ routes: () => ({ probe: createRoute("/probe", () => ({ diff --git a/packages/stack/src/__tests__/client-stack-test-utils.ts b/packages/stack/src/__tests__/client-stack-test-utils.ts new file mode 100644 index 000000000..ac39c7687 --- /dev/null +++ b/packages/stack/src/__tests__/client-stack-test-utils.ts @@ -0,0 +1,36 @@ +import { QueryClient } from "@tanstack/react-query"; +import { createClientStack } from "../client"; +import type { + ClientApiConfig, + ClientLocation, + ClientPluginRegistration, + MatchingPluginRegistrations, +} from "../types"; + +const TEST_ORIGIN = "http://test.local"; + +/** Builds the same canonical client runtime that consumers pass to StackProvider. */ +export function createTestClientStack< + const TPlugins extends Record< + string, + ClientPluginRegistration + >, +>( + plugins: TPlugins & MatchingPluginRegistrations, + queryClient = new QueryClient(), + locations: { + api?: ClientApiConfig; + site?: ClientLocation; + } = {}, +) { + return createClientStack({ + api: locations.api ?? { baseURL: TEST_ORIGIN, basePath: "/api/data" }, + site: locations.site ?? { baseURL: TEST_ORIGIN, basePath: "/pages" }, + queryClient, + plugins, + }); +} + +export function createEmptyTestClientStack(queryClient = new QueryClient()) { + return createTestClientStack({}, queryClient); +} diff --git a/packages/stack/src/__tests__/endpoint-inventory.test.ts b/packages/stack/src/__tests__/endpoint-inventory.test.ts index 004fe8b67..0de2ab5f9 100644 --- a/packages/stack/src/__tests__/endpoint-inventory.test.ts +++ b/packages/stack/src/__tests__/endpoint-inventory.test.ts @@ -2,7 +2,7 @@ import { createMemoryAdapter } from "@btst/adapter-memory"; import type { DatabaseDefinition } from "@btst/db"; import { describe, expect, it, vi } from "vitest"; import { z } from "zod"; -import { stack } from "../api"; +import { createBackendStack } from "../api"; import { defineAuthorization, definePermissions, @@ -134,7 +134,7 @@ function readOperation(options?: { describe("composed endpoint inventory", () => { it("requires every route to be classified when server authorization is configured", () => { const routeOnlyPlugin = defineBackendPlugin({ - name: "feature", + id: "feature", dbPlugin: createDbPlugin("feature", {}), routes: () => ({ read: createEndpoint("/records", { method: "GET" }, async () => ({ @@ -144,7 +144,7 @@ describe("composed endpoint inventory", () => { }); expect(() => - stack({ + createBackendStack({ basePath: "/api", plugins: { feature: routeOnlyPlugin }, adapter: memoryAdapter, @@ -157,7 +157,7 @@ describe("composed endpoint inventory", () => { rules: ({ inventory }) => [inventory.record.read.allow()], }); expect(() => - stack({ + createBackendStack({ basePath: "/api", plugins: { feature: routeOnlyPlugin }, adapter: memoryAdapter, @@ -179,7 +179,7 @@ describe("composed endpoint inventory", () => { }), delete: async () => undefined, }; - const backend = stack({ + const backend = createBackendStack({ basePath: "/api", plugins: { blog: blogBackendPlugin(), @@ -236,7 +236,7 @@ describe("composed endpoint inventory", () => { it("rejects an undeclared endpoint with its plugin, route, method, and path", () => { const plugin = defineBackendPlugin({ - name: "feature", + id: "feature", dbPlugin: createDbPlugin("feature", {}), operations: () => ({ read: readOperation() }), routes: () => ({ @@ -249,19 +249,19 @@ describe("composed endpoint inventory", () => { }); expect(() => - stack({ + createBackendStack({ basePath: "/api", - plugins: { registeredFeature: plugin }, + plugins: { feature: plugin }, adapter: memoryAdapter, }), ).toThrowError( - '[btst/endpoint-inventory] Plugin "registeredFeature" route "undeclared" (POST /records) has no same-key operation or infrastructure declaration.', + '[btst/endpoint-inventory] Plugin "feature" route "undeclared" (POST /records) has no same-key operation or infrastructure declaration.', ); }); it("validates routes when an explicitly declared operation catalog is empty", () => { const plugin = defineBackendPlugin({ - name: "feature", + id: "feature", dbPlugin: createDbPlugin("feature", {}), operations: () => ({}), routes: () => ({ @@ -272,7 +272,7 @@ describe("composed endpoint inventory", () => { }); expect(() => - stack({ + createBackendStack({ basePath: "/api", plugins: { feature: plugin }, adapter: memoryAdapter, @@ -284,7 +284,7 @@ describe("composed endpoint inventory", () => { it("rejects endpoints missing their exact operation transport binding", async () => { const plugin = defineBackendPlugin({ - name: "feature", + id: "feature", dbPlugin: createDbPlugin("feature", {}), operations: () => ({ read: readOperation() }), routes: () => ({ @@ -295,7 +295,7 @@ describe("composed endpoint inventory", () => { }); expect(() => - stack({ + createBackendStack({ basePath: "/api", plugins: { feature: plugin }, adapter: memoryAdapter, @@ -305,7 +305,7 @@ describe("composed endpoint inventory", () => { ); const mismatched = defineBackendPlugin({ - name: "feature", + id: "feature", dbPlugin: createDbPlugin("feature", {}), operations: () => ({ read: readOperation(), @@ -321,7 +321,7 @@ describe("composed endpoint inventory", () => { }), }); expect(() => - stack({ + createBackendStack({ basePath: "/api", plugins: { feature: mismatched }, adapter: memoryAdapter, @@ -331,7 +331,7 @@ describe("composed endpoint inventory", () => { ); const protectedPlugin = defineBackendPlugin({ - name: "feature", + id: "feature", dbPlugin: createDbPlugin("feature", {}), operations: () => ({ read: readOperation() }), routes: (_adapter, _context, operations) => ({ @@ -347,7 +347,7 @@ describe("composed endpoint inventory", () => { permissions: [featurePermissions] as const, rules: () => [], }); - const protectedBackend = stack({ + const protectedBackend = createBackendStack({ basePath: "/api", plugins: { feature: protectedPlugin }, adapter: memoryAdapter, @@ -365,7 +365,7 @@ describe("composed endpoint inventory", () => { it("preserves exact operation execution through createEndpoint.create", async () => { const endpoint = createEndpoint.create(); const plugin = defineBackendPlugin({ - name: "feature", + id: "feature", dbPlugin: createDbPlugin("feature", {}), operations: () => ({ read: readOperation() }), routes: (_adapter, _context, operations) => ({ @@ -376,7 +376,7 @@ describe("composed endpoint inventory", () => { ), }), }); - const backend = stack({ + const backend = createBackendStack({ basePath: "/api", plugins: { feature: plugin }, adapter: memoryAdapter, @@ -389,7 +389,7 @@ describe("composed endpoint inventory", () => { expect(await response.text()).toBe("1"); const undeclaredPlugin = defineBackendPlugin({ - name: "feature", + id: "feature", dbPlugin: createDbPlugin("feature", {}), operations: () => ({ read: readOperation() }), routes: () => ({ @@ -401,7 +401,7 @@ describe("composed endpoint inventory", () => { }), }); expect(() => - stack({ + createBackendStack({ basePath: "/api", plugins: { feature: undeclaredPlugin }, adapter: memoryAdapter, @@ -418,7 +418,7 @@ describe("composed endpoint inventory", () => { }); const factValidationFailure = z.string().safeParse(1).error; const plugin = defineBackendPlugin({ - name: "feature", + id: "feature", dbPlugin: createDbPlugin("feature", {}), operations: () => ({ validationFailure: defineOperation({ @@ -472,7 +472,7 @@ describe("composed endpoint inventory", () => { ), }), }); - const backend = stack({ + const backend = createBackendStack({ basePath: "/api", plugins: { feature: plugin }, adapter: memoryAdapter, @@ -534,7 +534,7 @@ describe("composed endpoint inventory", () => { it("rejects stale and ambiguous infrastructure allowlist entries", () => { const stale = defineBackendPlugin({ - name: "docs", + id: "docs", dbPlugin: createDbPlugin("docs", {}), infrastructureRoutes: { schema: { @@ -551,7 +551,7 @@ describe("composed endpoint inventory", () => { }), }); expect(() => - stack({ + createBackendStack({ basePath: "/api", plugins: { docs: stale }, adapter: memoryAdapter, @@ -561,7 +561,7 @@ describe("composed endpoint inventory", () => { ); const ambiguous = defineBackendPlugin({ - name: "feature", + id: "feature", dbPlugin: createDbPlugin("feature", {}), operations: () => ({ read: readOperation() }), infrastructureRoutes: { @@ -579,7 +579,7 @@ describe("composed endpoint inventory", () => { }), }); expect(() => - stack({ + createBackendStack({ basePath: "/api", plugins: { feature: ambiguous }, adapter: memoryAdapter, @@ -591,7 +591,7 @@ describe("composed endpoint inventory", () => { it("rejects stale or unknown route-to-operation mappings", () => { const unknown = defineBackendPlugin({ - name: "feature", + id: "feature", dbPlugin: createDbPlugin("feature", {}), operations: () => ({ read: readOperation() }), operationRouteMap: { fetch: "missing" }, @@ -602,7 +602,7 @@ describe("composed endpoint inventory", () => { }), }); expect(() => - stack({ + createBackendStack({ basePath: "/api", plugins: { feature: unknown }, adapter: memoryAdapter, @@ -612,7 +612,7 @@ describe("composed endpoint inventory", () => { ); const stale = defineBackendPlugin({ - name: "feature", + id: "feature", dbPlugin: createDbPlugin("feature", {}), operations: () => ({ read: readOperation() }), operationRouteMap: { renamed: "read" }, @@ -623,7 +623,7 @@ describe("composed endpoint inventory", () => { }), }); expect(() => - stack({ + createBackendStack({ basePath: "/api", plugins: { feature: stale }, adapter: memoryAdapter, @@ -636,7 +636,7 @@ describe("composed endpoint inventory", () => { it("keeps route-less trusted operations out of the HTTP inventory", async () => { const hiddenExecute = vi.fn(() => "hidden"); const plugin = defineBackendPlugin({ - name: "feature", + id: "feature", dbPlugin: createDbPlugin("feature", {}), operations: () => ({ read: readOperation(), @@ -650,7 +650,7 @@ describe("composed endpoint inventory", () => { ), }), }); - const backend = stack({ + const backend = createBackendStack({ basePath: "/api", plugins: { feature: plugin }, adapter: memoryAdapter, @@ -671,12 +671,12 @@ describe("composed endpoint inventory", () => { describe("operation access semantics", () => { it("preserves omitted-auth compatibility and denies a protected missing rule", async () => { const plugin = defineBackendPlugin({ - name: "feature", + id: "feature", dbPlugin: createDbPlugin("feature", {}), operations: () => ({ read: readOperation() }), routes: () => ({}), }); - const withoutAuth = stack({ + const withoutAuth = createBackendStack({ basePath: "/api", plugins: { feature: plugin }, adapter: memoryAdapter, @@ -694,7 +694,7 @@ describe("operation access semantics", () => { permissions: [featurePermissions] as const, rules: () => [], }); - const withAuth = stack({ + const withAuth = createBackendStack({ basePath: "/api", plugins: { feature: plugin }, adapter: memoryAdapter, @@ -711,7 +711,7 @@ describe("operation access semantics", () => { }), ).rejects.toMatchObject({ name: "AuthorizationError", statusCode: 401 }); - const identified = stack({ + const identified = createBackendStack({ basePath: "/api", plugins: { feature: plugin }, adapter: memoryAdapter, @@ -733,7 +733,7 @@ describe("operation access semantics", () => { }); const factFailure = new Error("trusted fact load failed"); const plugin = defineBackendPlugin({ - name: "feature", + id: "feature", dbPlugin: createDbPlugin("feature", {}), operations: () => ({ publicRead: readOperation({ access: "public" }), @@ -751,7 +751,7 @@ describe("operation access semantics", () => { permissions: [featurePermissions] as const, rules: ({ inventory }) => [inventory.record.read.allow()], }); - const backend = stack({ + const backend = createBackendStack({ basePath: "/api", plugins: { feature: plugin }, adapter: memoryAdapter, diff --git a/packages/stack/src/__tests__/i18n-provider.test.tsx b/packages/stack/src/__tests__/i18n-provider.test.tsx index fb8e84335..6922d3042 100644 --- a/packages/stack/src/__tests__/i18n-provider.test.tsx +++ b/packages/stack/src/__tests__/i18n-provider.test.tsx @@ -7,6 +7,7 @@ import { useTranslate, type StackI18nProvider, } from "../context"; +import { createEmptyTestClientStack } from "./client-stack-test-utils"; (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; @@ -41,7 +42,7 @@ describe("useTranslate", () => { return null; } await render( - + , ); @@ -56,7 +57,7 @@ describe("useTranslate", () => { return null; } await render( - + , ); @@ -80,7 +81,7 @@ describe("useTranslate", () => { } await render( - + , ); diff --git a/packages/stack/src/__tests__/kanban-query-keys.test.ts b/packages/stack/src/__tests__/kanban-query-keys.test.ts index 96e588945..8a61b0e2a 100644 --- a/packages/stack/src/__tests__/kanban-query-keys.test.ts +++ b/packages/stack/src/__tests__/kanban-query-keys.test.ts @@ -4,7 +4,7 @@ */ import { QueryClient } from "@tanstack/react-query"; import { afterEach, describe, expect, expectTypeOf, it, vi } from "vitest"; -import { createStackClient } from "../client"; +import { createClientStack } from "../client"; import { getBoardSummaries, serializeBoardSummary, @@ -105,7 +105,7 @@ describe("Kanban authenticated SSR hydration", () => { headers: { "content-type": "application/json" }, }), ); - const stack = createStackClient({ + const stack = createClientStack({ api: { baseURL: "http://test.local", basePath: "/api/data", @@ -157,7 +157,7 @@ describe("Kanban authenticated SSR hydration", () => { ); const stackClient = (identityPartition?: typeof identity) => - createStackClient({ + createClientStack({ api: { baseURL: "http://test.local", basePath: "/api/data" }, site: { baseURL: "http://test.local", basePath: "/pages" }, queryClient, @@ -187,7 +187,7 @@ describe("Kanban anonymous sitemap", () => { afterEach(() => vi.restoreAllMocks()); function createClient() { - return createStackClient({ + return createClientStack({ api: { baseURL: "http://test.local", basePath: "/api/data" }, site: { baseURL: "http://test.local", basePath: "/pages" }, queryClient: new QueryClient(), diff --git a/packages/stack/src/__tests__/notify-provider.test.tsx b/packages/stack/src/__tests__/notify-provider.test.tsx index 1265ae9b3..569180110 100644 --- a/packages/stack/src/__tests__/notify-provider.test.tsx +++ b/packages/stack/src/__tests__/notify-provider.test.tsx @@ -3,6 +3,7 @@ import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { StackProvider, useNotify, type StackNotifyProvider } from "../context"; +import { createEmptyTestClientStack } from "./client-stack-test-utils"; (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; @@ -55,7 +56,7 @@ describe("useNotify", () => { return null; } await render( - + , ); @@ -81,7 +82,7 @@ describe("useNotify", () => { await render( diff --git a/packages/stack/src/__tests__/package-metadata.test.ts b/packages/stack/src/__tests__/package-metadata.test.ts index 5ec28f13a..5eea55d6a 100644 --- a/packages/stack/src/__tests__/package-metadata.test.ts +++ b/packages/stack/src/__tests__/package-metadata.test.ts @@ -28,8 +28,8 @@ describe("published dependency alignment", () => { }); }); -describe("published symmetric stack constructors", () => { - it("exposes canonical and temporary names through ESM, CJS, declarations and typesVersions", async () => { +describe("published canonical stack constructors", () => { + it("exposes only canonical names through ESM, CJS, declarations and typesVersions", async () => { const manifest = JSON.parse( await readFile(resolve("package.json"), "utf8"), ) as { @@ -87,31 +87,17 @@ describe("published symmetric stack constructors", () => { } for (const declaration of [apiDeclaration, apiCjsDeclaration]) { expect(declaration).toContain("function createBackendStack"); - expect(declaration).toContain("const stack: typeof createBackendStack"); expect(declaration).toContain("BackendStackConfig"); expect(declaration).toContain("BackendStack<"); - expect(declaration).toMatch( - /@deprecated Use `createBackendStack`[\s\S]*const stack: typeof createBackendStack/, - ); - const canonicalSignature = declaration.match( - /function createBackendStack[\s\S]*?;/, - )?.[0]; - expect(canonicalSignature).not.toContain("BackendLib"); + expect(declaration).not.toContain("const stack:"); + expect(declaration).not.toContain("BackendLib"); } for (const declaration of [clientDeclaration, clientCjsDeclaration]) { expect(declaration).toContain("function createClientStack"); - expect(declaration).toContain( - "const createStackClient: typeof createClientStack", - ); expect(declaration).toContain("ClientStackConfig"); expect(declaration).toContain("ClientStack<"); - expect(declaration).toMatch( - /@deprecated Use `createClientStack`[\s\S]*const createStackClient: typeof createClientStack/, - ); - const canonicalSignature = declaration.match( - /function createClientStack[\s\S]*?;/, - )?.[0]; - expect(canonicalSignature).not.toContain("ClientLib"); + expect(declaration).not.toContain("createStackClient"); + expect(declaration).not.toContain("ClientLib"); } const apiSpecifier = "@btst/stack/api"; @@ -125,10 +111,14 @@ describe("published symmetric stack constructors", () => { const cjsClient = require(clientSpecifier); const cjsClientHooks = require(clientHooksSpecifier); - expect(esmApi.stack).toBe(esmApi.createBackendStack); - expect(esmClient.createStackClient).toBe(esmClient.createClientStack); - expect(cjsApi.stack).toBe(cjsApi.createBackendStack); - expect(cjsClient.createStackClient).toBe(cjsClient.createClientStack); + expect(esmApi.createBackendStack).toBeTypeOf("function"); + expect(esmClient.createClientStack).toBeTypeOf("function"); + expect(cjsApi.createBackendStack).toBeTypeOf("function"); + expect(cjsClient.createClientStack).toBeTypeOf("function"); + expect("stack" in esmApi).toBe(false); + expect("createStackClient" in esmClient).toBe(false); + expect("stack" in cjsApi).toBe(false); + expect("createStackClient" in cjsClient).toBe(false); expect(esmClient.useListState).toBeUndefined(); expect(cjsClient.useListState).toBeUndefined(); expect(esmClientHooks.useListState).toBeTypeOf("function"); @@ -183,7 +173,6 @@ describe("published symmetric stack constructors", () => { let observedRouteOperations: Record | undefined; const prototypePlugin = pluginApi.defineBackendPlugin({ id: "__proto__", - name: "legacy-name", dbPlugin: pluginApi.createDbPlugin("prototype-id-db", {}), operations: () => ({ echo }), infrastructureRoutes: { diff --git a/packages/stack/src/__tests__/page-component-overrides.test.tsx b/packages/stack/src/__tests__/page-component-overrides.test.tsx index beaf90c26..3e45f0fb9 100644 --- a/packages/stack/src/__tests__/page-component-overrides.test.tsx +++ b/packages/stack/src/__tests__/page-component-overrides.test.tsx @@ -1,5 +1,4 @@ import { describe, it, expect } from "vitest"; -import { defineClientPlugin } from "../plugins/client"; import type { ComponentType, ReactElement } from "react"; import { defineRoute, defineRoutes } from "@btst/yar"; @@ -38,8 +37,8 @@ function createTestPlugin(config: { detail?: ComponentType<{ params: { id: string } }>; }; }) { - return defineClientPlugin({ - name: "test", + return { + id: "test", routes: () => defineRoutes( { @@ -50,7 +49,7 @@ function createTestPlugin(config: { }, { pages: config.pageComponents }, ), - }); + }; } describe("pageComponents overrides", () => { diff --git a/packages/stack/src/__tests__/plugin-registration.test.tsx b/packages/stack/src/__tests__/plugin-registration.test.tsx index c788c2242..e2990daaa 100644 --- a/packages/stack/src/__tests__/plugin-registration.test.tsx +++ b/packages/stack/src/__tests__/plugin-registration.test.tsx @@ -154,14 +154,13 @@ describe("canonical plugin registration IDs", () => { ]); }); - it("uses a canonical ID instead of a conflicting legacy name in client diagnostics", () => { - let contextPluginName: string | undefined; + it("uses the canonical ID in client context and route diagnostics", () => { + let contextPluginId: string | undefined; const canonicalDefinition = defineClientPlugin({ id: "canonical", - name: "legacy-name", resolve: () => ({ routes: (context) => { - contextPluginName = context?.plugins.canonical?.name; + contextPluginId = context?.plugins.canonical?.id; return { canonical: createRoute("/canonical", () => ({ PageComponent: () => null, @@ -171,22 +170,12 @@ describe("canonical plugin registration IDs", () => { }), }); - createClientStack(runtimeConfig({ canonical: canonicalDefinition })); + const stack = createClientStack( + runtimeConfig({ canonical: canonicalDefinition }), + ); - expect(contextPluginName).toBe("canonical"); - const schema = generateRouteDocsSchema({ - plugins: { - canonical: defineClientPlugin({ - id: "canonical", - name: "legacy-name", - routes: () => ({ - canonical: createRoute("/canonical", () => ({ - PageComponent: () => null, - })), - }), - }), - }, - }); + expect(contextPluginId).toBe("canonical"); + const schema = generateRouteDocsSchema(stack.context); expect(schema.plugins).toMatchObject([ { key: "canonical", name: "canonical" }, ]); diff --git a/packages/stack/src/__tests__/plugin-registration.typecheck.tsx b/packages/stack/src/__tests__/plugin-registration.typecheck.tsx index 59f9d68dc..8c3a39310 100644 --- a/packages/stack/src/__tests__/plugin-registration.typecheck.tsx +++ b/packages/stack/src/__tests__/plugin-registration.typecheck.tsx @@ -1,10 +1,5 @@ import type { DatabaseDefinition, DBAdapter } from "@btst/db"; import { QueryClient } from "@tanstack/react-query"; -import type { - BackendPlugin, - ClientPlugin, - ClientPluginDefinition, -} from "../types"; import { createBackendStack } from "../api"; import { createClientStack } from "../client"; import { StackProvider } from "../context"; @@ -20,25 +15,6 @@ interface ConsumerOverrides { format?: "short" | "long"; } -const broadLegacyClient: ClientPlugin = { - name: "broad-client", - routes: () => ({}), -}; -defineClientPlugin(broadLegacyClient); - -const broadLegacyDefinition: ClientPluginDefinition = { - name: "broad-definition", - resolve: () => ({ routes: () => ({}) }), -}; -defineClientPlugin(broadLegacyDefinition); - -const broadLegacyBackend: BackendPlugin = { - name: "broad-backend", - dbPlugin: createDbPlugin("broad-backend-db", {}), - routes: () => ({}), -}; -defineBackendPlugin(broadLegacyBackend); - function consumerClientPlugin() { return defineClientPlugin()({ id: "consumerProbe", @@ -101,21 +77,21 @@ const routeOnlyStack = createClientStack({ // @ts-expect-error No-override stacks do not accept activation-style empty blocks. ; -// @ts-expect-error Override values are inferred from the registered definition. ; -// @ts-expect-error Plugins without configurable fields are absent from overrides. ; diff --git a/packages/stack/src/__tests__/plugins.test.tsx b/packages/stack/src/__tests__/plugins.test.tsx index d397f7642..bfa3b5886 100644 --- a/packages/stack/src/__tests__/plugins.test.tsx +++ b/packages/stack/src/__tests__/plugins.test.tsx @@ -1,9 +1,14 @@ import { describe, it, expect } from "vitest"; -import { stack } from "../api"; -import { createStackClient } from "../client"; +import { createBackendStack } from "../api"; +import { createClientStack } from "../client"; import { defineBackendPlugin } from "../plugins/api"; import { defineClientPlugin } from "../plugins/client"; -import type { BackendPlugin, ClientPlugin } from "../types"; +import type { + BackendPlugin, + ClientPlugin, + ClientPluginRegistration, + MatchingPluginRegistrations, +} from "../types"; import type { BetterAuthDBSchema, DatabaseDefinition, @@ -14,6 +19,7 @@ import { createMemoryAdapter } from "@btst/adapter-memory"; import { createEndpoint as endpoint } from "better-call"; import { z } from "zod"; import { createRoute } from "@btst/yar"; +import { QueryClient } from "@tanstack/react-query"; /** * Adapter wrapper for testing @@ -23,6 +29,20 @@ const testAdapter = (db: DatabaseDefinition): Adapter => { return createMemoryAdapter(db)({}); }; +function createThirdPartyClient< + const TPlugins extends Record< + string, + ClientPluginRegistration + >, +>(config: { plugins: TPlugins & MatchingPluginRegistrations }) { + return createClientStack({ + api: { baseURL: "https://example.com", basePath: "/api" }, + site: { baseURL: "https://example.com", basePath: "/pages" }, + queryClient: new QueryClient(), + ...config, + }); +} + /** * Test schema for messages plugin * Using BetterAuthDBSchema format @@ -58,7 +78,7 @@ const messagesSchema: BetterAuthDBSchema = { * Now using defineBackendPlugin for full type inference - no casts needed! */ const messagesBackendPlugin = defineBackendPlugin({ - name: "messages", + id: "messages", dbPlugin: createDbPlugin("messages", messagesSchema), routes: (adapter) => ({ list: endpoint( @@ -152,23 +172,29 @@ const messageDetailLoader = async ({ params }: { params: { id: string } }) => { * Using Yar's createRoute() to create proper route handlers. */ const messagesClientPlugin = defineClientPlugin({ - name: "messages", - routes: () => ({ - messagesList: createRoute("/messages", () => ({ - PageComponent: MessagesListComponent, - loader: messagesListLoader, - })), - messageDetail: createRoute("/messages/:id", ({ params }) => ({ - PageComponent: MessageDetailComponent, - loader: () => messageDetailLoader({ params }), - })), + id: "messages", + resolve: () => ({ + routes: () => ({ + messagesList: createRoute("/messages", () => ({ + PageComponent: MessagesListComponent, + loader: messagesListLoader, + })), + messageDetail: createRoute("/messages/:id", ({ params }) => ({ + PageComponent: MessageDetailComponent, + loader: () => messageDetailLoader({ params }), + })), + }), }), }); +const resolvedMessagesClientPlugin = { + ...messagesClientPlugin.resolve({} as never), + id: "messages" as const, +}; describe("3rd Party Plugin Support", () => { describe("Backend Plugin", () => { it("should create backend with custom plugin", () => { - const backend = stack({ + const backend = createBackendStack({ basePath: "/api", plugins: { messages: messagesBackendPlugin, @@ -202,7 +228,7 @@ describe("3rd Party Plugin Support", () => { }; const notificationsPlugin = defineBackendPlugin({ - name: "notifications", + id: "notifications", dbPlugin: createDbPlugin("notifications", notificationsSchema), routes: (adapter) => ({ list: endpoint( @@ -223,7 +249,7 @@ describe("3rd Party Plugin Support", () => { }), }); - const backend = stack({ + const backend = createBackendStack({ basePath: "/api", plugins: { messages: messagesBackendPlugin, @@ -237,7 +263,7 @@ describe("3rd Party Plugin Support", () => { }); it("should generate routes for custom plugin", () => { - const backend = stack({ + const backend = createBackendStack({ basePath: "/api", plugins: { messages: messagesBackendPlugin, @@ -246,13 +272,13 @@ describe("3rd Party Plugin Support", () => { }); // Check that routes are properly registered - // The router should have routes prefixed with plugin name + // The router should have routes prefixed with the plugin ID. expect(backend.router).toBeDefined(); expect(typeof backend.router.handler).toBe("function"); }); it("should include plugin schema in database", () => { - const backend = stack({ + const backend = createBackendStack({ basePath: "/api", plugins: { messages: messagesBackendPlugin, @@ -269,7 +295,7 @@ describe("3rd Party Plugin Support", () => { describe("Client Plugin", () => { it("should create client with custom plugin", () => { - const client = createStackClient({ + const client = createThirdPartyClient({ plugins: { messages: messagesClientPlugin, }, @@ -280,7 +306,7 @@ describe("3rd Party Plugin Support", () => { }); it("should have routes from custom plugin", () => { - const client = createStackClient({ + const client = createThirdPartyClient({ plugins: { messages: messagesClientPlugin, }, @@ -293,7 +319,7 @@ describe("3rd Party Plugin Support", () => { it("should return correct route shape with Component and loader", async () => { // Test the route object directly from the plugin - types are now preserved! - const routes = messagesClientPlugin.routes(); + const routes = resolvedMessagesClientPlugin.routes(); const listRoute = routes.messagesList; expect(listRoute).toBeDefined(); @@ -321,7 +347,7 @@ describe("3rd Party Plugin Support", () => { it("should return correct route shape with params", async () => { // Test the route object directly from the plugin - types are now preserved! - const routes = messagesClientPlugin.routes(); + const routes = resolvedMessagesClientPlugin.routes(); const detailRoute = routes.messageDetail; expect(detailRoute).toBeDefined(); @@ -345,7 +371,7 @@ describe("3rd Party Plugin Support", () => { }); it("should have all expected routes defined", () => { - const routes = messagesClientPlugin.routes(); + const routes = resolvedMessagesClientPlugin.routes(); // Verify all expected routes exist - types are fully inferred! expect(routes.messagesList).toBeDefined(); @@ -358,7 +384,7 @@ describe("3rd Party Plugin Support", () => { it("should maintain type safety across route definitions", async () => { // This test verifies that TypeScript types are preserved - const routes = messagesClientPlugin.routes(); + const routes = resolvedMessagesClientPlugin.routes(); const route = routes.messagesList; // Route should be a function (Yar's handler) @@ -388,16 +414,20 @@ describe("3rd Party Plugin Support", () => { }; const notificationsClientPlugin = defineClientPlugin({ - name: "notifications", - routes: () => ({ - notificationsList: createRoute("/notifications", () => ({ - PageComponent: NotificationsComponent, - loader: notificationsLoader, - })), + id: "notifications", + resolve: () => ({ + routes: () => ({ + notificationsList: createRoute("/notifications", () => ({ + PageComponent: NotificationsComponent, + loader: notificationsLoader, + })), + }), }), }); + const resolvedNotificationsClientPlugin = + notificationsClientPlugin.resolve({} as never); - const client = createStackClient({ + const client = createThirdPartyClient({ plugins: { messages: messagesClientPlugin, notifications: notificationsClientPlugin, @@ -408,8 +438,8 @@ describe("3rd Party Plugin Support", () => { expect(client.router).toBeDefined(); // Verify both plugin routes are accessible from their definitions - full type safety! - const messagesRoutes = messagesClientPlugin.routes(); - const notificationsRoutes = notificationsClientPlugin.routes(); + const messagesRoutes = resolvedMessagesClientPlugin.routes(); + const notificationsRoutes = resolvedNotificationsClientPlugin.routes(); expect(messagesRoutes.messagesList).toBeDefined(); expect(typeof messagesRoutes.messagesList).toBe("function"); @@ -425,7 +455,7 @@ describe("3rd Party Plugin Support", () => { it("should use backend and client plugins independently", () => { // Backend and client plugins are completely separate // This prevents SSR issues and enables better code splitting - const backend = stack({ + const backend = createBackendStack({ basePath: "/api", plugins: { messages: messagesBackendPlugin, @@ -433,7 +463,7 @@ describe("3rd Party Plugin Support", () => { adapter: testAdapter, }); - const client = createStackClient({ + const client = createThirdPartyClient({ plugins: { messages: messagesClientPlugin, }, @@ -447,7 +477,7 @@ describe("3rd Party Plugin Support", () => { // This test mimics the actual usage pattern in a Next.js app // Uses router.getRoute() to match paths and get route handlers - const client = createStackClient({ + const client = createThirdPartyClient({ plugins: { messages: messagesClientPlugin, }, @@ -494,7 +524,7 @@ describe("3rd Party Plugin Support", () => { it("should handle parameterized routes correctly", async () => { // Test that router.getRoute() correctly extracts path parameters - const client = createStackClient({ + const client = createThirdPartyClient({ plugins: { messages: messagesClientPlugin, }, @@ -534,10 +564,10 @@ describe("3rd Party Plugin Support", () => { it("should export all necessary types for building plugins", () => { // This test verifies the types are available at compile time const backendPlugin: BackendPlugin = messagesBackendPlugin; - const clientPlugin: ClientPlugin = messagesClientPlugin; + const clientPlugin: ClientPlugin = resolvedMessagesClientPlugin; - expect(backendPlugin.name).toBe("messages"); - expect(clientPlugin.name).toBe("messages"); + expect(backendPlugin.id).toBe("messages"); + expect(clientPlugin.id).toBe("messages"); }); }); }); diff --git a/packages/stack/src/__tests__/resource-factory.test.tsx b/packages/stack/src/__tests__/resource-factory.test.tsx index 0f7d95a64..e80498724 100644 --- a/packages/stack/src/__tests__/resource-factory.test.tsx +++ b/packages/stack/src/__tests__/resource-factory.test.tsx @@ -335,11 +335,21 @@ describe("createResource hooks", () => { } async function render(ui: React.ReactElement) { + const stack = createClientStack({ + api: { baseURL: "http://test.local", basePath: "/api/data" }, + site: { baseURL: "http://test.local", basePath: "/pages" }, + queryClient, + plugins: { + "test-plugin": defineClientPlugin<{ headers?: HeadersInit }>()({ + id: "test-plugin", + resolve: () => ({ routes: () => ({}) }), + }), + }, + }); await act(async () => { root.render( { await act(async () => { root.render( diff --git a/packages/stack/src/__tests__/resource-use-select.test.tsx b/packages/stack/src/__tests__/resource-use-select.test.tsx index 415e926a8..b5c0f4100 100644 --- a/packages/stack/src/__tests__/resource-use-select.test.tsx +++ b/packages/stack/src/__tests__/resource-use-select.test.tsx @@ -7,6 +7,7 @@ import { StackProvider } from "../context"; import type { ResourcesDeclaration } from "../plugins/client"; import { createResource } from "../plugins/client/hooks"; import type { ResourceSelectResult } from "../plugins/client/hooks"; +import { createEmptyTestClientStack } from "./client-stack-test-utils"; (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; @@ -97,10 +98,7 @@ describe("resource useSelect", () => { } await act(async () => { root.render( - + diff --git a/packages/stack/src/__tests__/router-overrides.test.tsx b/packages/stack/src/__tests__/router-overrides.test.tsx index 5484f7244..704bb7d6d 100644 --- a/packages/stack/src/__tests__/router-overrides.test.tsx +++ b/packages/stack/src/__tests__/router-overrides.test.tsx @@ -1,8 +1,11 @@ import { describe, expect, it } from "vitest"; import { renderToString } from "react-dom/server"; import type { ComponentType } from "react"; +import { QueryClient } from "@tanstack/react-query"; +import { createClientStack } from "../client"; import { StackProvider, usePluginOverrides, useStack } from "../context"; import type { StackApiConfig, StackRouterConfig } from "../context"; +import { defineClientPlugin } from "../plugins/client"; function resolveProvider({ pluginName, @@ -12,7 +15,7 @@ function resolveProvider({ defaultValues, }: { pluginName: string; - overrides?: Record; + overrides?: { blog?: Record }; router?: StackRouterConfig; api?: StackApiConfig; defaultValues?: Record; @@ -26,14 +29,23 @@ function resolveProvider({ }; return null; } + const stack = createClientStack({ + api: { + baseURL: api?.baseURL ?? "https://example.com", + basePath: api?.basePath ?? "/api/data", + }, + site: { baseURL: "https://example.com", basePath: "/pages" }, + queryClient: new QueryClient(), + plugins: { + blog: defineClientPlugin>()({ + id: "blog", + resolve: () => ({ routes: () => ({}) }), + }), + }, + }); renderToString( - + , ); @@ -78,7 +90,7 @@ describe("top-level router/api context", () => { navigate, refresh, }); - expect(resolved.stack.api).toBe(api); + expect(resolved.stack.api).toEqual(api); expect(resolved.overrides).toEqual({}); }); diff --git a/packages/stack/src/__tests__/sitemap.test.ts b/packages/stack/src/__tests__/sitemap.test.ts index b22aca9e3..fbe6a7ae5 100644 --- a/packages/stack/src/__tests__/sitemap.test.ts +++ b/packages/stack/src/__tests__/sitemap.test.ts @@ -1,35 +1,49 @@ import { describe, it, expect } from "vitest"; -import { createStackClient } from "../client"; +import { createClientStack } from "../client"; import { defineClientPlugin } from "../plugins/client"; import { createRoute } from "@btst/yar"; +import { QueryClient } from "@tanstack/react-query"; + +function createStack( + plugins: Parameters[0]["plugins"], +) { + return createClientStack({ + api: { baseURL: "https://example.com", basePath: "/api" }, + site: { baseURL: "https://example.com", basePath: "/pages" }, + queryClient: new QueryClient(), + plugins, + }); +} describe("Client sitemap generation", () => { it("aggregates sitemaps from multiple plugins and de-duplicates by URL", async () => { const pluginA = defineClientPlugin({ - name: "a", - routes: () => ({ - a: createRoute("/a", () => ({ PageComponent: () => null })), + id: "a", + resolve: () => ({ + routes: () => ({ + a: createRoute("/a", () => ({ PageComponent: () => null })), + }), + sitemap: () => [ + { url: "https://example.com/a", priority: 0.8 }, + { url: "https://example.com/b", changeFrequency: "weekly" }, + ], }), - sitemap: () => [ - { url: "https://example.com/a", priority: 0.8 }, - { url: "https://example.com/b", changeFrequency: "weekly" }, - ], }); const pluginB = defineClientPlugin({ - name: "b", - routes: () => ({ - b: createRoute("/b", () => ({ PageComponent: () => null })), + id: "b", + resolve: () => ({ + routes: () => ({ + b: createRoute("/b", () => ({ PageComponent: () => null })), + }), + sitemap: async () => [ + { url: "https://example.com/b", priority: 0.5 }, // duplicate + { url: "https://example.com/c", changeFrequency: "monthly" }, + ], }), - sitemap: async () => [ - { url: "https://example.com/b", priority: 0.5 }, // duplicate - { url: "https://example.com/c", changeFrequency: "monthly" }, - ], }); - const client = createStackClient({ - plugins: { a: pluginA, b: pluginB }, - }); + const client = createStack({ a: pluginA, b: pluginB }); const entries = await client.generateSitemap(); const urls = entries.map((e) => e.url).sort(); @@ -47,13 +61,15 @@ describe("Client sitemap generation", () => { it("returns empty array when no plugins provide a sitemap", async () => { const noop = defineClientPlugin({ - name: "noop", - routes: () => ({ - home: createRoute("/", () => ({ PageComponent: () => null })), + id: "noop", + resolve: () => ({ + routes: () => ({ + home: createRoute("/", () => ({ PageComponent: () => null })), + }), }), }); - const client = createStackClient({ plugins: { noop } }); + const client = createStack({ noop }); const entries = await client.generateSitemap(); expect(entries).toEqual([]); }); diff --git a/packages/stack/src/__tests__/stack-api.test.ts b/packages/stack/src/__tests__/stack-api.test.ts index c32501092..cf784e91e 100644 --- a/packages/stack/src/__tests__/stack-api.test.ts +++ b/packages/stack/src/__tests__/stack-api.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { stack } from "../api"; +import { createBackendStack } from "../api"; import { defineBackendPlugin } from "../plugins/api"; import { createDbPlugin } from "@btst/db"; import { createMemoryAdapter } from "@btst/adapter-memory"; @@ -14,14 +14,14 @@ const testAdapter = (db: DatabaseDefinition): Adapter => * A minimal plugin with no `raw` factory. */ const noRawPlugin = defineBackendPlugin({ - name: "no-raw", + id: "noRaw", dbPlugin: createDbPlugin("no-raw", {}), routes: () => ({}), }); describe("stack.raw surface", () => { it("exposes adapter on the returned backend", () => { - const backend = stack({ + const backend = createBackendStack({ basePath: "/api", plugins: { blog: blogBackendPlugin() }, adapter: testAdapter, @@ -34,7 +34,7 @@ describe("stack.raw surface", () => { }); it("keeps stack.raw narrow for SSG prefetch helpers", () => { - const backend = stack({ + const backend = createBackendStack({ basePath: "/api", plugins: { blog: blogBackendPlugin() }, adapter: testAdapter, @@ -46,7 +46,7 @@ describe("stack.raw surface", () => { }); it("exposes the Kanban raw namespace", () => { - const backend = stack({ + const backend = createBackendStack({ basePath: "/api", plugins: { kanban: kanbanBackendPlugin() }, adapter: testAdapter, @@ -57,7 +57,7 @@ describe("stack.raw surface", () => { }); it("omits plugins without a raw factory", () => { - const backend = stack({ + const backend = createBackendStack({ basePath: "/api", plugins: { noRaw: noRawPlugin }, adapter: testAdapter, @@ -67,7 +67,7 @@ describe("stack.raw surface", () => { }); it("uses trusted operations for explicitly trusted business calls", async () => { - const backend = stack({ + const backend = createBackendStack({ basePath: "/api", plugins: { blog: blogBackendPlugin() }, adapter: testAdapter, @@ -94,7 +94,7 @@ describe("stack.raw surface", () => { }); it("does not retain ambiguous server aliases", () => { - const backend = stack({ + const backend = createBackendStack({ basePath: "/api", plugins: { blog: blogBackendPlugin() }, adapter: testAdapter, @@ -108,7 +108,7 @@ describe("stack.raw surface", () => { }); it("combines multiple plugins in a single stack call", () => { - const backend = stack({ + const backend = createBackendStack({ basePath: "/api", plugins: { blog: blogBackendPlugin(), diff --git a/packages/stack/src/__tests__/stack-auth.test.ts b/packages/stack/src/__tests__/stack-auth.test.ts index 76589e166..14ef948e6 100644 --- a/packages/stack/src/__tests__/stack-auth.test.ts +++ b/packages/stack/src/__tests__/stack-auth.test.ts @@ -2,7 +2,7 @@ import { createMemoryAdapter } from "@btst/adapter-memory"; import { createDbPlugin, type DatabaseDefinition } from "@btst/db"; import { describe, expect, it } from "vitest"; import { z } from "zod"; -import { stack } from "../api"; +import { createBackendStack } from "../api"; import { defineAuthorization, definePermissions, @@ -19,7 +19,7 @@ const authorization = defineAuthorization({ rules: ({ probe }) => [probe.read.allow()], }); -describe("stack() server authorization boundary", () => { +describe("createBackendStack() server authorization boundary", () => { it("exposes only a createServerAuth adapter to plugin composition", () => { const auth = createServerAuth({ authorization, @@ -27,7 +27,7 @@ describe("stack() server authorization boundary", () => { }); let seenAuth: ServerAuth | undefined; const probePlugin = defineBackendPlugin({ - name: "probe", + id: "probe", dbPlugin: createDbPlugin("probe", {}), routes: (_adapter, context) => { seenAuth = context?.auth as ServerAuth; @@ -35,7 +35,7 @@ describe("stack() server authorization boundary", () => { }, }); - stack({ + createBackendStack({ basePath: "/api", plugins: { probe: probePlugin }, adapter: testAdapter, @@ -47,13 +47,13 @@ describe("stack() server authorization boundary", () => { it("rejects the removed structural provider shape at runtime", () => { const plugin = defineBackendPlugin({ - name: "probe", + id: "probe", dbPlugin: createDbPlugin("probe", {}), routes: () => ({}), }); expect(() => - stack({ + createBackendStack({ basePath: "/api", plugins: { probe: plugin }, adapter: testAdapter, diff --git a/packages/stack/src/__tests__/stack-constructors.test.ts b/packages/stack/src/__tests__/stack-constructors.test.ts index dca5d9829..eeeccf56c 100644 --- a/packages/stack/src/__tests__/stack-constructors.test.ts +++ b/packages/stack/src/__tests__/stack-constructors.test.ts @@ -1,11 +1,12 @@ import { createMemoryAdapter } from "@btst/adapter-memory"; import { createDbPlugin, type DatabaseDefinition } from "@btst/db"; +import { QueryClient } from "@tanstack/react-query"; import { createRoute } from "@btst/yar"; import { describe, expect, it } from "vitest"; import { z } from "zod"; -import { createBackendStack, stack } from "../api"; +import { createBackendStack } from "../api"; import { definePermissions, permission } from "../authorization"; -import { createClientStack, createStackClient } from "../client"; +import { createClientStack } from "../client"; import { createEndpoint, defineBackendPlugin, @@ -25,7 +26,7 @@ const probeOperation = defineOperation({ execute: ({ input }) => ({ value: input.value }), }); const backendPlugin = defineBackendPlugin({ - name: "probe", + id: "probe", dbPlugin: createDbPlugin("probe", {}), operations: () => ({ echo: probeOperation }), routes: (_adapter, _context, operations) => ({ @@ -37,67 +38,59 @@ const backendPlugin = defineBackendPlugin({ }), }); const clientPlugin = defineClientPlugin({ - name: "probe", - routes: () => ({ - probe: createRoute("/probe", () => ({ - PageComponent: () => null, - })), + id: "probe", + resolve: () => ({ + routes: () => ({ + probe: createRoute("/probe", () => ({ + PageComponent: () => null, + })), + }), + sitemap: () => [{ url: "https://example.com/probe" }], }), - sitemap: () => [{ url: "https://example.com/probe" }], }); -describe("symmetric stack constructors", () => { - it("keeps the legacy constructor names as exact forwarding aliases", () => { - expect(stack).toBe(createBackendStack); - expect(createStackClient).toBe(createClientStack); - }); +describe("canonical stack constructors", () => { + it("preserves backend routes and operation surfaces", async () => { + const backend = createBackendStack({ + basePath: "/api", + plugins: { probe: backendPlugin }, + adapter, + }); - for (const [name, factory] of [ - ["canonical", createBackendStack], - ["temporary alias", stack], - ] as const) { - it(`preserves backend routes and operation surfaces through the ${name} name`, async () => { - const backend = factory({ - basePath: "/api", - plugins: { probe: backendPlugin }, - adapter, - }); + const endpointNames = Object.keys( + (backend.router as unknown as { endpoints: Record }) + .endpoints, + ); + expect(endpointNames).toContain("probe_echo"); + await expect( + backend.trusted.probe.echo({ value: "trusted" }), + ).resolves.toEqual({ value: "trusted" }); + await expect( + backend + .forRequest(new Request("https://example.com/api")) + .operations.probe.echo({ value: "request" }), + ).resolves.toEqual({ value: "request" }); - const endpointNames = Object.keys( - (backend.router as unknown as { endpoints: Record }) - .endpoints, - ); - expect(endpointNames).toContain("probe_echo"); - await expect( - backend.trusted.probe.echo({ value: "trusted" }), - ).resolves.toEqual({ value: "trusted" }); - await expect( - backend - .forRequest(new Request("https://example.com/api")) - .operations.probe.echo({ value: "request" }), - ).resolves.toEqual({ value: "request" }); + const response = await backend.handler( + new Request("https://example.com/api/echo/handler"), + ); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ value: "handler" }); + }); - const response = await backend.handler( - new Request("https://example.com/api/echo/handler"), - ); - expect(response.status).toBe(200); - expect(await response.json()).toEqual({ value: "handler" }); + it("preserves client routes and sitemap behavior", async () => { + const client = createClientStack({ + api: { baseURL: "https://example.com", basePath: "/api" }, + site: { baseURL: "https://example.com", basePath: "/pages" }, + queryClient: new QueryClient(), + plugins: { probe: clientPlugin }, }); - } - for (const [name, factory] of [ - ["canonical", createClientStack], - ["temporary alias", createStackClient], - ] as const) { - it(`preserves client routes and sitemap behavior through the ${name} name`, async () => { - const client = factory({ plugins: { probe: clientPlugin } }); - - expect(typeof client.router.getRoute("/probe")?.PageComponent).toBe( - "function", - ); - await expect(client.generateSitemap()).resolves.toEqual([ - { url: "https://example.com/probe" }, - ]); - }); - } + expect(typeof client.router.getRoute("/probe")?.PageComponent).toBe( + "function", + ); + await expect(client.generateSitemap()).resolves.toEqual([ + { url: "https://example.com/probe" }, + ]); + }); }); diff --git a/packages/stack/src/__tests__/stack-constructors.typecheck.ts b/packages/stack/src/__tests__/stack-constructors.typecheck.ts index 416204617..f969f526e 100644 --- a/packages/stack/src/__tests__/stack-constructors.typecheck.ts +++ b/packages/stack/src/__tests__/stack-constructors.typecheck.ts @@ -3,30 +3,17 @@ import { type DatabaseDefinition, type DBAdapter, } from "@btst/db"; +import { QueryClient } from "@tanstack/react-query"; import { createRoute } from "@btst/yar"; import { z } from "zod"; -import { - createBackendStack, - stack, - type BackendLib, - type BackendLibConfig, - type BackendStack, - type BackendStackConfig, -} from "../api"; +import { createBackendStack, type BackendStackConfig } from "../api"; import { defineAuthorization, definePermissions, permission, } from "../authorization"; import { createServerAuth } from "../authorization/server"; -import { - createClientStack, - createStackClient, - type ClientLib, - type ClientLibConfig, - type ClientStack, - type ClientStackConfig, -} from "../client"; +import { createClientStack, type ClientStackConfig } from "../client"; import { createEndpoint, defineBackendPlugin, @@ -60,7 +47,7 @@ const read = defineOperation({ execute: ({ input }) => ({ id: input.id }), }); const backendPlugin = defineBackendPlugin({ - name: "constructorProbe", + id: "constructorProbe", dbPlugin: createDbPlugin("constructorProbe", {}), operations: () => ({ read }), routes: (_adapter, _context, operations) => ({ @@ -81,28 +68,12 @@ const backendConfig = { typeof serverAuth >; -const canonicalBackend = createBackendStack(backendConfig); -const temporaryBackend = stack(backendConfig); -type _BackendFactoriesMatch = Expect< - Equal ->; -type _BackendInferenceMatches = Expect< - Equal ->; -type _CanonicalBackendRouteKeys = Expect< - Equal ->; -type _TemporaryBackendRouteKeys = Expect< - Equal +const backend = createBackendStack(backendConfig); +type _BackendRouteKeys = Expect< + Equal >; -const canonicalBackendConfig: BackendStackConfig = backendConfig; -const legacyBackendConfig: BackendLibConfig = canonicalBackendConfig; -const canonicalBackendResult: BackendStack = canonicalBackend; -const legacyBackendResult: BackendLib = canonicalBackendResult; -void legacyBackendConfig; -void legacyBackendResult; -canonicalBackend.trusted.constructorProbe.read({ id: "record-1" }); -canonicalBackend +backend.trusted.constructorProbe.read({ id: "record-1" }); +backend .forRequest(new Request("https://example.com/api")) .operations.constructorProbe.read({ id: "record-1" }); @@ -122,39 +93,23 @@ createBackendStack({ // @ts-expect-error canonical constructor rejects incompatible authorization catalogs auth: incompatibleAuth, }); -stack({ - ...backendConfig, - // @ts-expect-error temporary alias preserves authorization compatibility inference - auth: incompatibleAuth, -}); const clientPlugin = defineClientPlugin({ - name: "constructorProbe", - routes: () => ({ - read: createRoute("/constructor-probe/:id", ({ params }) => ({ - PageComponent: () => null, - loader: async () => ({ recordId: params.id }), - })), + id: "constructorProbe", + resolve: () => ({ + routes: () => ({ + read: createRoute("/constructor-probe/:id", ({ params }) => ({ + PageComponent: () => null, + loader: async () => ({ recordId: params.id }), + })), + }), }), }); const clientConfig = { + api: { baseURL: "https://example.com", basePath: "/api" }, + site: { baseURL: "https://example.com", basePath: "/pages" }, + queryClient: new QueryClient(), plugins: { constructorProbe: clientPlugin }, } satisfies ClientStackConfig<{ constructorProbe: typeof clientPlugin }>; -const canonicalClient = createClientStack(clientConfig); -const temporaryClient = createStackClient(clientConfig); -type _ClientFactoriesMatch = Expect< - Equal ->; -type _ClientInferenceMatches = Expect< - Equal ->; -const canonicalClientConfig: ClientStackConfig = clientConfig; -const legacyClientConfig: ClientLibConfig = canonicalClientConfig; -const canonicalClientResult: ClientStack< - ReturnType -> = canonicalClient; -const legacyClientResult: ClientLib> = - canonicalClientResult; -void legacyClientConfig; -void legacyClientResult; -canonicalClient.router.getRoute("/constructor-probe/record-1")?.loader?.(); +const client = createClientStack(clientConfig); +client.router.getRoute("/constructor-probe/record-1")?.loader?.(); diff --git a/packages/stack/src/__tests__/use-list-state.test.tsx b/packages/stack/src/__tests__/use-list-state.test.tsx index f3ffd73d5..09aacb530 100644 --- a/packages/stack/src/__tests__/use-list-state.test.tsx +++ b/packages/stack/src/__tests__/use-list-state.test.tsx @@ -4,6 +4,7 @@ import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useListState } from "../client/hooks/use-list-state"; import { StackProvider } from "../context"; +import { createEmptyTestClientStack } from "./client-stack-test-utils"; (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; @@ -54,7 +55,7 @@ describe("useListState", () => { } await act(async () => { root.render( - + , ); @@ -167,7 +168,7 @@ describe("useListState", () => { await act(async () => { root.render( - + , @@ -206,7 +207,7 @@ describe("useListState", () => { await act(async () => { root.render( - + , @@ -235,7 +236,7 @@ describe("useListState", () => { } await act(async () => { root.render( - + , ); @@ -273,7 +274,7 @@ describe("useListState", () => { // StackProvider without getSearchParams/setSearchParams on the router. await act(async () => { root.render( - + , ); @@ -307,7 +308,7 @@ describe("useListState", () => { } await act(async () => { root.render( - + , ); diff --git a/packages/stack/src/api/index.ts b/packages/stack/src/api/index.ts index 7451ebf9b..26560d126 100644 --- a/packages/stack/src/api/index.ts +++ b/packages/stack/src/api/index.ts @@ -27,10 +27,7 @@ import { type ComposedEndpointInventoryEntry, } from "../plugins/api/endpoint-inventory"; import { serializeValidationIssues } from "../plugins/api/create-endpoint"; -import { - resolvePluginProgrammaticId, - resolvePluginRegistrationIds, -} from "../plugin-registration"; +import { resolvePluginRegistrationIds } from "../plugin-registration"; export { toNodeHandler } from "better-call/node"; @@ -197,7 +194,7 @@ export function createBackendStack< endpointInventory.push( ...composeEndpointInventory( pluginKey, - resolvePluginProgrammaticId(plugin, registrationIds[pluginKey]!), + registrationIds[pluginKey]!, pluginRoutes, pluginOperations[pluginKey] ?? Object.create(null), plugin.operations !== undefined || runtimeAuth !== undefined, @@ -206,7 +203,7 @@ export function createBackendStack< ), ); - // Prefix route keys with plugin name to avoid collisions + // Prefix route keys with the canonical plugin ID to avoid collisions for (const [routeKey, endpoint] of Object.entries(pluginRoutes)) { const compositeKey = `${pluginKey}_${routeKey}` as keyof TRoutes; (allRoutes as any)[compositeKey] = endpoint; @@ -281,17 +278,10 @@ export function createBackendStack< }; } -/** - * @deprecated Use `createBackendStack`. This alias is removed by #225. - */ -export const stack: typeof createBackendStack = createBackendStack; - export type { BackendPlugin, BackendStackConfig, BackendStack, - BackendLibConfig, - BackendLib, PluginRaw, PluginOperations, StackContext, diff --git a/packages/stack/src/client/index.ts b/packages/stack/src/client/index.ts index 0f9e3045d..8b0087bae 100644 --- a/packages/stack/src/client/index.ts +++ b/packages/stack/src/client/index.ts @@ -2,22 +2,16 @@ import { createRouter } from "@btst/yar"; import type { ClientStackConfig, - ClientStack, ClientPlugin, ClientPluginDefinition, ClientPluginRegistration, ClientStackContext, - LegacyClientStackConfig, PluginRoutes, ResolvedClientStack, - ResolvedClientStackConfig, Sitemap, } from "../types"; import { resolveClientRuntime } from "./runtime"; -import { - resolvePluginProgrammaticId, - resolvePluginRegistrationIds, -} from "../plugin-registration"; +import { resolvePluginRegistrationIds } from "../plugin-registration"; export type { ClientApiConfig, ClientApiEndpointOverride, @@ -42,18 +36,6 @@ type AnyPluginMap = Record< string, ClientPluginRegistration >; -type LegacyPluginMap = Record>; - -function hasResolvedRuntime( - config: ClientStackConfig, -): config is ResolvedClientStackConfig { - return ( - Object.hasOwn(config, "api") || - Object.hasOwn(config, "site") || - Object.hasOwn(config, "queryClient") || - Object.hasOwn(config, "endpoints") - ); -} /** * Resolves all registered client plugin definitions against one API location, @@ -88,32 +70,11 @@ function hasResolvedRuntime( export function createClientStack< TPlugins extends AnyPluginMap, TRoutes extends PluginRoutes = PluginRoutes, ->( - config: ResolvedClientStackConfig, -): ResolvedClientStack; -export function createClientStack< - TPlugins extends LegacyPluginMap, - TRoutes extends PluginRoutes = PluginRoutes, ->(config: LegacyClientStackConfig): ClientStack; -export function createClientStack< - TPlugins extends AnyPluginMap, - TRoutes extends PluginRoutes = PluginRoutes, ->(config: ClientStackConfig): ClientStack; -export function createClientStack< - TPlugins extends AnyPluginMap, - TRoutes extends PluginRoutes = PluginRoutes, ->( - config: ClientStackConfig, -): ClientStack | ResolvedClientStack { - const registrations = Object.hasOwn(config, "plugins") - ? config.plugins - : undefined; +>(config: ClientStackConfig): ResolvedClientStack { + const registrations = config.plugins; const registrationIds = resolvePluginRegistrationIds(registrations, "client"); const validatedRegistrations = registrations as TPlugins; - const canonical = hasResolvedRuntime(config); - const runtime = canonical - ? resolveClientRuntime(config, registrationIds) - : undefined; + const runtime = resolveClientRuntime(config, registrationIds); const resolvedPlugins: Record> = Object.create( null, ); @@ -121,52 +82,35 @@ export function createClientStack< for (const [pluginKey, registration] of Object.entries( validatedRegistrations, )) { - if (Object.hasOwn(registration, "resolve")) { - if (!runtime) { - throw new Error( - `[btst/client] Client plugin "${pluginKey}" is a runtime-independent definition. Configure api, site, and queryClient on createClientStack().`, - ); - } - const definition = registration as ClientPluginDefinition< - any, - any, - any, - any, - any - >; - const resolution = definition.resolve(runtime.pluginRuntimes[pluginKey]!); - if (!resolution || typeof resolution.routes !== "function") { - throw new Error( - `[btst/client] Client plugin "${pluginKey}" did not resolve to a routes() function.`, - ); - } - resolvedPlugins[pluginKey] = { - ...resolution, - ...(Object.hasOwn(definition, "id") - ? { id: registrationIds[pluginKey] } - : {}), - name: resolvePluginProgrammaticId( - definition, - registrationIds[pluginKey]!, - ), - }; - } else if (Object.hasOwn(registration, "id")) { - resolvedPlugins[pluginKey] = { - ...(registration as ClientPlugin), - id: registrationIds[pluginKey], - name: registrationIds[pluginKey], - }; - } else { - resolvedPlugins[pluginKey] = registration as ClientPlugin; + const definition = registration as ClientPluginDefinition< + any, + any, + any, + any, + any + >; + if ( + !Object.hasOwn(definition, "resolve") || + typeof definition.resolve !== "function" + ) { + throw new Error( + `[btst/client] Client plugin "${pluginKey}" must declare an own resolve() function.`, + ); + } + const resolution = definition.resolve(runtime.pluginRuntimes[pluginKey]!); + if (!resolution || typeof resolution.routes !== "function") { + throw new Error( + `[btst/client] Client plugin "${pluginKey}" did not resolve to a routes() function.`, + ); } + resolvedPlugins[pluginKey] = { + ...resolution, + id: registrationIds[pluginKey]!, + }; } const plugins = resolvedPlugins as Record>; - const basePath = canonical - ? runtime!.provider.site.basePath - : Object.hasOwn(config, "basePath") - ? config.basePath - : undefined; + const basePath = runtime.provider.site.basePath; // Collect all routes from all plugins // We build this with type assertions to preserve literal keys @@ -188,7 +132,7 @@ export function createClientStack< // The router's getRoute method will return the union of all route return types const router = createRouter(allRoutes); - const result: ClientStack = { + const result = { context: clientStackContext, router, async generateSitemap() { @@ -210,21 +154,14 @@ export function createClientStack< } return deduped; }, - }; + } as const; - return runtime ? { ...result, provider: runtime.provider } : result; + return { ...result, provider: runtime.provider }; } -/** - * @deprecated Use `createClientStack`. This alias is removed by #225. - */ -export const createStackClient: typeof createClientStack = createClientStack; - export type { ClientStack, ClientStackConfig, - ClientLib, - ClientLibConfig, } from "../types"; export { sitemapEntryToXmlString } from "./sitemap-utils"; diff --git a/packages/stack/src/client/runtime.ts b/packages/stack/src/client/runtime.ts index 8f90a8910..f6243891c 100644 --- a/packages/stack/src/client/runtime.ts +++ b/packages/stack/src/client/runtime.ts @@ -210,6 +210,13 @@ export function resolveClientRuntime( config: ResolvedClientStackConfig, registrationIds: Readonly>, ): ResolvedClientRuntime { + for (const removedField of ["baseURL", "basePath"] as const) { + if (Object.hasOwn(config, removedField)) { + throw new Error( + `[btst/client] Top-level ${removedField} was removed. Configure it under api or site.`, + ); + } + } const apiConfig = ownValue(config, "api"); const siteConfig = ownValue(config, "site"); const queryClient = ownValue(config, "queryClient") as diff --git a/packages/stack/src/context/provider.tsx b/packages/stack/src/context/provider.tsx index 1c7083aa5..173c6f872 100644 --- a/packages/stack/src/context/provider.tsx +++ b/packages/stack/src/context/provider.tsx @@ -61,15 +61,6 @@ interface StackContextValue> { const StackContext = createContext | null>(null); -/** - * The `overrides` prop shape for `StackProvider`. - * Plugin blocks are optional; fields inside each block retain the plugin's - * declared requirements. - */ -export type StackProviderOverrides< - TPluginOverrides extends Record, -> = Partial; - type StackProviderServices = { children?: ReactNode; router?: StackRouterConfig; @@ -102,28 +93,12 @@ type CanonicalStackProviderProps> = StackProviderServices & { /** Resolved browser client stack; supplies API/site/plugin runtime once. */ stack: TStack; - /** Runtime paths come from `stack`. */ - basePath?: never; - /** Runtime API configuration comes from `stack`. */ + /** API configuration belongs to createClientStack(). */ api?: never; + /** Site paths belong to createClientStack(). */ + basePath?: never; } & CanonicalStackProviderOverrideProps; -type CanonicalStackProviderImplementationProps = StackProviderServices & { - stack: ResolvedClientStack; - overrides?: Record; - basePath?: never; - api?: never; -}; - -type LegacyStackProviderProps> = - StackProviderServices & { - /** @deprecated Pass the resolved `stack` instead. Removed by #225. */ - stack?: never; - overrides?: StackProviderOverrides; - basePath: string; - api?: StackApiConfig; - }; - /** Removes keys whose value is `undefined` so they don't clobber lower layers in spreads. */ function stripUndefined>(obj: T): Partial { const result: Record = {}; @@ -217,42 +192,28 @@ function RouterBridge({ * * ``` */ -export function StackProvider< - TPluginOverrides extends Record = Record, ->(props: LegacyStackProviderProps): ReactElement; export function StackProvider< const TStack extends ResolvedClientStack, ->(props: CanonicalStackProviderProps): ReactElement; -export function StackProvider({ +>({ children, overrides, - basePath, stack, router, - api, auth, initialIdentity, notify, i18n, -}: - | LegacyStackProviderProps> - | CanonicalStackProviderImplementationProps): ReactElement { - const projection = stack?.provider; - const resolvedBasePath = projection?.site.basePath ?? basePath; - if (resolvedBasePath === undefined) { - throw new Error( - "StackProvider requires a resolved client stack or a legacy basePath.", - ); - } +}: CanonicalStackProviderProps): ReactElement { + const projection = stack.provider; const staticRouter = resolveStaticRouter(router); const value: Omit, "router"> = { overrides: overrides ?? {}, - basePath: resolvedBasePath, - api: projection?.api ?? api, - site: projection?.site, - plugins: projection?.plugins, - queryClient: projection?.queryClient, - clientStackContext: stack?.context, + basePath: projection.site.basePath, + api: projection.api, + site: projection.site, + plugins: projection.plugins, + queryClient: projection.queryClient, + clientStackContext: stack.context, resolvedStack: stack, auth, }; diff --git a/packages/stack/src/next/router.tsx b/packages/stack/src/next/router.tsx index 2d4af4c3d..b07a69e9e 100644 --- a/packages/stack/src/next/router.tsx +++ b/packages/stack/src/next/router.tsx @@ -89,7 +89,7 @@ function useNextStackRouter(): StackRouter { * ```tsx * import { nextRouter } from "@btst/stack/next"; * - * + * * ``` */ export function nextRouter(): StackRouterConfig { diff --git a/packages/stack/src/plugin-registration.ts b/packages/stack/src/plugin-registration.ts index e8630d1d9..c99efe081 100644 --- a/packages/stack/src/plugin-registration.ts +++ b/packages/stack/src/plugin-registration.ts @@ -12,22 +12,7 @@ function isPluginObject(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } -/** Resolve the stable programmatic ID while retaining legacy `name` support. */ -export function resolvePluginProgrammaticId( - plugin: object, - registrationId: string, -): string { - if (Object.hasOwn(plugin, "id")) return registrationId; - const legacyName = (plugin as { name?: unknown }).name; - return typeof legacyName === "string" && legacyName.length > 0 - ? legacyName - : registrationId; -} - -/** - * Validates canonical plugin IDs before any plugin factory or adapter work. - * Legacy plugins without an own `id` remain bound to their registration key. - */ +/** Validates canonical plugin IDs before any plugin factory or adapter work. */ export function resolvePluginRegistrationIds( plugins: unknown, side: PluginSide, @@ -50,8 +35,9 @@ export function resolvePluginRegistrationIds( } if (!Object.hasOwn(plugin, "id")) { - idsByKey[key] = key; - continue; + throw new Error( + `[btst/${side}] ${side === "client" ? "Client" : "Backend"} plugin registered as "${key}" must declare a stable id.`, + ); } const id = plugin.id; diff --git a/packages/stack/src/plugins/ai-chat/__tests__/authorization.test.ts b/packages/stack/src/plugins/ai-chat/__tests__/authorization.test.ts index 20aa63c40..5e20b038f 100644 --- a/packages/stack/src/plugins/ai-chat/__tests__/authorization.test.ts +++ b/packages/stack/src/plugins/ai-chat/__tests__/authorization.test.ts @@ -2,7 +2,7 @@ import { createMemoryAdapter } from "@btst/adapter-memory"; import { type DatabaseDefinition, type DBAdapter, defineDb } from "@btst/db"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { z } from "zod"; -import { stack } from "../../../api"; +import { createBackendStack } from "../../../api"; import { defineAuthorization } from "../../../authorization"; import { createServerAuth } from "../../../authorization/server"; import { @@ -113,7 +113,7 @@ function backend(options?: { request: Request, ) => Identity | null | Promise; }) { - return stack({ + return createBackendStack({ basePath: "/api", plugins: { aiChat: aiChatBackendPlugin({ @@ -754,7 +754,7 @@ describe("AI Chat operation authorization", () => { }); it("preserves ownerless history when no request authorization is configured", async () => { - const app = stack({ + const app = createBackendStack({ basePath: "/api", plugins: { aiChat: aiChatBackendPlugin({ model }) }, adapter: memory, diff --git a/packages/stack/src/plugins/ai-chat/__tests__/chat-hooks.test.tsx b/packages/stack/src/plugins/ai-chat/__tests__/chat-hooks.test.tsx index adfd15e31..6d394a763 100644 --- a/packages/stack/src/plugins/ai-chat/__tests__/chat-hooks.test.tsx +++ b/packages/stack/src/plugins/ai-chat/__tests__/chat-hooks.test.tsx @@ -5,11 +5,17 @@ import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { StackProvider } from "@btst/stack/context"; import { createIdentityTestAuth } from "../../../__tests__/auth-test-utils"; +import { createTestClientStack } from "../../../__tests__/client-stack-test-utils"; import { type UseConversationsResult, useConversations, useSuspenseConversations, } from "../client/hooks/chat-hooks"; +import { aiChatClientPlugin } from "../client/plugin"; + +function createAiChatStack(queryClient: QueryClient) { + return createTestClientStack({ aiChat: aiChatClientPlugin() }, queryClient); +} (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; @@ -69,11 +75,7 @@ describe("AI Chat identity-aware hooks", () => { await act(async () => { root.render( - + @@ -120,11 +122,7 @@ describe("AI Chat identity-aware hooks", () => { await act(async () => { root.render( - + diff --git a/packages/stack/src/plugins/ai-chat/__tests__/chat-input-authorization.test.tsx b/packages/stack/src/plugins/ai-chat/__tests__/chat-input-authorization.test.tsx index 50aaffa6c..b0904a8c7 100644 --- a/packages/stack/src/plugins/ai-chat/__tests__/chat-input-authorization.test.tsx +++ b/packages/stack/src/plugins/ai-chat/__tests__/chat-input-authorization.test.tsx @@ -8,6 +8,12 @@ import { createClientAuth } from "@btst/stack/authorization/client"; import { StackProvider } from "@btst/stack/context"; import { ChatInput } from "../client/components/chat-input"; import { aiChatPermissions } from "../permissions"; +import { createTestClientStack } from "../../../__tests__/client-stack-test-utils"; +import { aiChatClientPlugin } from "../client/plugin"; + +function createAiChatStack() { + return createTestClientStack({ aiChat: aiChatClientPlugin() }); +} (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; @@ -56,7 +62,7 @@ describe("AI Chat attachment authorization", () => { await act(async () => { root.render( { await act(async () => { root.render( ; beforeEach(() => { @@ -1761,7 +1766,7 @@ describe("AI Chat route lifecycle", () => { await act(async () => { root.render( >("aiChat", {}); - const { basePath: legacyBasePath, plugins, router } = useStack(); + const { basePath: stackBasePath, plugins, router } = useStack(); const navigate = router?.navigate; const siteBaseURL = plugins?.aiChat?.site.baseURL; - const siteBasePath = plugins?.aiChat?.site.basePath ?? legacyBasePath; + const siteBasePath = plugins?.aiChat?.site.basePath ?? stackBasePath; const notify = useNotify(); const tr = useAiChatTranslation(customLocalization); const { conversations, isLoading } = useConversations(); diff --git a/packages/stack/src/plugins/api/index.ts b/packages/stack/src/plugins/api/index.ts index 6260b3eb5..e9010c557 100644 --- a/packages/stack/src/plugins/api/index.ts +++ b/packages/stack/src/plugins/api/index.ts @@ -18,16 +18,7 @@ type IdentifiedBackendPlugin< TRoutes extends Record, TRaw extends Record any>, TOperations extends import("./operation").OperationRecord, -> = BackendPlugin & { readonly id: TId }; - -type LegacyBackendPlugin< - TRoutes extends Record, - TRaw extends Record any>, - TOperations extends import("./operation").OperationRecord, -> = BackendPlugin & { - name: string; - readonly id?: never; -}; +> = BackendPlugin; export type { BackendPlugin, @@ -97,23 +88,6 @@ export function defineBackendPlugin< TOperations extends import("./operation").OperationRecord = never, >( plugin: IdentifiedBackendPlugin, -): IdentifiedBackendPlugin; -export function defineBackendPlugin< - TRoutes extends Record = Record, - TRaw extends Record any> = never, - TOperations extends import("./operation").OperationRecord = never, ->( - plugin: LegacyBackendPlugin, -): LegacyBackendPlugin; -export function defineBackendPlugin< - TRoutes extends Record = Record, - TRaw extends Record any> = never, - TOperations extends import("./operation").OperationRecord = never, ->( - plugin: BackendPlugin, -): BackendPlugin; -export function defineBackendPlugin( - plugin: BackendPlugin, -): BackendPlugin { +): IdentifiedBackendPlugin { return plugin; } diff --git a/packages/stack/src/plugins/blog/__tests__/authorization.test.ts b/packages/stack/src/plugins/blog/__tests__/authorization.test.ts index a56f6494b..34f9b6bff 100644 --- a/packages/stack/src/plugins/blog/__tests__/authorization.test.ts +++ b/packages/stack/src/plugins/blog/__tests__/authorization.test.ts @@ -2,7 +2,7 @@ import { createMemoryAdapter } from "@btst/adapter-memory"; import { defineDb, type DatabaseDefinition } from "@btst/db"; import { describe, expect, it, vi } from "vitest"; import { z } from "zod"; -import { stack } from "../../../api"; +import { createBackendStack } from "../../../api"; import { defineAuthorization } from "../../../authorization"; import { createServerAuth, @@ -141,7 +141,7 @@ function makeBackend(options?: { hooks?: BlogBackendHooks; auth?: ServerAuth; }) { - return stack({ + return createBackendStack({ basePath: "/api", plugins: { blog: blogBackendPlugin(options?.hooks ? { hooks: options.hooks } : {}), @@ -1273,7 +1273,7 @@ describe("Blog delete one-rule authorization tracer", () => { ], }); const ruleLifecycleEvents: string[] = []; - const ruleFailure = stack({ + const ruleFailure = createBackendStack({ basePath: "/api", plugins: { blog: blogBackendPlugin({ @@ -1321,7 +1321,7 @@ describe("Blog delete one-rule authorization tracer", () => { rules: () => [], }); const missingRuleLifecycleEvents: string[] = []; - const missingRule = stack({ + const missingRule = createBackendStack({ basePath: "/api", plugins: { blog: blogBackendPlugin({ diff --git a/packages/stack/src/plugins/blog/__tests__/client-runtime.typecheck.tsx b/packages/stack/src/plugins/blog/__tests__/client-runtime.typecheck.tsx index 3a662c45a..edebb16b2 100644 --- a/packages/stack/src/plugins/blog/__tests__/client-runtime.typecheck.tsx +++ b/packages/stack/src/plugins/blog/__tests__/client-runtime.typecheck.tsx @@ -33,10 +33,14 @@ const stack = createClientStack({ }} />; -// @ts-expect-error Blog override values are inferred from registration. StackProvider({ stack, - overrides: { blog: { uploadImage: "not-a-function" } }, + overrides: { + blog: { + // @ts-expect-error Blog override values are inferred from registration. + uploadImage: "not-a-function", + }, + }, }); // @ts-expect-error Unregistered provider override keys are rejected. diff --git a/packages/stack/src/plugins/blog/__tests__/client-sweep.test.tsx b/packages/stack/src/plugins/blog/__tests__/client-sweep.test.tsx index 986895065..6df4d2a9e 100644 --- a/packages/stack/src/plugins/blog/__tests__/client-sweep.test.tsx +++ b/packages/stack/src/plugins/blog/__tests__/client-sweep.test.tsx @@ -23,6 +23,8 @@ import type { SerializedPost } from "../types"; import { blogPermissions } from "../permissions"; import { z } from "zod"; import { createIdentityTestAuth } from "../../../__tests__/auth-test-utils"; +import { createTestClientStack } from "../../../__tests__/client-stack-test-utils"; +import { blogClientPlugin } from "../client/plugin"; (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; @@ -119,6 +121,14 @@ async function render(ui: React.ReactElement) { }); } +function createBlogStack() { + return createTestClientStack({ blog: blogClientPlugin() }); +} + +function blogOverrides() { + return { uploadImage: async () => "" }; +} + function texts(): string { return container.textContent ?? ""; } @@ -164,7 +174,7 @@ describe("FeaturedImageField notifications (useNotify)", () => { await render( @@ -186,7 +196,7 @@ describe("FeaturedImageField notifications (useNotify)", () => { await render( @@ -207,7 +217,7 @@ describe("FeaturedImageField notifications (useNotify)", () => { await render( @@ -235,9 +245,9 @@ describe("Blog route authorization errors", () => { await render( null, { loginPath: "/login" })} initialIdentity={null} > @@ -264,9 +274,9 @@ describe("Blog route authorization errors", () => { await render( ({ id: "stale-user", role: "user" }), { loginPath: "/login" }, @@ -298,9 +308,9 @@ describe("Blog route authorization errors", () => { await render( new Promise(() => {}), { loginPath: "/login", })} @@ -336,8 +346,8 @@ describe("EditPostForm operation descriptor controls", () => { await render( @@ -351,7 +361,10 @@ describe("EditPostForm operation descriptor controls", () => { it("shows the delete button without an auth provider", async () => { await render( - + {}} @@ -378,8 +391,8 @@ describe("EditPostForm operation descriptor controls", () => { await render( @@ -416,7 +429,11 @@ describe("SearchInput list state (useListState)", () => { const router = createMockRouter("q=hello"); await render( - + , ); @@ -432,7 +449,11 @@ describe("SearchInput list state (useListState)", () => { const router = createMockRouter(); await render( - + , ); @@ -478,7 +499,11 @@ describe("SearchInput list state (useListState)", () => { const router = createMockRouter("q=hello"); await render( - + , ); @@ -509,7 +534,10 @@ describe("SearchInput list state (useListState)", () => { describe("blog i18n precedence (useTranslate + overrides.localization)", () => { it("renders the English default without providers", async () => { await render( - + , ); @@ -524,7 +552,11 @@ describe("blog i18n precedence (useTranslate + overrides.localization)", () => { }; await render( - + , ); @@ -539,9 +571,12 @@ describe("blog i18n precedence (useTranslate + overrides.localization)", () => { await render( diff --git a/packages/stack/src/plugins/blog/client/overrides.ts b/packages/stack/src/plugins/blog/client/overrides.ts index eb069fe61..8f0ff88b9 100644 --- a/packages/stack/src/plugins/blog/client/overrides.ts +++ b/packages/stack/src/plugins/blog/client/overrides.ts @@ -93,7 +93,7 @@ export interface BlogPluginOverrides { /** * Localization object for the blog plugin */ - localization?: BlogLocalization; + localization?: Partial; /** * Whether to show the attribution */ diff --git a/packages/stack/src/plugins/client/index.ts b/packages/stack/src/plugins/client/index.ts index 491e5a605..beb0c3f7a 100644 --- a/packages/stack/src/plugins/client/index.ts +++ b/packages/stack/src/plugins/client/index.ts @@ -10,15 +10,9 @@ * - Client: import type { ClientPlugin } from "@btst/stack/plugins/client" */ -import type { ClientPlugin, ClientPluginDefinition } from "../../types"; +import type { ClientPluginDefinition } from "../../types"; import type { Route } from "@btst/yar"; -type IdentifiedClientPlugin< - TId extends string, - TOverrides, - TRoutes extends Record, -> = ClientPlugin & { readonly id: TId }; - type IdentifiedClientPluginDefinition< TId extends string, TOverrides, @@ -33,28 +27,11 @@ type IdentifiedClientPluginDefinition< TId, TProviderConfig, TApiRuntimeFrom -> & { - readonly id: TId; -} & ([TApiRuntimeFrom] extends [never] +> & + ([TApiRuntimeFrom] extends [never] ? unknown : { readonly apiRuntimeFrom: TApiRuntimeFrom }); -type LegacyClientPlugin< - TOverrides, - TRoutes extends Record, -> = ClientPlugin & { - name: string; - readonly id?: never; -}; - -type LegacyClientPluginDefinition< - TOverrides, - TRoutes extends Record, -> = ClientPluginDefinition & { - name: string; - readonly id?: never; -}; - interface DefineClientPluginWithOverrides { < const TId extends string, @@ -78,9 +55,6 @@ interface DefineClientPluginWithOverrides { TProviderConfig, TApiRuntimeFrom >; - >( - plugin: IdentifiedClientPlugin, - ): IdentifiedClientPlugin; } export type { @@ -165,12 +139,6 @@ export { createClient } from "better-call/client"; export function defineClientPlugin< TOverrides, >(): DefineClientPluginWithOverrides; -export function defineClientPlugin< - const TId extends string, - TRoutes extends Record, ->( - plugin: IdentifiedClientPlugin, TRoutes>, -): IdentifiedClientPlugin, TRoutes>; export function defineClientPlugin< const TId extends string, TRoutes extends Record, @@ -193,22 +161,6 @@ export function defineClientPlugin< TProviderConfig, TApiRuntimeFrom >; -export function defineClientPlugin< - TOverrides, - TRoutes extends Record = Record, ->( - plugin: LegacyClientPlugin, -): LegacyClientPlugin; -export function defineClientPlugin< - TOverrides, - TRoutes extends Record = Record, ->( - plugin: LegacyClientPluginDefinition, -): LegacyClientPluginDefinition; -export function defineClientPlugin< - TOverrides = Record, - TRoutes extends Record = Record, ->(plugin: ClientPlugin): ClientPlugin; export function defineClientPlugin< TOverrides = Record, TRoutes extends Record = Record, @@ -232,11 +184,8 @@ export function defineClientPlugin< TApiRuntimeFrom >; export function defineClientPlugin( - plugin?: - | ClientPlugin - | ClientPluginDefinition, + plugin?: ClientPluginDefinition, ): - | ClientPlugin | ClientPluginDefinition | DefineClientPluginWithOverrides { if (plugin === undefined) { diff --git a/packages/stack/src/plugins/client/resource/hooks.tsx b/packages/stack/src/plugins/client/resource/hooks.tsx index f0393f9a0..f259aad90 100644 --- a/packages/stack/src/plugins/client/resource/hooks.tsx +++ b/packages/stack/src/plugins/client/resource/hooks.tsx @@ -138,7 +138,7 @@ export type Resource = { }; export interface CreateResourceConfig { - /** Plugin name used to resolve plugin-specific request headers. */ + /** Plugin ID used to resolve plugin-specific request headers. */ plugin: string; resources: TResources; } diff --git a/packages/stack/src/plugins/cms/__tests__/authorization.test.ts b/packages/stack/src/plugins/cms/__tests__/authorization.test.ts index 3a94512b2..fe6f035b0 100644 --- a/packages/stack/src/plugins/cms/__tests__/authorization.test.ts +++ b/packages/stack/src/plugins/cms/__tests__/authorization.test.ts @@ -3,7 +3,7 @@ import { defineDb, type DatabaseDefinition } from "@btst/db"; import { zodToFormSchema } from "@workspace/ui/lib/schema-converter"; import { describe, expect, it, vi } from "vitest"; import { z } from "zod"; -import { stack } from "../../../api"; +import { createBackendStack } from "../../../api"; import { defineAuthorization } from "../../../authorization"; import { createServerAuth, @@ -146,7 +146,7 @@ function makeBackend(options?: { auth?: ServerAuth; hooks?: CMSBackendHooks; }) { - return stack({ + return createBackendStack({ basePath: "/api", plugins: { cms: cmsBackendPlugin({ diff --git a/packages/stack/src/plugins/cms/__tests__/client-runtime.typecheck.tsx b/packages/stack/src/plugins/cms/__tests__/client-runtime.typecheck.tsx index 86a9c833f..3172ae801 100644 --- a/packages/stack/src/plugins/cms/__tests__/client-runtime.typecheck.tsx +++ b/packages/stack/src/plugins/cms/__tests__/client-runtime.typecheck.tsx @@ -64,10 +64,14 @@ stack.provider.plugins.uiBuilder.config?.components satisfies // @ts-expect-error CMS override values are inferred from registration. StackProvider({ stack, overrides: { cms: { uploadImage: "not-a-function" } } }); -// @ts-expect-error UI Builder components are configured by uiBuilderClientPlugin(). StackProvider({ stack, - overrides: { uiBuilder: { componentRegistry: defaultComponentRegistry } }, + overrides: { + uiBuilder: { + // @ts-expect-error UI Builder components are configured by uiBuilderClientPlugin(). + componentRegistry: defaultComponentRegistry, + }, + }, }); // @ts-expect-error Kebab-case is only a package and route slug. StackProvider({ stack, overrides: { "ui-builder": {} } }); diff --git a/packages/stack/src/plugins/cms/__tests__/client-sweep.test.tsx b/packages/stack/src/plugins/cms/__tests__/client-sweep.test.tsx index ebbca7aca..fbe1b8781 100644 --- a/packages/stack/src/plugins/cms/__tests__/client-sweep.test.tsx +++ b/packages/stack/src/plugins/cms/__tests__/client-sweep.test.tsx @@ -17,6 +17,7 @@ import { import { defineAuthorization } from "@btst/stack/authorization"; import { createClientAuth } from "@btst/stack/authorization/client"; import { createClientStack } from "../../../client"; +import { createTestClientStack } from "../../../__tests__/client-stack-test-utils"; import { cmsClientPlugin } from "../client"; import { CMSFileUpload } from "../client/components/forms/file-upload"; import { ContentForm } from "../client/components/forms/content-form"; @@ -141,6 +142,10 @@ async function render(ui: React.ReactElement) { }); } +function createCmsStack() { + return createTestClientStack({ cms: cmsClientPlugin() }); +} + function texts(): string { return container.textContent ?? ""; } @@ -212,7 +217,7 @@ describe("CMSFileUpload notifications (useNotify)", () => { await render( @@ -233,7 +238,7 @@ describe("CMSFileUpload notifications (useNotify)", () => { await render( @@ -251,7 +256,7 @@ describe("CMSFileUpload notifications (useNotify)", () => { describe("ContentForm inline errors", () => { it("shows a non-field error message above the form", async () => { await render( - + {}} @@ -265,7 +270,7 @@ describe("ContentForm inline errors", () => { it("lists server field errors in the banner when no form instance was captured", async () => { await render( - + {}} @@ -282,7 +287,7 @@ describe("ContentListPage row actions (CanAccess)", () => { function renderListPage(auth?: StackClientAuth, router = createMockRouter()) { return render( { await render( { await render( { expect(router.navigate).toHaveBeenCalledWith("/cms/post/new"); }); - it("falls back to the legacy provider base path", async () => { + it("falls back to the shared stack site path", async () => { const router = createMockRouter(); await render( @@ -506,7 +511,7 @@ describe("ContentListPage search (useListState)", () => { await render( @@ -531,7 +536,7 @@ describe("ContentListPage search (useListState)", () => { await render( @@ -575,7 +580,7 @@ describe("ContentListPage search (useListState)", () => { await render( @@ -624,7 +629,7 @@ describe("cms i18n precedence (useTranslate + overrides.localization)", () => { await render( @@ -651,7 +656,7 @@ describe("cms i18n precedence (useTranslate + overrides.localization)", () => { await render( { await render( (CMS_PLUGIN_ID); - const { router, plugins, basePath: legacyBasePath } = useStack(); + const { router, plugins, basePath: stackBasePath } = useStack(); const navigate = router?.navigate; const Link = router?.Link; - const basePath = plugins?.[CMS_PLUGIN_ID]?.site.basePath ?? legacyBasePath; + const basePath = plugins?.[CMS_PLUGIN_ID]?.site.basePath ?? stackBasePath; // Fetch inverse relations metadata const { inverseRelations, isLoading } = useInverseRelations( diff --git a/packages/stack/src/plugins/cms/client/components/pages/404-page.tsx b/packages/stack/src/plugins/cms/client/components/pages/404-page.tsx index 308769bf1..8ca11f55b 100644 --- a/packages/stack/src/plugins/cms/client/components/pages/404-page.tsx +++ b/packages/stack/src/plugins/cms/client/components/pages/404-page.tsx @@ -14,8 +14,8 @@ export function NotFoundPage() { const t = useTranslate(); const { localization } = usePluginOverrides(CMS_PLUGIN_ID); - const { router, plugins, basePath: legacyBasePath } = useStack(); - const basePath = plugins?.[CMS_PLUGIN_ID]?.site.basePath ?? legacyBasePath; + const { router, plugins, basePath: stackBasePath } = useStack(); + const basePath = plugins?.[CMS_PLUGIN_ID]?.site.basePath ?? stackBasePath; const LinkComponent = router?.Link ?? "a"; diff --git a/packages/stack/src/plugins/cms/client/components/pages/content-editor-page.internal.tsx b/packages/stack/src/plugins/cms/client/components/pages/content-editor-page.internal.tsx index 5face1ebd..3ee901bca 100644 --- a/packages/stack/src/plugins/cms/client/components/pages/content-editor-page.internal.tsx +++ b/packages/stack/src/plugins/cms/client/components/pages/content-editor-page.internal.tsx @@ -138,9 +138,9 @@ export function ContentEditorPage({ typeSlug, id }: ContentEditorPageProps) { const t = useTranslate(); const overrides = usePluginOverrides(CMS_PLUGIN_ID); const { localization } = overrides; - const { router, plugins, basePath: legacyBasePath } = useStack(); + const { router, plugins, basePath: stackBasePath } = useStack(); const navigate = router?.navigate; - const basePath = plugins?.[CMS_PLUGIN_ID]?.site.basePath ?? legacyBasePath; + const basePath = plugins?.[CMS_PLUGIN_ID]?.site.basePath ?? stackBasePath; // Parse prefill query parameters for pre-populating fields when creating new items // This is used by the inverse relations panel to pre-fill the parent relation diff --git a/packages/stack/src/plugins/cms/client/components/pages/content-list-page.internal.tsx b/packages/stack/src/plugins/cms/client/components/pages/content-list-page.internal.tsx index 1a396db8c..7df70e4a3 100644 --- a/packages/stack/src/plugins/cms/client/components/pages/content-list-page.internal.tsx +++ b/packages/stack/src/plugins/cms/client/components/pages/content-list-page.internal.tsx @@ -53,10 +53,10 @@ export function ContentListPage({ typeSlug }: ContentListPageProps) { const notify = useNotify(); const overrides = usePluginOverrides(CMS_PLUGIN_ID); const { localization } = overrides; - const { router, plugins, basePath: legacyBasePath } = useStack(); + const { router, plugins, basePath: stackBasePath } = useStack(); const navigate = router?.navigate; const Link = router?.Link; - const basePath = plugins?.[CMS_PLUGIN_ID]?.site.basePath ?? legacyBasePath; + const basePath = plugins?.[CMS_PLUGIN_ID]?.site.basePath ?? stackBasePath; // Call route lifecycle hooks for telemetry and application behavior. useRouteLifecycle({ diff --git a/packages/stack/src/plugins/cms/client/components/pages/dashboard-page.internal.tsx b/packages/stack/src/plugins/cms/client/components/pages/dashboard-page.internal.tsx index 084ae4fe2..add8bd5bb 100644 --- a/packages/stack/src/plugins/cms/client/components/pages/dashboard-page.internal.tsx +++ b/packages/stack/src/plugins/cms/client/components/pages/dashboard-page.internal.tsx @@ -24,9 +24,9 @@ export function DashboardPage() { const t = useTranslate(); const overrides = usePluginOverrides(CMS_PLUGIN_ID); const { localization } = overrides; - const { router, plugins, basePath: legacyBasePath } = useStack(); + const { router, plugins, basePath: stackBasePath } = useStack(); const navigate = router?.navigate; - const basePath = plugins?.[CMS_PLUGIN_ID]?.site.basePath ?? legacyBasePath; + const basePath = plugins?.[CMS_PLUGIN_ID]?.site.basePath ?? stackBasePath; // Call route lifecycle hooks for telemetry and application behavior. useRouteLifecycle({ diff --git a/packages/stack/src/plugins/cms/client/overrides.ts b/packages/stack/src/plugins/cms/client/overrides.ts index 6814d36a3..bdd4cf048 100644 --- a/packages/stack/src/plugins/cms/client/overrides.ts +++ b/packages/stack/src/plugins/cms/client/overrides.ts @@ -168,7 +168,7 @@ export interface CMSPluginOverrides { /** * Localization object for the CMS plugin */ - localization?: CMSLocalization; + localization?: Partial; /** * Whether to show the attribution diff --git a/packages/stack/src/plugins/comments/__tests__/authorization.test.ts b/packages/stack/src/plugins/comments/__tests__/authorization.test.ts index 1d1dfa268..74af6004c 100644 --- a/packages/stack/src/plugins/comments/__tests__/authorization.test.ts +++ b/packages/stack/src/plugins/comments/__tests__/authorization.test.ts @@ -2,7 +2,7 @@ import { createMemoryAdapter } from "@btst/adapter-memory"; import { defineDb, type DatabaseDefinition } from "@btst/db"; import { describe, expect, it, vi } from "vitest"; import { z } from "zod"; -import { stack } from "../../../api"; +import { createBackendStack } from "../../../api"; import { defineAuthorization } from "../../../authorization"; import { createServerAuth, @@ -151,7 +151,7 @@ function makeBackend(options?: { plugin?: CommentsBackendOptions; auth?: ServerAuth; }) { - return stack({ + return createBackendStack({ basePath: "/api", plugins: { comments: commentsBackendPlugin(options?.plugin) }, adapter: memoryAdapter, diff --git a/packages/stack/src/plugins/comments/__tests__/client-sweep.test.tsx b/packages/stack/src/plugins/comments/__tests__/client-sweep.test.tsx index 0d7f0c99e..6537fadef 100644 --- a/packages/stack/src/plugins/comments/__tests__/client-sweep.test.tsx +++ b/packages/stack/src/plugins/comments/__tests__/client-sweep.test.tsx @@ -22,6 +22,8 @@ import { CommentCount } from "../client/components/comment-count"; import { CommentThread } from "../client/components/comment-thread"; import type { SerializedComment } from "../types"; import { commentsPermissions } from "../permissions"; +import { createTestClientStack } from "../../../__tests__/client-stack-test-utils"; +import { commentsClientPlugin } from "../client/plugin"; (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; @@ -142,6 +144,10 @@ async function render(ui: React.ReactElement) { }); } +function createCommentsStack() { + return createTestClientStack({ comments: commentsClientPlugin() }); +} + function texts(): string { return document.body.textContent ?? ""; } @@ -234,7 +240,7 @@ describe("ModerationPage row actions (PermissionAccess)", () => { ) { return render( { await expect( render( { await render( { describe("CommentCount permission descriptors", () => { it("renders an explicitly public approved count for an anonymous identity", async () => { await render( - + , ); @@ -384,8 +386,7 @@ describe("CommentCount permission descriptors", () => { it("does not fetch a moderation count when the local rule denies it", async () => { await render( { await render( @@ -453,7 +454,7 @@ describe("ModerationPage tab/page state (useListState)", () => { await render( @@ -511,7 +512,7 @@ describe("ModerationPage tab/page state (useListState)", () => { const router = createMockRouter(); const page = () => ( @@ -551,7 +552,7 @@ describe("ModerationPage tab/page state (useListState)", () => { await render( @@ -571,7 +572,7 @@ describe("ModerationPage tab/page state (useListState)", () => { await render( @@ -589,7 +590,7 @@ describe("ModerationPage tab/page state (useListState)", () => { await render( @@ -631,11 +632,10 @@ describe("UserCommentsPage (login gate + useNotify + useListState)", () => { ) { return render( @@ -732,8 +732,7 @@ describe("CommentThread provider wiring", () => { await render( @@ -772,8 +771,7 @@ describe("CommentThread provider wiring", () => { await render( @@ -797,7 +795,7 @@ describe("CommentThread provider wiring", () => { it("uses the provider identity for authenticated thread rendering", async () => { await render( @@ -821,8 +819,7 @@ describe("CommentThread provider wiring", () => { await render( identity)} > @@ -845,8 +842,7 @@ describe("CommentThread provider wiring", () => { it("uses the top-level auth login path when unauthenticated", async () => { await render( { it("prefers the per-thread login href when unauthenticated", async () => { await render( { function renderForm(onSubmit: (body: string) => Promise) { return render( @@ -939,7 +934,7 @@ describe("comments i18n precedence (useTranslate + localization prop)", () => { it("renders the English default without providers", async () => { await render( @@ -960,7 +955,7 @@ describe("comments i18n precedence (useTranslate + localization prop)", () => { await render( { await render( ; adapter?: (db: DatabaseDefinition) => DBAdapter; }) { - return stack({ + return createBackendStack({ basePath: "/api", plugins: { formBuilder: formBuilderBackendPlugin({ hooks: options?.hooks }), diff --git a/packages/stack/src/plugins/form-builder/__tests__/client-sweep.test.tsx b/packages/stack/src/plugins/form-builder/__tests__/client-sweep.test.tsx index 818ce747f..9bf9f2350 100644 --- a/packages/stack/src/plugins/form-builder/__tests__/client-sweep.test.tsx +++ b/packages/stack/src/plugins/form-builder/__tests__/client-sweep.test.tsx @@ -26,6 +26,8 @@ import type { SerializedFormSubmissionWithData, } from "../types"; import { formBuilderPermissions } from "../permissions"; +import { createTestClientStack } from "../../../__tests__/client-stack-test-utils"; +import { formBuilderClientPlugin } from "../client/plugin"; (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; @@ -174,6 +176,10 @@ async function render(ui: React.ReactElement) { }); } +function createFormBuilderStack() { + return createTestClientStack({ formBuilder: formBuilderClientPlugin() }); +} + function texts(): string { return document.body.textContent ?? ""; } @@ -247,7 +253,7 @@ describe("maintained route authorization gates", () => { vi.spyOn(console, "error").mockImplementation(() => {}); await render( { await render( { await render( { await render( { function renderListPage(auth?: StackClientAuth, router = createMockRouter()) { return render( { await render( { await render( @@ -449,7 +455,7 @@ describe("FormListPage search (useListState)", () => { await render( @@ -487,7 +493,7 @@ describe("FormListPage search (useListState)", () => { await render( @@ -532,7 +538,7 @@ describe("SubmissionsPage row actions (CanAccess + useNotify)", () => { ) { return render( { await render( { await render( { }) { return render( { await render( @@ -813,7 +819,7 @@ describe("form-builder i18n precedence (useTranslate + overrides.localization)", await render( { await act(async () => { root.render( { await act(async () => { root.render( ; /** * Whether to show the attribution diff --git a/packages/stack/src/plugins/kanban/__tests__/authorization.test.ts b/packages/stack/src/plugins/kanban/__tests__/authorization.test.ts index 99b501f77..3e965a141 100644 --- a/packages/stack/src/plugins/kanban/__tests__/authorization.test.ts +++ b/packages/stack/src/plugins/kanban/__tests__/authorization.test.ts @@ -3,7 +3,7 @@ import { type DatabaseDefinition, type DBAdapter, defineDb } from "@btst/db"; import { QueryClient } from "@tanstack/react-query"; import { describe, expect, it, vi } from "vitest"; import { z } from "zod"; -import { stack } from "../../../api"; +import { createBackendStack } from "../../../api"; import { defineAuthorization } from "../../../authorization"; import { createServerAuth, @@ -199,7 +199,7 @@ function makeBackend(options?: { auth?: ServerAuth; adapter?: (db: DatabaseDefinition) => DBAdapter; }) { - return stack({ + return createBackendStack({ basePath: "/api", plugins: { kanban: kanbanBackendPlugin( diff --git a/packages/stack/src/plugins/kanban/__tests__/client-sweep.test.tsx b/packages/stack/src/plugins/kanban/__tests__/client-sweep.test.tsx index d8c88593c..3e1b52216 100644 --- a/packages/stack/src/plugins/kanban/__tests__/client-sweep.test.tsx +++ b/packages/stack/src/plugins/kanban/__tests__/client-sweep.test.tsx @@ -27,6 +27,8 @@ import type { SerializedColumn, SerializedTask, } from "../types"; +import { createTestClientStack } from "../../../__tests__/client-stack-test-utils"; +import { kanbanClientPlugin } from "../client/plugin"; (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; @@ -165,7 +167,7 @@ async function render( await act(async () => { root.render( boolean, timeout = 3000) { const start = Date.now(); while (!check()) { diff --git a/packages/stack/src/plugins/kanban/__tests__/identity-partition.test.tsx b/packages/stack/src/plugins/kanban/__tests__/identity-partition.test.tsx index a31516fb7..32e698090 100644 --- a/packages/stack/src/plugins/kanban/__tests__/identity-partition.test.tsx +++ b/packages/stack/src/plugins/kanban/__tests__/identity-partition.test.tsx @@ -5,8 +5,23 @@ import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { StackProvider, useIdentity } from "@btst/stack/context"; import { createIdentityTestAuth } from "../../../__tests__/auth-test-utils"; +import { createTestClientStack } from "../../../__tests__/client-stack-test-utils"; import type { StackClientAuth } from "../../../shared/auth-types"; import { useBoard } from "../client/hooks/kanban-hooks"; +import { kanbanClientPlugin } from "../client/plugin"; + +const kanbanProviderOverrides = { + kanban: { + resolveUser: () => null, + searchUsers: () => [], + }, +}; + +function createKanbanStack(queryClient: QueryClient) { + return createTestClientStack({ kanban: kanbanClientPlugin() }, queryClient, { + api: { baseURL: "http://test.local", basePath: "/api" }, + }); +} ( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } @@ -77,8 +92,8 @@ describe("Kanban protected query identity partition", () => { await act(async () => { root.render( @@ -127,8 +142,8 @@ describe("Kanban protected query identity partition", () => { await act(async () => { root.render( @@ -187,8 +202,8 @@ describe("Kanban protected query identity partition", () => { await act(async () => { root.render( @@ -261,8 +276,8 @@ describe("Kanban protected query identity partition", () => { await act(async () => { root.render( diff --git a/packages/stack/src/plugins/media/__tests__/authorization.test.ts b/packages/stack/src/plugins/media/__tests__/authorization.test.ts index d39a6ab1c..0bda84eba 100644 --- a/packages/stack/src/plugins/media/__tests__/authorization.test.ts +++ b/packages/stack/src/plugins/media/__tests__/authorization.test.ts @@ -4,7 +4,7 @@ import { QueryClient } from "@tanstack/react-query"; import { createHmac } from "node:crypto"; import { describe, expect, it, vi } from "vitest"; import { z } from "zod"; -import { stack } from "../../../api"; +import { createBackendStack } from "../../../api"; import { defineAuthorization } from "../../../authorization"; import { createServerAuth, @@ -234,7 +234,7 @@ function makeBackend( tenantId?: string; } = {}, ) { - return stack({ + return createBackendStack({ basePath: "/api", plugins: { media: mediaBackendPlugin({ diff --git a/packages/stack/src/plugins/media/__tests__/client-sweep.test.tsx b/packages/stack/src/plugins/media/__tests__/client-sweep.test.tsx index 80975b81a..3518b2aa5 100644 --- a/packages/stack/src/plugins/media/__tests__/client-sweep.test.tsx +++ b/packages/stack/src/plugins/media/__tests__/client-sweep.test.tsx @@ -17,6 +17,8 @@ import { LibraryPageComponent } from "../client/components/pages/library-page"; import { MediaPicker } from "../client/components/media-picker"; import { UrlTab } from "../client/components/media-picker/url-tab"; import type { SerializedAsset, SerializedFolder } from "../types"; +import { createTestClientStack } from "../../../__tests__/client-stack-test-utils"; +import { mediaClientPlugin } from "../client/plugin"; (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; (globalThis as any).ResizeObserver ??= class { @@ -128,9 +130,11 @@ function createMockRouter(initial = "") { }; } -const mediaOverrides = () => ({ - queryClient, -}); +const mediaOverrides = () => ({}); + +function createMediaStack(queryClient: QueryClient) { + return createTestClientStack({ media: mediaClientPlugin() }, queryClient); +} async function renderLibrary( options: { @@ -144,8 +148,7 @@ async function renderLibrary( await act(async () => { root.render( { await act(async () => { root.render( { await act(async () => { root.render( { await act(async () => { root.render( { await act(async () => { root.render( diff --git a/packages/stack/src/plugins/media/__tests__/identity-partition.test.tsx b/packages/stack/src/plugins/media/__tests__/identity-partition.test.tsx index c0f0ec1b3..cd0a6b137 100644 --- a/packages/stack/src/plugins/media/__tests__/identity-partition.test.tsx +++ b/packages/stack/src/plugins/media/__tests__/identity-partition.test.tsx @@ -6,6 +6,7 @@ import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { StackProvider, useIdentity } from "@btst/stack/context"; import { createIdentityTestAuth } from "../../../__tests__/auth-test-utils"; +import { createTestClientStack } from "../../../__tests__/client-stack-test-utils"; import { MEDIA_QUERY_KEYS } from "../api/query-key-defs"; import { useAssets, @@ -13,12 +14,22 @@ import { useUploadAsset, } from "../client/hooks/use-media"; import { mediaResources } from "../query-keys"; +import { mediaClientPlugin } from "../client/plugin"; const MEDIA_ENDPOINT = { baseURL: "http://test.local", basePath: "/api", }; +function createMediaStack( + queryClient: QueryClient, + api: typeof MEDIA_ENDPOINT = MEDIA_ENDPOINT, +) { + return createTestClientStack({ media: mediaClientPlugin() }, queryClient, { + api, + }); +} + ( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; @@ -92,11 +103,10 @@ describe("Media protected query identity partition", () => { await act(async () => { root.render( @@ -154,11 +164,10 @@ describe("Media protected query identity partition", () => { await act(async () => { root.render( @@ -214,11 +223,10 @@ describe("Media protected query identity partition", () => { await act(async () => { root.render( @@ -283,11 +291,10 @@ describe("Media protected query identity partition", () => { await act(async () => { root.render( @@ -354,11 +361,10 @@ describe("Media protected query identity partition", () => { await act(async () => { root.render( @@ -442,11 +448,10 @@ describe("Media protected query identity partition", () => { await act(async () => { root.render( @@ -513,11 +518,10 @@ describe("Media protected query identity partition", () => { const tree = (initialIdentity: { id: string }) => ( @@ -590,11 +594,10 @@ describe("Media protected query identity partition", () => { const tree = (identity: { id: string }, completeDeletion: boolean) => ( @@ -670,11 +673,10 @@ describe("Media protected query identity partition", () => { await act(async () => { root.render( diff --git a/packages/stack/src/plugins/media/__tests__/plugin.test.ts b/packages/stack/src/plugins/media/__tests__/plugin.test.ts index 6fc63728a..de5f56e0c 100644 --- a/packages/stack/src/plugins/media/__tests__/plugin.test.ts +++ b/packages/stack/src/plugins/media/__tests__/plugin.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, it, expect, vi } from "vitest"; import { createMemoryAdapter } from "@btst/adapter-memory"; import type { DBAdapter as Adapter, DatabaseDefinition } from "@btst/db"; -import { stack } from "../../../api"; +import { createBackendStack } from "../../../api"; import { mediaBackendPlugin, type MediaBackendConfig } from "../api/plugin"; import { localAdapter } from "../api/adapters/local"; import type { @@ -27,7 +27,7 @@ function createBackend( ) { const { adapterFactory = testAdapter, storageAdapter, ...overrides } = config; - return stack({ + return createBackendStack({ basePath: "/api", plugins: { media: mediaBackendPlugin({ diff --git a/packages/stack/src/plugins/open-api/__tests__/route-introspection.test.ts b/packages/stack/src/plugins/open-api/__tests__/route-introspection.test.ts index 399356c73..98fd670e7 100644 --- a/packages/stack/src/plugins/open-api/__tests__/route-introspection.test.ts +++ b/packages/stack/src/plugins/open-api/__tests__/route-introspection.test.ts @@ -2,7 +2,7 @@ import { createMemoryAdapter } from "@btst/adapter-memory"; import type { DatabaseDefinition } from "@btst/db"; import { describe, expect, it, vi } from "vitest"; import { z } from "zod"; -import { stack } from "../../../api"; +import { createBackendStack } from "../../../api"; import { defineAuthorization, definePermissions, @@ -57,12 +57,12 @@ describe("OpenAPI route introspection", () => { }; }); const feature = defineBackendPlugin({ - name: "introspection", + id: "feature", dbPlugin: createDbPlugin("introspection", {}), operations: () => ({ read }), routes, }); - const backend = stack({ + const backend = createBackendStack({ basePath: "/api", plugins: { openApi: openApiBackendPlugin(), @@ -102,7 +102,7 @@ describe("OpenAPI route introspection", () => { execute: () => ({ ok: true as const }), }); const feature = defineBackendPlugin({ - name: "documented", + id: "documented", dbPlugin: createDbPlugin("documented", {}), operations: () => ({ read }), routes: (_adapter, _context, operations) => ({ @@ -121,9 +121,9 @@ describe("OpenAPI route introspection", () => { const getIdentity = vi.fn(() => { throw new Error("public infrastructure must not resolve identity"); }); - const backend = stack({ + const backend = createBackendStack({ basePath: "/api", - plugins: { openApi: openApiBackendPlugin(), feature }, + plugins: { openApi: openApiBackendPlugin(), documented: feature }, adapter: (db: DatabaseDefinition) => createMemoryAdapter(db)({}), auth: createServerAuth({ authorization, getIdentity }), }); @@ -139,11 +139,11 @@ describe("OpenAPI route introspection", () => { expect(reference.headers.get("content-type")).toContain("text/html"); expect(getIdentity).not.toHaveBeenCalled(); - const disabled = stack({ + const disabled = createBackendStack({ basePath: "/api", plugins: { openApi: openApiBackendPlugin({ disableDefaultReference: true }), - feature, + documented: feature, }, adapter: (db: DatabaseDefinition) => createMemoryAdapter(db)({}), }); @@ -179,7 +179,7 @@ describe("OpenAPI route introspection", () => { execute: () => "internal", }); const feature = defineBackendPlugin({ - name: "metadata", + id: "feature", dbPlugin: createDbPlugin("metadata", {}), operations: () => ({ protectedRead, publicRead, internalOnly }), routes: (_adapter, _context, operations) => ({ @@ -196,13 +196,16 @@ describe("OpenAPI route introspection", () => { }), }); const build = (plugins: Record) => - stack({ + createBackendStack({ basePath: "/api", plugins, adapter: (db: DatabaseDefinition) => createMemoryAdapter(db)({}), }); const first = build({ openApi: openApiBackendPlugin(), feature }); - const second = build({ feature, openApi: openApiBackendPlugin() }); + const second = build({ + feature, + openApi: openApiBackendPlugin(), + }); const readSchema = async (backend: typeof first) => { const response = await backend.handler( new Request("http://localhost/api/open-api/schema"), diff --git a/packages/stack/src/plugins/open-api/api/generator.ts b/packages/stack/src/plugins/open-api/api/generator.ts index 8aa2c4995..58d785cb1 100644 --- a/packages/stack/src/plugins/open-api/api/generator.ts +++ b/packages/stack/src/plugins/open-api/api/generator.ts @@ -1,6 +1,5 @@ import type { Endpoint } from "better-call"; import type { StackContext } from "../../../types"; -import { resolvePluginProgrammaticId } from "../../../plugin-registration"; import * as z from "zod"; /** @@ -352,9 +351,9 @@ export function generateOpenAPISchema( for (const [pluginKey, plugin] of Object.entries(context.plugins).sort( ([left], [right]) => compareStable(left, right), )) { - const pluginId = resolvePluginProgrammaticId(plugin, pluginKey); - // Skip the open-api plugin itself - if (pluginId === "openApi" || pluginId === "open-api") { + const pluginId = plugin.id; + // Skip the OpenAPI plugin itself. + if (pluginId === "openApi") { continue; } diff --git a/packages/stack/src/plugins/open-api/api/plugin.ts b/packages/stack/src/plugins/open-api/api/plugin.ts index 2d7ab70d1..cdde3c564 100644 --- a/packages/stack/src/plugins/open-api/api/plugin.ts +++ b/packages/stack/src/plugins/open-api/api/plugin.ts @@ -165,7 +165,7 @@ function getScalarHTML( * * @example * ```ts - * const { handler } = stack({ + * const { handler } = createBackendStack({ * basePath: "/api/data", * plugins: { * blog: blogBackendPlugin(), diff --git a/packages/stack/src/plugins/route-docs/client/components/pages/docs-page.tsx b/packages/stack/src/plugins/route-docs/client/components/pages/docs-page.tsx index 64c4a4c9d..68db9f68c 100644 --- a/packages/stack/src/plugins/route-docs/client/components/pages/docs-page.tsx +++ b/packages/stack/src/plugins/route-docs/client/components/pages/docs-page.tsx @@ -771,7 +771,7 @@ function SitemapSection({ }) { const [isExpanded, setIsExpanded] = useState(false); - // Get plugin name from schema + // Get the canonical plugin ID from the schema. const getPluginName = (pluginKey: string): string => { const plugin = schema.plugins.find((p) => p.key === pluginKey); return plugin?.name || pluginKey; diff --git a/packages/stack/src/plugins/route-docs/client/plugin.tsx b/packages/stack/src/plugins/route-docs/client/plugin.tsx index 2e4544ca4..a37a022ba 100644 --- a/packages/stack/src/plugins/route-docs/client/plugin.tsx +++ b/packages/stack/src/plugins/route-docs/client/plugin.tsx @@ -7,7 +7,6 @@ import { normalizePath } from "@btst/stack/client"; import { defineRoute } from "@btst/yar"; import { hashKey, type QueryClient } from "@tanstack/react-query"; import type { ClientStackContext } from "../../../types"; -import { resolvePluginProgrammaticId } from "../../../plugin-registration"; import { generateRouteDocsSchema, fetchAllSitemapEntries, @@ -51,7 +50,7 @@ export function getRegisteredRoutes( if (!context) return []; const result: RegisteredRoute[] = []; for (const [pluginKey, plugin] of Object.entries(context.plugins)) { - const pluginId = resolvePluginProgrammaticId(plugin, pluginKey); + const pluginId = plugin.id; if (pluginId === ROUTE_DOCS_PLUGIN_ID) continue; try { const routes = plugin.routes(context); @@ -111,7 +110,7 @@ function createRouteDocsBaseFingerprint( ? Object.entries(context.plugins) .map(([key, plugin]) => ({ key, - id: resolvePluginProgrammaticId(plugin, key), + id: plugin.id, })) .sort((left, right) => left.key.localeCompare(right.key)) : []; diff --git a/packages/stack/src/plugins/route-docs/generator.ts b/packages/stack/src/plugins/route-docs/generator.ts index e1e31c176..00addd185 100644 --- a/packages/stack/src/plugins/route-docs/generator.ts +++ b/packages/stack/src/plugins/route-docs/generator.ts @@ -1,5 +1,4 @@ import type { ClientStackContext, SitemapEntry } from "../../types"; -import { resolvePluginProgrammaticId } from "../../plugin-registration"; import { ROUTE_DOCS_PLUGIN_ID } from "./client/constants"; import type { Route } from "@btst/yar"; import * as z from "zod"; @@ -49,7 +48,7 @@ export interface DocumentedRoute { export interface DocumentedPlugin { /** Plugin key */ key: string; - /** Plugin name */ + /** Canonical programmatic plugin ID */ name: string; /** Routes from this plugin */ routes: DocumentedRoute[]; @@ -288,7 +287,7 @@ export async function fetchAllSitemapEntries( const allEntries: PluginSitemapEntry[] = []; for (const [pluginKey, plugin] of Object.entries(context.plugins)) { - const pluginId = resolvePluginProgrammaticId(plugin, pluginKey); + const pluginId = plugin.id; // Skip route-docs plugin if (pluginId === ROUTE_DOCS_PLUGIN_ID) { continue; @@ -333,7 +332,7 @@ export function generateRouteDocsSchema( // Iterate over all plugins for (const [pluginKey, plugin] of Object.entries(context.plugins)) { - const pluginId = resolvePluginProgrammaticId(plugin, pluginKey); + const pluginId = plugin.id; // Skip the route-docs plugin itself if (pluginId === ROUTE_DOCS_PLUGIN_ID) { continue; diff --git a/packages/stack/src/plugins/ui-builder/__tests__/client-sweep.test.tsx b/packages/stack/src/plugins/ui-builder/__tests__/client-sweep.test.tsx index 6a94bf0d0..40ebf78a3 100644 --- a/packages/stack/src/plugins/ui-builder/__tests__/client-sweep.test.tsx +++ b/packages/stack/src/plugins/ui-builder/__tests__/client-sweep.test.tsx @@ -16,7 +16,7 @@ import { type StackI18nProvider, } from "@btst/stack/context"; import { createApiClient } from "@btst/stack/plugins/client"; -import { stack } from "../../../api"; +import { createBackendStack } from "../../../api"; import { cmsBackendPlugin, type CMSApiRouter } from "../../cms/api"; import { cmsPermissions } from "../../cms/permissions"; import { createCMSQueryKeys } from "../../cms/query-keys"; @@ -31,6 +31,11 @@ import { PageListPage as PageListRoutePage } from "../client/components/pages/pa import { createUIBuilderQueryKeys } from "../query-keys"; import { UI_BUILDER_CONTENT_TYPE, UI_BUILDER_TYPE_SLUG } from "../schemas"; import type { SerializedUIBuilderPage } from "../types"; +import { createTestClientStack } from "../../../__tests__/client-stack-test-utils"; + +function createUIBuilderStack() { + return createTestClientStack({ uiBuilder: uiBuilderClientPlugin() }); +} (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; @@ -218,8 +223,7 @@ async function renderPage( await act(async () => { root.render( { describe("UI Builder public page authorization", () => { it("serves a real anonymous CMS page with the shared public rules", async () => { - const backend = stack({ + const backend = createBackendStack({ basePath: "/api", plugins: { cms: cmsBackendPlugin({ contentTypes: [UI_BUILDER_CONTENT_TYPE] }), @@ -546,7 +550,7 @@ describe("UI Builder public page authorization", () => { it("renders anonymous SSR and SSG output from an explicitly public CMS read", () => { const ssr = renderToString( { ); const ssg = renderToStaticMarkup( (UI_BUILDER_PLUGIN_ID); - const { router, plugins, basePath: legacyBasePath } = useStack(); + const { router, plugins, basePath: stackBasePath } = useStack(); const basePath = - plugins?.[UI_BUILDER_PLUGIN_ID]?.site.basePath ?? legacyBasePath; + plugins?.[UI_BUILDER_PLUGIN_ID]?.site.basePath ?? stackBasePath; const LinkComponent = router?.Link ?? "a"; const componentRegistry = resolveUIBuilderComponents(plugins?.[UI_BUILDER_PLUGIN_ID]?.config) ?? diff --git a/packages/stack/src/plugins/ui-builder/client/components/pages/page-list-page.internal.tsx b/packages/stack/src/plugins/ui-builder/client/components/pages/page-list-page.internal.tsx index 99944023b..1e78637c0 100644 --- a/packages/stack/src/plugins/ui-builder/client/components/pages/page-list-page.internal.tsx +++ b/packages/stack/src/plugins/ui-builder/client/components/pages/page-list-page.internal.tsx @@ -54,10 +54,10 @@ export function PageListPage() { const notify = useNotify(); const { localization } = usePluginOverrides(UI_BUILDER_PLUGIN_ID); - const { router, plugins, basePath: legacyBasePath } = useStack(); + const { router, plugins, basePath: stackBasePath } = useStack(); const navigate = router?.navigate; const basePath = - plugins?.[UI_BUILDER_PLUGIN_ID]?.site.basePath ?? legacyBasePath; + plugins?.[UI_BUILDER_PLUGIN_ID]?.site.basePath ?? stackBasePath; const { pages, total, hasMore, isLoadingMore, loadMore } = useSuspenseUIBuilderPages(); const deleteMutation = useDeleteUIBuilderPage(); diff --git a/packages/stack/src/react-router/router.tsx b/packages/stack/src/react-router/router.tsx index 27b0b40f1..495eec1b4 100644 --- a/packages/stack/src/react-router/router.tsx +++ b/packages/stack/src/react-router/router.tsx @@ -52,7 +52,7 @@ function useReactRouterStackRouter(): StackRouter { * ```tsx * import { reactRouter } from "@btst/stack/react-router"; * - * + * * ``` */ export function reactRouter(): StackRouterConfig { diff --git a/packages/stack/src/shared/entry-factories.ts b/packages/stack/src/shared/entry-factories.ts index 04a5a2cb8..5cfb9b557 100644 --- a/packages/stack/src/shared/entry-factories.ts +++ b/packages/stack/src/shared/entry-factories.ts @@ -7,7 +7,7 @@ import type { ComponentType } from "react"; /** * Minimal structural view of a route returned by - * `createStackClient(...).router.getRoute(path)`. The framework entry + * `createClientStack(...).router.getRoute(path)`. The framework entry * factories only need these three fields. */ export interface StackRouteLike { @@ -17,8 +17,8 @@ export interface StackRouteLike { } /** - * Minimal structural view of the object returned by `createStackClient`. - * Any concrete `ClientLib` is assignable to this shape. + * Minimal structural view of the object returned by `createClientStack`. + * Any concrete `ClientStack` is assignable to this shape. */ export interface StackClientLike { router: { diff --git a/packages/stack/src/tanstack/router.tsx b/packages/stack/src/tanstack/router.tsx index 9451bf423..f53aa2cf7 100644 --- a/packages/stack/src/tanstack/router.tsx +++ b/packages/stack/src/tanstack/router.tsx @@ -50,7 +50,7 @@ function useTanStackStackRouter(): StackRouter { * ```tsx * import { tanstackRouter } from "@btst/stack/tanstack"; * - * + * * ``` */ export function tanstackRouter(): StackRouterConfig { diff --git a/packages/stack/src/types.ts b/packages/stack/src/types.ts index ecc55a5d2..54649d351 100644 --- a/packages/stack/src/types.ts +++ b/packages/stack/src/types.ts @@ -186,9 +186,7 @@ export interface BackendPlugin< TId extends string = string, > { /** Canonical stable programmatic identifier. */ - readonly id?: TId; - /** @deprecated Use `id`. Retained only while first-party plugins migrate. */ - name?: string; + readonly id: TId; /** * Create API endpoints for this plugin @@ -250,9 +248,7 @@ export interface ClientPlugin< TId extends string = string, > { /** Canonical stable programmatic identifier. */ - readonly id?: TId; - /** @deprecated Use `id`. Retained only while first-party plugins migrate. */ - name?: string; + readonly id: TId; /** * Define routes (pages) for this plugin @@ -283,9 +279,7 @@ export interface ClientPluginDefinition< TApiRuntimeFrom extends string = never, > { /** Canonical stable programmatic identifier. */ - readonly id?: TId; - /** @deprecated Use `id`. Retained only while first-party plugins migrate. */ - name?: string; + readonly id: TId; /** * Optional programmatic ID whose effective API transport this definition * consumes. The dependent plugin may still resolve its own site endpoint. @@ -300,10 +294,10 @@ export interface ClientPluginDefinition< /** Expand plugin-specific options against one resolved stack runtime. */ resolve: ( runtime: ResolvedClientPluginRuntime, - ) => Omit, "id" | "name">; + ) => Omit, "id">; } -/** Canonical definitions plus the temporary already-resolved plugin seam. */ +/** Runtime-independent client plugin registration resolved by the client stack. */ export type ClientPluginRegistration< TOverrides = Record, TRoutes extends Record = Record, @@ -312,15 +306,13 @@ export type ClientPluginRegistration< Record >, TApiRuntimeFrom extends string = never, -> = - | ClientPlugin - | ClientPluginDefinition< - TOverrides, - TRoutes, - TId, - TProviderConfig, - TApiRuntimeFrom - >; +> = ClientPluginDefinition< + TOverrides, + TRoutes, + TId, + TProviderConfig, + TApiRuntimeFrom +>; /** * Utility type that maps each plugin key to the return type of its `raw` factory. @@ -353,7 +345,7 @@ type _OperationsOf = T extends BackendPlugin< ? TOperations : never; -/** Bound operation APIs, keyed by their registered backend plugin names. */ +/** Bound operation APIs, keyed by their registered backend plugin IDs. */ export type PluginOperations< TPlugins extends Record>, > = { @@ -441,19 +433,6 @@ export interface BackendStackConfig< auth?: TAuth; } -/** - * @deprecated Use `BackendStackConfig`. This alias is removed by #225. - */ -export type BackendLibConfig< - TPlugins extends Record> = Record< - string, - BackendPlugin - >, - TAuth extends ServerAuth | undefined = - | ServerAuth - | undefined, -> = BackendStackConfig; - type AnyClientPluginRegistration = ClientPluginRegistration< any, any, @@ -461,9 +440,8 @@ type AnyClientPluginRegistration = ClientPluginRegistration< any, any >; -type LegacyClientPluginMap = Record>; -/** @internal Extract a required canonical plugin ID, excluding legacy optional IDs. */ +/** @internal Extract a plugin's required canonical ID. */ type _DeclaredPluginId = TPlugin extends { readonly id: infer TId extends string; } @@ -492,7 +470,7 @@ type _ClientApiRuntimeSource = TPlugin extends { export type MatchingPluginRegistrations< TPlugins extends Record, > = { - [K in keyof TPlugins]: [_DeclaredPluginId] extends [never] + [K in keyof TPlugins]: string extends _DeclaredPluginId ? TPlugins[K] : K extends _DeclaredPluginId ? _DeclaredPluginId extends K @@ -528,64 +506,23 @@ export interface ResolvedClientStackConfig< site: ClientLocation; /** The one React Query client used by loaders, hydration, and browser hooks. */ queryClient: QueryClient; - /** Runtime-independent and temporary already-resolved plugin registrations. */ + /** Runtime-independent plugin registrations. */ plugins: TPlugins & MatchingPluginRegistrations; - /** Explicit endpoint replacements keyed by a registered plugin name. */ + /** Explicit endpoint replacements keyed by a registered plugin ID. */ endpoints?: ClientPluginEndpointOverrides; - /** Shared origins live under `api` or `site`, never an ambiguous top level. */ - baseURL?: never; - /** Shared paths live under `api` or `site`, never an ambiguous top level. */ - basePath?: never; -} - -/** - * Temporary compatibility shape for already-resolved first-party plugins. - * Canonical consumers configure `api`, `site`, and `queryClient` instead. - */ -export interface LegacyClientStackConfig< - TPlugins extends LegacyClientPluginMap = LegacyClientPluginMap, -> { - /** Temporary already-resolved plugin registrations. */ - plugins: TPlugins; - /** Temporary route-introspection base path retained during plugin migration. */ - basePath?: string; - /** Removed ambiguous top-level origin. */ - baseURL?: never; - /** Canonical runtime configuration is unavailable on the legacy branch. */ - api?: never; - /** Canonical runtime configuration is unavailable on the legacy branch. */ - site?: never; - /** Canonical runtime configuration is unavailable on the legacy branch. */ - queryClient?: never; - /** Endpoint replacements require the canonical resolved runtime. */ - endpoints?: never; } -/** Configuration for creating either a canonical or compatibility client stack. */ +/** Canonical configuration for a resolved client stack. */ export type ClientStackConfig< - TPlugins extends Record< - string, - AnyClientPluginRegistration - > = LegacyClientPluginMap, -> = - | ResolvedClientStackConfig - | (TPlugins extends LegacyClientPluginMap - ? LegacyClientStackConfig - : never); - -/** - * @deprecated Use `ClientStackConfig`. This alias is removed by #225. - */ -export type ClientLibConfig< - TPlugins extends Record< + TPlugins extends Record = Record< string, AnyClientPluginRegistration - > = LegacyClientPluginMap, -> = ClientStackConfig; + >, +> = ResolvedClientStackConfig; /** * Utility type to extract override types from plugins - * Maps plugin names to their override types + * Maps plugin IDs to their override types */ declare const requiredClientPluginOverrides: unique symbol; @@ -691,7 +628,7 @@ export type PluginRoutes< > = MergeAllPluginRoutes; /** - * Prefix all backend plugin route keys with the plugin name + * Prefix all backend plugin route keys with the canonical plugin ID * Example: { messages: { list: Endpoint } } => { messages_list: Endpoint } */ export type PrefixedPluginRoutes< @@ -741,33 +678,18 @@ export interface BackendStack< trusted: TOperations; } -/** - * @deprecated Use `BackendStack`. This alias is removed by #225. - */ -export type BackendLib< - TRoutes extends Record = Record, - TRaw extends Record any>> = Record< - string, - Record any> - >, - TOperations extends Record< - string, - Record any> - > = Record any>>, -> = BackendStack; - /** * Helper type to extract routes from a client plugin */ -export type ExtractPluginRoutes = T extends ClientPlugin< +export type ExtractPluginRoutes = T extends ClientPluginDefinition< any, infer TRoutes, + any, + any, any > ? TRoutes - : T extends ClientPluginDefinition - ? TRoutes - : never; + : never; /** * Helper type to merge all routes from all plugins into a single record @@ -851,13 +773,6 @@ export interface ResolvedClientStack< export type RegisteredClientPlugins = TStack extends ResolvedClientStack ? TPlugins : never; -/** - * @deprecated Use `ClientStack`. This alias is removed by #225. - */ -export type ClientLib< - TRoutes extends Record = Record, -> = ClientStack; - /** * Minimal sitemap entry shape aligned with Next.js MetadataRoute.Sitemap */ diff --git a/scripts/check-canonical-dx.mjs b/scripts/check-canonical-dx.mjs index d03852c63..19323cf00 100644 --- a/scripts/check-canonical-dx.mjs +++ b/scripts/check-canonical-dx.mjs @@ -30,6 +30,15 @@ const generatedTargets = [ "packages/stack/registry", "playground/src", ]; +const publicContractTargets = [ + "packages/stack/src/api/index.ts", + "packages/stack/src/client/index.ts", + "packages/stack/src/context/provider.tsx", + "packages/stack/src/plugin-registration.ts", + "packages/stack/src/plugins/api/index.ts", + "packages/stack/src/plugins/client/index.ts", + "packages/stack/src/types.ts", +]; // Exact historical inputs are immutable negative fixtures, not maintained // examples. Their README and hash allowlist fail closed if their bytes change. @@ -634,6 +643,25 @@ for (const absolute of allFiles) { } } +for (const target of publicContractTargets) { + const absolute = join(root, target); + const source = readFileSync(absolute, "utf8"); + recordMatches( + failures, + target, + source, + "removed public contract", + /\b(?:createStackClient|BackendLibConfig|ClientLibConfig|BackendLib|ClientLib|StackProviderOverrides|resolvePluginProgrammaticId)\b|\bexport\s+(?:const|function)\s+stack\b/g, + ); + recordMatches( + failures, + target, + source, + "legacy plugin name fallback", + /^\s*(?:readonly\s+)?name\??\s*:/gm, + ); +} + if (failures.length > 0) { console.error( "Canonical DX guard found legacy maintained guidance or sources:", From d77acdf50cbfd026ed815dd71c146fb49718556f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 29 Aug 2026 17:27:18 +0000 Subject: [PATCH 2/2] chore: update shadcn registry [skip ci] --- packages/stack/registry/btst-route-docs.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/stack/registry/btst-route-docs.json b/packages/stack/registry/btst-route-docs.json index 9af1cf80e..a559e1007 100644 --- a/packages/stack/registry/btst-route-docs.json +++ b/packages/stack/registry/btst-route-docs.json @@ -22,7 +22,7 @@ { "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", + "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 the canonical plugin ID from the 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" } ],