@vexcms/next 0.1.0-alpha.11 → 0.1.0-alpha.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +46 -48
  2. package/dist/NextAdminLayout.js +3 -20
  3. package/dist/NextAdminLayout.js.map +1 -1
  4. package/dist/NextAdminPage.js +2 -137
  5. package/dist/NextAdminPage.js.map +1 -1
  6. package/dist/cache/createVexRevalidateRoute.d.ts +88 -0
  7. package/dist/cache/createVexRevalidateRoute.d.ts.map +1 -0
  8. package/dist/cache/createVexServerClient.d.ts +31 -0
  9. package/dist/cache/createVexServerClient.d.ts.map +1 -0
  10. package/dist/cache/index.d.ts +5 -0
  11. package/dist/cache/index.d.ts.map +1 -0
  12. package/dist/cache/index.js +9 -0
  13. package/dist/cache/index.js.map +1 -0
  14. package/dist/cache/types.d.ts +36 -0
  15. package/dist/cache/types.d.ts.map +1 -0
  16. package/dist/chunk-5AGM3PEX.js +121 -0
  17. package/dist/chunk-5AGM3PEX.js.map +1 -0
  18. package/dist/chunk-KIRSFMPI.js +78 -0
  19. package/dist/chunk-KIRSFMPI.js.map +1 -0
  20. package/dist/chunk-TR3Z3P6Q.js +143 -0
  21. package/dist/chunk-TR3Z3P6Q.js.map +1 -0
  22. package/dist/chunk-YCJNIS6O.js +25 -0
  23. package/dist/chunk-YCJNIS6O.js.map +1 -0
  24. package/dist/index.d.ts +9 -0
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +48 -0
  27. package/dist/index.js.map +1 -0
  28. package/dist/seo/createVexRobots.d.ts +22 -0
  29. package/dist/seo/createVexRobots.d.ts.map +1 -0
  30. package/dist/seo/createVexSitemap.d.ts +41 -0
  31. package/dist/seo/createVexSitemap.d.ts.map +1 -0
  32. package/dist/seo/index.d.ts +5 -0
  33. package/dist/seo/index.d.ts.map +1 -0
  34. package/dist/seo/index.js +13 -0
  35. package/dist/seo/index.js.map +1 -0
  36. package/dist/seo/vexMetadata.d.ts +30 -0
  37. package/dist/seo/vexMetadata.d.ts.map +1 -0
  38. package/dist/seo/vexStaticParams.d.ts +25 -0
  39. package/dist/seo/vexStaticParams.d.ts.map +1 -0
  40. package/package.json +19 -4
package/README.md CHANGED
@@ -1,87 +1,85 @@
1
- # @vexcms/admin-next
1
+ # @vexcms/next
2
2
 
3
- The admin panel for [VEX CMS](https://github.com/ianyimi/vex), built for Next.js. Provides a complete content management interface with collection CRUD, media management, versioning, live preview, access control, and user impersonation.
3
+ The Next.js adapter for [VEX CMS](https://github.com/ianyimi/vex). Provides the server and client components that render the admin panel — collection CRUD, media management, and role-based access control — plus config functions re-exported from `@vexcms/react`.
4
4
 
5
5
  ## Installation
6
6
 
7
7
  ```bash
8
- pnpm add @vexcms/admin-next
8
+ pnpm add @vexcms/next@alpha
9
9
  ```
10
10
 
11
11
  ## Quick Setup
12
12
 
13
13
  ```tsx
14
- // app/admin/[[...path]]/page.tsx
15
- import { AdminLayout, AdminPage } from "@vexcms/admin-next"
14
+ // app/admin/layout.tsx
15
+ import { NextAdminLayout } from "@vexcms/next/client"
16
16
  import config from "@/vex.config"
17
+ import { getCurrentUser } from "@/auth/serverUtils"
17
18
 
18
- export default function Admin({ params }) {
19
+ export default async function AdminRootLayout({ children }: { children: React.ReactNode }) {
20
+ const user = await getCurrentUser()
19
21
  return (
20
- <AdminLayout config={config} user={user}>
21
- <AdminPage config={config} path={params.path} />
22
- </AdminLayout>
22
+ <NextAdminLayout config={config} user={user ?? undefined}>
23
+ {children}
24
+ </NextAdminLayout>
23
25
  )
24
26
  }
25
27
  ```
26
28
 
29
+ ```tsx
30
+ // app/admin/[[...path]]/page.tsx
31
+ import { NextAdminPage } from "@vexcms/next/server"
32
+ import config from "@/vex.config"
33
+
34
+ export default function AdminPage({
35
+ params,
36
+ }: {
37
+ params: Promise<{ path?: string[] }>
38
+ }) {
39
+ return <NextAdminPage config={config} params={params} />
40
+ }
41
+ ```
42
+
43
+ `NextAdminPage` and `NextAdminLayout` are exported from separate subpaths (`/server` and `/client`) rather than the package root, because an async server component and a `"use client"` component cannot share one bundle.
44
+
27
45
  ## Features
28
46
 
29
47
  ### Collection Management
30
48
 
31
- - **List view** — Paginated data table with search, sorting, bulk delete, and configurable columns
32
- - **Edit view** — Auto-generated forms from collection schema with field validation via TanStack React Form
33
- - **Create/delete** — Dialogs for document creation and deletion with permission checks
34
- - **Bidirectional pagination** — Instant access to first and last pages
49
+ - **List view** — Data table with Load More pagination, row selection, and bulk delete
50
+ - **Edit view** — Auto-generated forms from the collection schema, submitted via TanStack React Form
51
+ - **Create dialog** — Modal for document creation, gated by a `create` permission check
35
52
 
36
53
  ### Media Management
37
54
 
38
55
  - **Grid/list views** — Browse media files with thumbnails
39
- - **Upload** — Drag-and-drop upload with file type detection
40
- - **Media picker** — Inline picker for upload and relationship fields with search and pagination
41
- - **Image dimensions** — Auto-detected on upload
42
-
43
- ### Versioning & Drafts
44
-
45
- - **Draft/publish workflow** — Status badges, publish/unpublish actions
46
- - **Version history** — Dropdown to view, restore, or delete previous versions
47
- - **Autosave** — Configurable auto-save interval for draft documents
56
+ - **Upload** — Drag-and-drop or click-to-upload through the collection's configured storage adapter
57
+ - **Media picker** — Inline picker for upload fields, with a client-side search filter and Load More pagination
48
58
 
49
- ### Live Preview
59
+ ### Roadmap note
50
60
 
51
- - **Side-by-side panel** — Preview content changes in an iframe alongside the editor
52
- - **Responsive breakpoints** — Test at different screen sizes
53
- - **Snapshot system** — Transient preview data without saving to database
61
+ Versioning, drafts, and live preview are not implemented yet — both are in progress on the [roadmap](https://docs.vexcms.dev). A global's `versions.drafts` option parses and is stored on the resolved config, but it is not enforced: every read still returns the live document. See [docs.vexcms.dev](https://docs.vexcms.dev) for current status.
54
62
 
55
63
  ### Access Control
56
64
 
57
- - **Permission provider** — React context for role-based access control
58
- - **Field-level permissions** — Read-only fields based on user roles
59
- - **Collection-level permissions** — Control create, read, update, delete per collection
60
- - **UI enforcement** — Buttons and actions hidden/disabled based on permissions
61
-
62
- ### User Impersonation
63
-
64
- - **Impersonation banner** — Shows when an admin is viewing as another user
65
- - **User switching** — Select from impersonatable users list
65
+ - **Access provider** — `VexAccessProvider` supplies the RBAC access matrix to the admin panel via React context
66
+ - **Collection-level permissions** — `usePermission` gates create, read, update, and delete per collection and per global
67
+ - **UI enforcement** — Buttons and actions are disabled or hidden when the current user lacks permission
66
68
 
67
69
  ### Admin Layout
68
70
 
69
- - **Sidebar navigation** — Collections grouped by `admin.group`, with auth and media sections
70
- - **Theme support** — Light/dark mode
71
- - **User menu** — Profile, sign out, impersonation controls
71
+ - **Sidebar navigation** — Links grouped into Collections, Globals, and Media sections
72
+ - **Theme support** — Light, dark, and system modes via `ThemeToggle`
72
73
 
73
74
  ## Exports
74
75
 
75
- | Export | Description |
76
- |--------|-------------|
77
- | `AdminPage` | Main routing component — renders the correct view based on URL path |
78
- | `AdminLayout` | Server-side layout wrapper with sidebar and theme |
79
- | `PermissionProvider` | React context provider for RBAC |
80
- | `usePermission` | Hook to check a single action permission |
81
- | `usePermissions` | Hook to check all CRUD permissions for a resource |
82
- | `usePermissionContext` | Hook to access the full permission context |
83
- | `ImpersonationBanner` | Banner component for admin impersonation |
84
- | `useMediaPickerState` | Hook for media picker state management |
76
+ | Export | From | Description |
77
+ |--------|------|-------------|
78
+ | `NextAdminPage` | `@vexcms/next/server` | Async server component that routes the admin panel by URL path (dashboard, list, edit) |
79
+ | `NextAdminLayout` | `@vexcms/next/client` | Client component rendering the admin shell (sidebar, theme, breadcrumbs) around page content |
80
+ | `defineConfig`, `defineCollection`, `relationship`, `text`, `number`, `select`, `date`, `url`, `checkbox` | `@vexcms/next` | Config functions re-exported from `@vexcms/react` |
81
+
82
+ The compiled stylesheet is available at `@vexcms/next/styles`.
85
83
 
86
84
  ## Peer Dependencies
87
85
 
@@ -1,23 +1,6 @@
1
- // src/NextAdminLayout.tsx
2
- import { sanitizeConfigForClient } from "@vexcms/core";
3
- import { NextAdminLayoutClient } from "./NextAdminLayoutClient";
4
- import { cookies } from "next/headers";
5
- import { jsx } from "react/jsx-runtime";
6
- async function NextAdminLayout(props) {
7
- const clientConfig = sanitizeConfigForClient(props.config);
8
- const cookieStore = await cookies();
9
- const sidebarOpen = String(cookieStore.get("sidebar_state")?.value) === "true";
10
- return /* @__PURE__ */ jsx(
11
- NextAdminLayoutClient,
12
- {
13
- config: clientConfig,
14
- user: props.user,
15
- organization: props.organization,
16
- sidebarOpen,
17
- children: props.children
18
- }
19
- );
20
- }
1
+ import {
2
+ NextAdminLayout
3
+ } from "./chunk-YCJNIS6O.js";
21
4
  export {
22
5
  NextAdminLayout
23
6
  };
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/NextAdminLayout.tsx"],"sourcesContent":["import type { ReactNode } from \"react\";\nimport type { VexConfig } from \"@vexcms/core\";\nimport { sanitizeConfigForClient } from \"@vexcms/core\";\nimport type { AdminUser } from \"@vexcms/react\";\nimport { NextAdminLayoutClient } from \"./NextAdminLayoutClient\";\nimport { cookies } from \"next/headers\";\n\n/**\n * Next.js admin layout shell for VexCMS.\n *\n * A **server component** that sits on the server→client boundary for the admin\n * panel. It sanitizes the resolved `vex.config` with `sanitizeConfigForClient`\n * — stripping storage adapter class instances, functions, and React components\n * — *before* the config is handed to the client leaf `NextAdminLayoutClient`.\n *\n * This placement is the whole point: React serializes props when a server\n * component renders a client component, so the sanitize **must** happen on the\n * server side of that edge. Sanitizing inside the client component would run\n * too late — the raw config would already have failed to serialize.\n *\n * The client leaf handles everything that needs the browser (`usePathname`,\n * `NuqsAdapter`, `NextLink`/`NextImage`); this wrapper does no client work.\n *\n * Render it from your admin `layout.tsx`. Because this is a server component,\n * your `layout.tsx` may itself be an `async` server component (e.g. to run auth\n * checks) and pass the **raw** `vex.config` straight through — no sanitize\n * needed on the application side.\n *\n * @param props - Layout props\n * @param props.config - The resolved VexCMS config from `vex.config.ts` (raw)\n * @param props.children - The page content from `[[...slug]]/page.tsx`\n * @param props.user - Current user for the admin shell\n * @returns The rendered admin shell, wrapping `children` in the sanitized-config\n * client boundary ({@link NextAdminLayoutClient}).\n * @example\n * ```tsx\n * // app/admin/layout.tsx\n * import { NextAdminLayout } from \"@vexcms/next/client\";\n * import config from \"~/vex.config\";\n * import { getCurrentUser } from \"~/auth/serverUtils\";\n *\n * export default async function AdminLayout({ children }: { children: ReactNode }) {\n * const user = await getCurrentUser();\n * return (\n * <NextAdminLayout config={config} user={user ?? undefined}>\n * {children}\n * </NextAdminLayout>\n * );\n * }\n * ```\n */\nexport async function NextAdminLayout(props: {\n config: VexConfig;\n children: ReactNode;\n user?: AdminUser;\n organization?: Record<string, unknown>;\n}) {\n // Sanitize on the SERVER side of the server→client boundary, before the\n // config is serialized as a prop into the client leaf. This removes the\n // storage adapter class instances that would otherwise crash RSC\n // serialization (\"Classes or null prototypes are not supported\").\n const clientConfig = sanitizeConfigForClient(props.config);\n const cookieStore = await cookies();\n const sidebarOpen = String(cookieStore.get(\"sidebar_state\")?.value) === \"true\";\n\n return (\n <NextAdminLayoutClient\n config={clientConfig}\n user={props.user}\n organization={props.organization}\n sidebarOpen={sidebarOpen}\n >\n {props.children}\n </NextAdminLayoutClient>\n );\n}\n"],"mappings":";AAEA,SAAS,+BAA+B;AAExC,SAAS,6BAA6B;AACtC,SAAS,eAAe;AA6DpB;AAfJ,eAAsB,gBAAgB,OAKnC;AAKD,QAAM,eAAe,wBAAwB,MAAM,MAAM;AACzD,QAAM,cAAc,MAAM,QAAQ;AAClC,QAAM,cAAc,OAAO,YAAY,IAAI,eAAe,GAAG,KAAK,MAAM;AAExE,SACE;AAAA,IAAC;AAAA;AAAA,MACC,QAAQ;AAAA,MACR,MAAM,MAAM;AAAA,MACZ,cAAc,MAAM;AAAA,MACpB;AAAA,MAEC,gBAAM;AAAA;AAAA,EACT;AAEJ;","names":[]}
1
+ {"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
@@ -1,141 +1,6 @@
1
- // src/NextAdminPage.tsx
2
- import { fetchQuery } from "convex/nextjs";
3
- import { vexConvexApi } from "@vexcms/core";
4
1
  import {
5
- DashboardView,
6
- CollectionListView,
7
- CollectionEditView,
8
- MediaCollectionEditView,
9
- MediaCollectionListView,
10
- GlobalsListView,
11
- GlobalEditView
12
- } from "@vexcms/react";
13
- import { sanitizeConfigForClient } from "@vexcms/core";
14
- import { jsx, jsxs } from "react/jsx-runtime";
15
- async function NextAdminPage(props) {
16
- const { path = [] } = await props.params;
17
- const [collectionSlug, documentId] = path;
18
- const clientConfig = sanitizeConfigForClient(props.config);
19
- if (!collectionSlug) {
20
- return /* @__PURE__ */ jsx(DashboardView, { config: clientConfig });
21
- }
22
- if (collectionSlug === "globals") {
23
- if (!documentId) {
24
- return /* @__PURE__ */ jsx(GlobalsListView, { config: clientConfig });
25
- }
26
- const globalConfig = clientConfig.globals.find((g) => g.slug === documentId);
27
- if (!globalConfig) {
28
- return /* @__PURE__ */ jsxs("div", { children: [
29
- /* @__PURE__ */ jsxs("p", { className: "text-muted-foreground p-6", children: [
30
- 'Global "',
31
- documentId,
32
- '" not found.'
33
- ] }),
34
- /* @__PURE__ */ jsx("p", { children: "TODO: add not found view" })
35
- ] });
36
- }
37
- const global = await fetchQuery(
38
- vexConvexApi.globals.get,
39
- { slug: globalConfig.slug },
40
- props.token ? { token: props.token } : void 0
41
- );
42
- return /* @__PURE__ */ jsx(GlobalEditView, { global: globalConfig, initialData: global });
43
- }
44
- const collection = clientConfig.collections.find((c) => c.slug === collectionSlug);
45
- const mediaCollection = clientConfig.mediaCollections.find((mc) => mc.slug === collectionSlug);
46
- if (!collection && !mediaCollection) {
47
- return /* @__PURE__ */ jsxs("div", { children: [
48
- /* @__PURE__ */ jsxs("p", { className: "text-muted-foreground p-6", children: [
49
- 'Collection "',
50
- collectionSlug,
51
- '" not found.'
52
- ] }),
53
- /* @__PURE__ */ jsx("p", { children: "TODO: add not found view" })
54
- ] });
55
- }
56
- if (mediaCollection && documentId) {
57
- const initialData2 = await fetchQuery(
58
- vexConvexApi.get,
59
- {
60
- id: documentId,
61
- collection: mediaCollection.slug
62
- },
63
- props.token ? { token: props.token } : void 0
64
- );
65
- return /* @__PURE__ */ jsx(
66
- MediaCollectionEditView,
67
- {
68
- collection: mediaCollection,
69
- documentId,
70
- initialData: initialData2
71
- }
72
- );
73
- }
74
- if (mediaCollection) {
75
- const numItems2 = Math.max(
76
- mediaCollection.admin.table.serverPageSize,
77
- mediaCollection.admin.table.defaultPageSize
78
- );
79
- const initialData2 = await fetchQuery(
80
- vexConvexApi.find,
81
- {
82
- collection: collectionSlug,
83
- paginationOpts: {
84
- numItems: numItems2,
85
- totalDocs: true,
86
- cursor: null
87
- }
88
- },
89
- props.token ? { token: props.token } : void 0
90
- );
91
- return /* @__PURE__ */ jsx(
92
- MediaCollectionListView,
93
- {
94
- collection: mediaCollection,
95
- initialData: initialData2
96
- }
97
- );
98
- }
99
- if (collection && documentId) {
100
- const initialData2 = await fetchQuery(
101
- vexConvexApi.get,
102
- {
103
- id: documentId,
104
- collection: collection.slug
105
- },
106
- props.token ? { token: props.token } : void 0
107
- );
108
- return /* @__PURE__ */ jsx(
109
- CollectionEditView,
110
- {
111
- collection,
112
- documentId,
113
- initialData: initialData2
114
- }
115
- );
116
- }
117
- if (!collection) {
118
- throw new Error("invalid collection slug");
119
- }
120
- const numItems = Math.max(
121
- collection.admin.table.serverPageSize,
122
- collection.admin.table.defaultPageSize
123
- );
124
- const initialData = await fetchQuery(
125
- vexConvexApi.findPaginated,
126
- {
127
- collection: collectionSlug,
128
- depth: 1,
129
- paginationOpts: {
130
- cursor: null,
131
- numItems,
132
- totalDocs: true
133
- }
134
- },
135
- props.token ? { token: props.token } : void 0
136
- );
137
- return /* @__PURE__ */ jsx(CollectionListView, { collection, initialData });
138
- }
2
+ NextAdminPage
3
+ } from "./chunk-TR3Z3P6Q.js";
139
4
  export {
140
5
  NextAdminPage
141
6
  };
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/NextAdminPage.tsx"],"sourcesContent":["import { fetchQuery } from \"convex/nextjs\";\nimport { vexConvexApi } from \"@vexcms/core\";\nimport type { CollectionSlug, PaginationResult, VexConfig, VexMediaDocument } from \"@vexcms/core\";\nimport {\n DashboardView,\n CollectionListView,\n CollectionEditView,\n MediaCollectionEditView,\n MediaCollectionListView,\n GlobalsListView,\n GlobalEditView,\n} from \"@vexcms/react\";\nimport { sanitizeConfigForClient } from \"@vexcms/core\";\n\n/**\n * VexCMS admin page server component for Next.js.\n *\n * An `async` server component that routes by the `[[...slug]]` catch-all\n * params, prefetches Convex data via `fetchQuery`, and renders the correct\n * view component. Does **not** include a layout wrapper — `VexAdminLayout`\n * in `app/admin/layout.tsx` owns the persistent shell.\n *\n * **Route mapping:**\n * | `path` array | View |\n * |---|---|\n * | `[]` or undefined | `DashboardView` |\n * | `[collectionSlug]` | `CollectionListView` with preloaded docs |\n * | `[collectionSlug, \"new\"]` | `CollectionEditView` (empty form) |\n * | `[collectionSlug, documentId]` | `CollectionEditView` with preloaded doc |\n *\n * @param props - Component props\n * @param props.config - The resolved VexCMS configuration from `vex.config.ts`\n * @param props.params - Next.js 15 async params `{ path?: string[] }`\n * @returns The appropriate admin view for the current URL path.\n *\n * @example\n * ```tsx\n * // app/admin/[[...slug]]/page.tsx\n * import { VexAdminPage } from \"@vexcms/next\";\n * import config from \"../../../../vex.config\";\n *\n * export default function AdminPage({\n * params,\n * }: {\n * params: Promise<{ path?: string[] }>;\n * }) {\n * return <VexAdminPage config={config} params={params} />;\n * }\n * ```\n */\nexport async function NextAdminPage(props: {\n config: VexConfig;\n params: Promise<{ path?: string[] }>;\n token?: string;\n}) {\n const { path = [] } = await props.params;\n const [collectionSlug, documentId] = path;\n\n // Sanitize config for client components (strips storageAdapters, recursively sanitizes mediaCollections)\n const clientConfig = sanitizeConfigForClient(props.config);\n\n if (!collectionSlug) {\n return <DashboardView config={clientConfig} />;\n }\n\n if (collectionSlug === \"globals\") {\n if (!documentId) {\n return <GlobalsListView config={clientConfig} />;\n }\n // Validate the route param against the registered globals first — the\n // found config's `slug` carries the narrowed GlobalSlug type, so no cast\n // of the raw URL segment is needed downstream.\n const globalConfig = clientConfig.globals.find((g) => g.slug === documentId);\n if (!globalConfig) {\n return (\n <div>\n <p className=\"text-muted-foreground p-6\">Global &quot;{documentId}&quot; not found.</p>\n <p>TODO: add not found view</p>\n </div>\n );\n }\n const global = await fetchQuery(\n vexConvexApi.globals.get,\n { slug: globalConfig.slug },\n props.token ? { token: props.token } : undefined,\n );\n return <GlobalEditView global={globalConfig} initialData={global} />;\n }\n\n const collection = clientConfig.collections.find((c) => c.slug === collectionSlug);\n const mediaCollection = clientConfig.mediaCollections.find((mc) => mc.slug === collectionSlug);\n\n if (!collection && !mediaCollection) {\n return (\n <div>\n <p className=\"text-muted-foreground p-6\">\n Collection &quot;{collectionSlug}&quot; not found.\n </p>\n <p>TODO: add not found view</p>\n </div>\n );\n }\n\n if (mediaCollection && documentId) {\n const initialData = await fetchQuery(\n vexConvexApi.get,\n {\n id: documentId,\n collection: mediaCollection.slug,\n },\n props.token ? { token: props.token } : undefined,\n );\n return (\n <MediaCollectionEditView\n collection={mediaCollection}\n documentId={documentId}\n initialData={initialData as VexMediaDocument | null}\n />\n );\n }\n\n if (mediaCollection) {\n const numItems = Math.max(\n mediaCollection.admin.table.serverPageSize,\n mediaCollection.admin.table.defaultPageSize,\n );\n const initialData = await fetchQuery(\n vexConvexApi.find,\n {\n collection: collectionSlug as CollectionSlug,\n paginationOpts: {\n numItems,\n totalDocs: true,\n cursor: null,\n },\n },\n props.token ? { token: props.token } : undefined,\n );\n return (\n <MediaCollectionListView\n collection={mediaCollection}\n initialData={initialData as PaginationResult<VexMediaDocument>}\n />\n );\n }\n\n if (collection && documentId) {\n const initialData = await fetchQuery(\n vexConvexApi.get,\n {\n id: documentId,\n collection: collection.slug,\n },\n props.token ? { token: props.token } : undefined,\n );\n return (\n <CollectionEditView\n collection={collection}\n documentId={documentId}\n initialData={initialData}\n />\n );\n }\n\n if (!collection) {\n throw new Error(\"invalid collection slug\");\n }\n\n const numItems = Math.max(\n collection.admin.table.serverPageSize,\n collection.admin.table.defaultPageSize,\n );\n const initialData = await fetchQuery(\n vexConvexApi.findPaginated,\n {\n collection: collectionSlug as CollectionSlug,\n depth: 1,\n paginationOpts: {\n cursor: null,\n numItems,\n totalDocs: true,\n },\n },\n props.token ? { token: props.token } : undefined,\n );\n return <CollectionListView collection={collection} initialData={initialData} />;\n}\n"],"mappings":"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","names":["initialData","numItems"]}
1
+ {"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
@@ -0,0 +1,88 @@
1
+ import type { CollectionSlug, VexConfig, VexDocument, VexRevalidateResponse } from "@vexcms/core";
2
+ import type { NextRequest } from "next/server";
3
+ import { NextResponse } from "next/server";
4
+ /** Props for {@link createVexRevalidateRoute}. */
5
+ export interface CreateVexRevalidateRouteProps {
6
+ /**
7
+ * The resolved VexCMS config. Supplies `access` for the write-permission
8
+ * check and `routes.map` for target resolution.
9
+ */
10
+ config: VexConfig;
11
+ /**
12
+ * Resolves the authenticated user and active organization for the current
13
+ * request. Wire to `() => fetchAuthQuery(api.auth.api.getUserOrg, {})` — the
14
+ * same call the admin panel route already makes.
15
+ */
16
+ getAuth: () => Promise<{
17
+ organization?: Record<string, unknown>;
18
+ user: null | Record<string, unknown>;
19
+ }>;
20
+ /**
21
+ * Reads the caller's session token for the current request. Wire to the host
22
+ * app's `getToken` export (`convexBetterAuthNextJs`'s output, re-exported
23
+ * from e.g. `~/auth/server`). A falsy return means "no session".
24
+ */
25
+ getToken: () => Promise<null | string | undefined>;
26
+ /**
27
+ * Lists every document in a collection, for the collection-wide purge
28
+ * (`{ collection, all: true }`). Wire to a read through the app's own
29
+ * `publishedSlugs`-style query — the factory cannot know an app's generated
30
+ * Convex `api`, which is why this is injected like `getToken`/`getAuth`.
31
+ *
32
+ * Omit to leave the collection-wide branch unwired: it then purges nothing
33
+ * and reports that in `errors`, rather than silently succeeding.
34
+ */
35
+ listCollection?: (collection: CollectionSlug) => Promise<Partial<VexDocument>[]>;
36
+ }
37
+ /**
38
+ * Creates a Next.js route handler that purges the cached paths affected by a
39
+ * document write. The admin panel calls this via `useVexMutation` after every
40
+ * successful create/update/delete, same-origin, as a relative fetch — no URL
41
+ * config, no shared secret.
42
+ *
43
+ * **Session-authorized, not secret-authorized.** The caller is a signed-in
44
+ * admin, so this reuses `getToken`/`getAuth` exactly like the admin panel route
45
+ * does, rather than introducing an env var or a shared secret. There is no
46
+ * API-key fallback: a caller with no browser session cannot reach this route as
47
+ * designed.
48
+ *
49
+ * **Path-based, not tag-based.** `revalidatePath` is the only purge mechanism.
50
+ * `cacheComponents: true` — required for `cacheTag`/`revalidateTag` — is
51
+ * incompatible with the `dynamic`/`runtime` segment configs the auth and admin
52
+ * routes require (measured: 5 files fail to build with it enabled), so
53
+ * `revalidateTag` is not an option in this codebase. Do not "upgrade" this to
54
+ * tag-based revalidation without first removing that segment-config dependency
55
+ * everywhere.
56
+ *
57
+ * **Never fails the caller past authorization.** A missing session returns 401,
58
+ * a session without write permission 403, and an oversized `changes` batch 413
59
+ * — all three reject before any map work runs. Past that point the handler
60
+ * never throws or returns 5xx: an unconfigured map, a throwing map, or an
61
+ * individual `revalidatePath` failure all resolve to 200 with the failures
62
+ * reported in the body. A failed purge is a stale page; failing the endpoint
63
+ * would only teach the fire-and-forget caller to retry pointlessly.
64
+ *
65
+ * @param props - Config and the host app's auth accessors.
66
+ * @returns `{ POST }` — mount directly as the route module's named export.
67
+ *
68
+ * @example
69
+ * ```ts
70
+ * // app/api/vex/revalidate/route.ts
71
+ * import { api } from "@convex/_generated/api";
72
+ * import { createVexRevalidateRoute } from "@vexcms/next/cache";
73
+ * import { fetchAuthQuery, getToken } from "~/auth/server";
74
+ * import config from "~/vex.config";
75
+ *
76
+ * export const { POST } = createVexRevalidateRoute({
77
+ * config,
78
+ * getToken,
79
+ * getAuth: () => fetchAuthQuery(api.auth.api.getUserOrg, {}),
80
+ * });
81
+ * ```
82
+ */
83
+ export declare function createVexRevalidateRoute(props: CreateVexRevalidateRouteProps): {
84
+ POST: (request: NextRequest) => Promise<NextResponse<{
85
+ error: string;
86
+ } | VexRevalidateResponse>>;
87
+ };
88
+ //# sourceMappingURL=createVexRevalidateRoute.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"createVexRevalidateRoute.d.ts","sourceRoot":"","sources":["../../src/cache/createVexRevalidateRoute.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,cAAc,EAEd,SAAS,EACT,WAAW,EAIX,qBAAqB,EACtB,MAAM,cAAc,CAAC;AAGtB,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAC/C,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAE3C,kDAAkD;AAClD,MAAM,WAAW,6BAA6B;IAC5C;;;OAGG;IACH,MAAM,EAAE,SAAS,CAAC;IAClB;;;;OAIG;IACH,OAAO,EAAE,MAAM,OAAO,CAAC;QACrB,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;QACvC,IAAI,EAAE,IAAI,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;KACtC,CAAC,CAAC;IACH;;;;OAIG;IACH,QAAQ,EAAE,MAAM,OAAO,CAAC,IAAI,GAAG,MAAM,GAAG,SAAS,CAAC,CAAC;IACnD;;;;;;;;OAQG;IACH,cAAc,CAAC,EAAE,CAAC,UAAU,EAAE,cAAc,KAAK,OAAO,CAAC,OAAO,CAAC,WAAW,CAAC,EAAE,CAAC,CAAC;CAClF;AAoBD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6CG;AACH,wBAAgB,wBAAwB,CAAC,KAAK,EAAE,6BAA6B,GAAG;IAC9E,IAAI,EAAE,CAAC,OAAO,EAAE,WAAW,KAAK,OAAO,CAAC,YAAY,CAAC;QAAE,KAAK,EAAE,MAAM,CAAA;KAAE,GAAG,qBAAqB,CAAC,CAAC,CAAC;CAClG,CA8GA"}
@@ -0,0 +1,31 @@
1
+ import type { VexServerClientOptions, VexServerClient } from "./types";
2
+ /**
3
+ * Creates a server-only Convex read client safe to use in prerenderable
4
+ * Next.js routes.
5
+ *
6
+ * Wraps `ConvexHttpClient` from `convex/browser` instead of `convex/nextjs`'s
7
+ * `fetchQuery`, which hard-codes `client.setFetchOptions({ cache: "no-store" })`
8
+ * — the reason every route calling it builds dynamic (`ƒ`). This client never
9
+ * sets that option, so a route calling only `query()` can be prerendered and
10
+ * governed by the route's own `export const revalidate`.
11
+ *
12
+ * Every `query()` call is deduped via `React.cache` for the lifetime of one
13
+ * request: two identical `(query, args)` calls — e.g. `generateMetadata` and
14
+ * its page both reading the same document — resolve from a single Convex round
15
+ * trip instead of two.
16
+ *
17
+ * `React.cache` is instantiated fresh inside every `createVexServerClient()`
18
+ * call, so that dedupe only holds across calls sharing the SAME client
19
+ * instance — two clients built in two different modules cannot share a round
20
+ * trip even when they read identical `(query, args)`. Each app therefore
21
+ * creates exactly one instance, exported from its own `src/lib/vex.ts`, and
22
+ * every route or component imports that shared `vex` rather than calling
23
+ * `createVexServerClient()` itself.
24
+ *
25
+ * @param props - Client configuration.
26
+ * @returns A {@link VexServerClient} whose `query` method is deduped per request.
27
+ * @throws {Error} When no `url` is given and `process.env.NEXT_PUBLIC_CONVEX_URL`
28
+ * is unset.
29
+ */
30
+ export declare function createVexServerClient(props?: VexServerClientOptions): VexServerClient;
31
+ //# sourceMappingURL=createVexServerClient.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"createVexServerClient.d.ts","sourceRoot":"","sources":["../../src/cache/createVexServerClient.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,sBAAsB,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAEvE;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,wBAAgB,qBAAqB,CAAC,KAAK,GAAE,sBAA2B,GAAG,eAAe,CA2BzF"}
@@ -0,0 +1,5 @@
1
+ export { createVexRevalidateRoute } from "./createVexRevalidateRoute";
2
+ export type { CreateVexRevalidateRouteProps } from "./createVexRevalidateRoute";
3
+ export { createVexServerClient } from "./createVexServerClient";
4
+ export type { VexServerClientOptions, VexServerClient, } from "./types";
5
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/cache/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,wBAAwB,EAAE,MAAM,4BAA4B,CAAC;AACtE,YAAY,EAAE,6BAA6B,EAAE,MAAM,4BAA4B,CAAC;AAChF,OAAO,EAAE,qBAAqB,EAAE,MAAM,yBAAyB,CAAC;AAOhE,YAAY,EACV,sBAAsB,EACtB,eAAe,GAChB,MAAM,SAAS,CAAC"}
@@ -0,0 +1,9 @@
1
+ import {
2
+ createVexRevalidateRoute,
3
+ createVexServerClient
4
+ } from "../chunk-5AGM3PEX.js";
5
+ export {
6
+ createVexRevalidateRoute,
7
+ createVexServerClient
8
+ };
9
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
@@ -0,0 +1,36 @@
1
+ import type { FunctionReference, FunctionReturnType, OptionalRestArgs } from "convex/server";
2
+ /**
3
+ * Construction options for {@link createVexServerClient}.
4
+ *
5
+ * Nothing here configures caching — the client's only cache behaviour is that
6
+ * it never sets `cache: "no-store"` and dedupes per request via `React.cache`.
7
+ * Cache *policy* lives where Next requires it: `export const revalidate` in
8
+ * each route.
9
+ */
10
+ export interface VexServerClientOptions {
11
+ /**
12
+ * Skip Convex's deployment URL format check — for self-hosted backends whose
13
+ * URL does not match `https://<name>.convex.cloud`.
14
+ */
15
+ skipConvexDeploymentUrlCheck?: boolean;
16
+ /** Convex deployment URL. Defaults to `process.env.NEXT_PUBLIC_CONVEX_URL`. */
17
+ url?: string;
18
+ }
19
+ /**
20
+ * Server-only Convex read client returned by {@link createVexServerClient}.
21
+ *
22
+ * Exposes only `query` — this client is for prerenderable read paths, and it
23
+ * never sets `cache: "no-store"`, unlike `convex/nextjs`'s `fetchQuery`.
24
+ */
25
+ export interface VexServerClient {
26
+ /**
27
+ * Runs a Convex query, deduped via `React.cache` for the lifetime of one
28
+ * request when called again with the same function reference and args.
29
+ *
30
+ * @param query - The Convex query function reference to call.
31
+ * @param args - Arguments for `query`, per Convex's `OptionalRestArgs`.
32
+ * @returns The query's resolved return value.
33
+ */
34
+ query<Query extends FunctionReference<"query">>(query: Query, ...args: OptionalRestArgs<Query>): Promise<FunctionReturnType<Query>>;
35
+ }
36
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/cache/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,kBAAkB,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;AAE7F;;;;;;;GAOG;AACH,MAAM,WAAW,sBAAsB;IACrC;;;OAGG;IACH,4BAA4B,CAAC,EAAE,OAAO,CAAC;IACvC,+EAA+E;IAC/E,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AAED;;;;;GAKG;AACH,MAAM,WAAW,eAAe;IAC9B;;;;;;;OAOG;IACH,KAAK,CAAC,KAAK,SAAS,iBAAiB,CAAC,OAAO,CAAC,EAC5C,KAAK,EAAE,KAAK,EACZ,GAAG,IAAI,EAAE,gBAAgB,CAAC,KAAK,CAAC,GAC/B,OAAO,CAAC,kBAAkB,CAAC,KAAK,CAAC,CAAC,CAAC;CACvC"}
@@ -0,0 +1,121 @@
1
+ // src/cache/createVexRevalidateRoute.ts
2
+ import { hasPermission, resolveTargets, VEX_REVALIDATE_BATCH_SIZE } from "@vexcms/core";
3
+ import { revalidatePath } from "next/cache";
4
+ import { NextResponse } from "next/server";
5
+ function toCrudAction(operation) {
6
+ if (operation === "remove") return "delete";
7
+ if (operation === "upsert") return "update";
8
+ return operation;
9
+ }
10
+ function createVexRevalidateRoute(props) {
11
+ return {
12
+ async POST(request) {
13
+ const token = await props.getToken();
14
+ if (token === null || token === void 0 || token === "") {
15
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
16
+ }
17
+ let body;
18
+ try {
19
+ body = await request.json();
20
+ } catch {
21
+ return NextResponse.json({ error: "Bad Request" }, { status: 400 });
22
+ }
23
+ const { organization, user } = await props.getAuth();
24
+ const operation = "all" in body ? "update" : body.operation;
25
+ const action = toCrudAction(operation);
26
+ const allowed = hasPermission({
27
+ access: props.config.access,
28
+ action,
29
+ organization,
30
+ resource: body.collection,
31
+ user
32
+ });
33
+ if (!allowed) {
34
+ return NextResponse.json({ error: "Forbidden" }, { status: 403 });
35
+ }
36
+ const errors = [];
37
+ let changes;
38
+ if ("all" in body) {
39
+ if (props.listCollection === void 0) {
40
+ errors.push(
41
+ new Error(
42
+ "createVexRevalidateRoute: a collection-wide purge was requested but `listCollection` is not wired"
43
+ )
44
+ );
45
+ changes = [];
46
+ } else {
47
+ try {
48
+ const docs = await props.listCollection(body.collection);
49
+ changes = docs.map((doc) => ({ after: doc }));
50
+ } catch (error) {
51
+ errors.push(error);
52
+ changes = [];
53
+ }
54
+ }
55
+ } else {
56
+ changes = body.changes;
57
+ if (changes.length > VEX_REVALIDATE_BATCH_SIZE) {
58
+ return NextResponse.json(
59
+ {
60
+ error: `Cannot revalidate more than ${VEX_REVALIDATE_BATCH_SIZE} changes in a single request`
61
+ },
62
+ { status: 413 }
63
+ );
64
+ }
65
+ }
66
+ const routesConfig = props.config.routes;
67
+ if (routesConfig === void 0) {
68
+ return NextResponse.json({ errors, revalidated: [] }, { status: 200 });
69
+ }
70
+ const pool = [];
71
+ for (const change of changes) {
72
+ const target = resolveTargets({
73
+ after: change.after,
74
+ before: change.before,
75
+ collection: body.collection,
76
+ map: routesConfig.map,
77
+ operation: action
78
+ });
79
+ pool.push(...target.paths);
80
+ errors.push(...target.errors);
81
+ }
82
+ const revalidated = [];
83
+ for (const path of new Set(pool)) {
84
+ try {
85
+ revalidatePath(path);
86
+ revalidated.push(path);
87
+ } catch (error) {
88
+ errors.push(error);
89
+ }
90
+ }
91
+ return NextResponse.json({ errors, revalidated }, { status: 200 });
92
+ }
93
+ };
94
+ }
95
+
96
+ // src/cache/createVexServerClient.ts
97
+ import { ConvexHttpClient } from "convex/browser";
98
+ import { cache } from "react";
99
+ function createVexServerClient(props = {}) {
100
+ const url = props.url ?? process.env.NEXT_PUBLIC_CONVEX_URL;
101
+ if (url === void 0 || url === "") {
102
+ throw new Error(
103
+ "createVexServerClient: no Convex deployment URL. Pass `url`, or set NEXT_PUBLIC_CONVEX_URL."
104
+ );
105
+ }
106
+ const client = new ConvexHttpClient(url, {
107
+ skipConvexDeploymentUrlCheck: props.skipConvexDeploymentUrlCheck
108
+ });
109
+ const cachedQuery = cache(
110
+ async (query, args) => client.query(query, args)
111
+ );
112
+ return {
113
+ query: async (query, ...args) => cachedQuery(query, args[0] ?? {})
114
+ };
115
+ }
116
+
117
+ export {
118
+ createVexRevalidateRoute,
119
+ createVexServerClient
120
+ };
121
+ //# sourceMappingURL=chunk-5AGM3PEX.js.map