create-vexcms 0.1.0-alpha.12 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-vexcms",
3
- "version": "0.1.0-alpha.12",
3
+ "version": "0.1.0-alpha.13",
4
4
  "description": "Scaffold a new VexCMS project",
5
5
  "keywords": [
6
6
  "cms",
@@ -130,6 +130,7 @@ export const images = defineTable({
130
130
  src: v.string(),
131
131
  width: v.optional(v.number()),
132
132
  height: v.optional(v.number()),
133
+ updatedAt: v.optional(v.number()),
133
134
  })
134
135
  .index("by_deleted", ["deleted"])
135
136
  .searchIndex("search_filename", {
@@ -24,11 +24,11 @@
24
24
  "@t3-oss/env-nextjs": "0.13.11",
25
25
  "@tanstack/react-form": "1.33.1",
26
26
  "@tanstack/react-query": "5.101.2",
27
- "@vexcms/better-auth": "~0.1.0-alpha.12",
28
- "@vexcms/core": "~0.1.0-alpha.12",
29
- "@vexcms/file-storage-convex": "~0.1.0-alpha.12",
30
- "@vexcms/next": "~0.1.0-alpha.12",
31
- "@vexcms/react": "~0.1.0-alpha.12",
27
+ "@vexcms/better-auth": "~0.1.0-alpha.13",
28
+ "@vexcms/core": "~0.1.0-alpha.13",
29
+ "@vexcms/file-storage-convex": "~0.1.0-alpha.13",
30
+ "@vexcms/next": "~0.1.0-alpha.13",
31
+ "@vexcms/react": "~0.1.0-alpha.13",
32
32
  "better-auth": "1.6.23",
33
33
  "class-variance-authority": "0.7.1",
34
34
  "clsx": "2.1.1",
@@ -52,7 +52,7 @@
52
52
  "@types/node": "20.19.43",
53
53
  "@types/react": "19.2.17",
54
54
  "@types/react-dom": "19.2.3",
55
- "@vexcms/cli": "~0.1.0-alpha.12",
55
+ "@vexcms/cli": "~0.1.0-alpha.13",
56
56
  "babel-plugin-react-compiler": "1.0.0",
57
57
  "eslint": "9.39.5",
58
58
  "eslint-config-next": "15.5.9",
@@ -63,5 +63,17 @@
63
63
  "tailwindcss": "4.3.2",
64
64
  "typescript": "6.0.3",
65
65
  "typescript-eslint": "8.63.0"
66
+ },
67
+ "pnpm": {
68
+ "overrides": {
69
+ "@better-auth/passkey": "1.6.23"
70
+ },
71
+ "peerDependencyRules": {
72
+ "allowedVersions": {
73
+ "@convex-dev/better-auth>better-auth": "1.6.23",
74
+ "better-call": "2",
75
+ "typescript": "6"
76
+ }
77
+ }
66
78
  }
67
79
  }
@@ -2,6 +2,7 @@ import type { ReactNode } from "react"
2
2
 
3
3
  import { NextAdminLayout } from "@vexcms/next/client"
4
4
 
5
+ import { AuthServerProvider } from "~/components/providers/auth"
5
6
  import { getCurrentUser } from "~/auth/serverUtils"
6
7
  import config from "~/vex.config"
7
8
 
@@ -10,10 +11,12 @@ import { ClientProviders } from "./clientProviders"
10
11
  export default async function AdminLayout({ children }: { children: ReactNode }) {
11
12
  const user = await getCurrentUser()
12
13
  return (
13
- <ClientProviders>
14
- <NextAdminLayout config={config} user={user ?? undefined}>
15
- {children}
16
- </NextAdminLayout>
17
- </ClientProviders>
14
+ <AuthServerProvider>
15
+ <ClientProviders>
16
+ <NextAdminLayout config={config} user={user ?? undefined}>
17
+ {children}
18
+ </NextAdminLayout>
19
+ </ClientProviders>
20
+ </AuthServerProvider>
18
21
  )
19
22
  }
@@ -0,0 +1,19 @@
1
+ import { api } from "@convex/_generated/api"
2
+ import { createVexRevalidateRoute } from "@vexcms/next/cache"
3
+
4
+ import { fetchAuthQuery, getToken } from "~/auth/server"
5
+ import config from "~/vex.config"
6
+
7
+ /**
8
+ * `POST /api/vex/revalidate` — session-authorized path purge.
9
+ *
10
+ * Called by `useVexMutation` after every admin-panel write, same origin, and
11
+ * by the admin panel's Revalidate control over the same session. Never
12
+ * secret-authorized: the caller must hold a real session with write
13
+ * permission on the affected collection.
14
+ */
15
+ export const { POST } = createVexRevalidateRoute({
16
+ config,
17
+ getToken,
18
+ getAuth: () => fetchAuthQuery(api.auth.api.getUserOrg, {}),
19
+ })
@@ -2,10 +2,12 @@ import { ThemeProvider } from "@vexcms/react";
2
2
  import { NuqsAdapter } from "nuqs/adapters/next/app";
3
3
  import { type PropsWithChildren } from "react";
4
4
 
5
- import { AuthServerProvider } from "./auth";
6
-
7
5
  /**
8
- * Server-side provider shell.
6
+ * Server-side provider shell: theme context + the nuqs URL-state adapter.
7
+ *
8
+ * `AuthServerProvider` used to wrap `NuqsAdapter` here, putting a `getToken()`
9
+ * cookie read on every route's render path. It now mounts directly in
10
+ * `app/(vexcms)/admin/layout.tsx`, the only route group that needs it.
9
11
  *
10
12
  * Deliberately does **not** mount `ConvexClientProvider` — `ClientProviders`
11
13
  * renders it, and `ClientProviders` is nested inside this component, so its
@@ -14,18 +16,11 @@ import { AuthServerProvider } from "./auth";
14
16
  * render (`providers/convex.tsx` intentionally creates fresh clients per call
15
17
  * server-side to avoid cross-request leaks) whose only consumer was the
16
18
  * discarded outer subtree.
17
- *
18
- * Nothing between here and `ClientProviders` needs Convex: `AuthServerProvider`
19
- * is a server component that calls `getToken()`/`fetchAuthQuery` directly, and
20
- * `NuqsAdapter` is unrelated. React context is unreadable from server
21
- * components regardless.
22
19
  */
23
20
  export default function ServerProviders({ children }: PropsWithChildren) {
24
21
  return (
25
22
  <ThemeProvider defaultTheme="system">
26
- <AuthServerProvider>
27
- <NuqsAdapter>{children}</NuqsAdapter>
28
- </AuthServerProvider>
23
+ <NuqsAdapter>{children}</NuqsAdapter>
29
24
  </ThemeProvider>
30
25
  );
31
26
  }
@@ -135,6 +135,7 @@ export interface Image extends VexDocument {
135
135
  src: string
136
136
  width?: number
137
137
  height?: number
138
+ updatedAt?: number
138
139
  }
139
140
 
140
141
  export type CollectionSlug =
@@ -263,7 +264,7 @@ declare module "@vexcms/core" {
263
264
  }
264
265
  images: {
265
266
  text: "filename" | "alt" | "mimeType" | "storageId" | "src"
266
- number: "size" | "width" | "height"
267
+ number: "size" | "width" | "height" | "updatedAt"
267
268
  checkbox: "deleted"
268
269
  }
269
270
  }
@@ -1,7 +1,7 @@
1
1
  import { v } from "convex/values"
2
2
 
3
3
  import { TABLE_SLUG_PAGES } from "~/db/constants"
4
- import { find } from "~/vexcms/api"
4
+ import { find, publishedSlugs as readPublishedSlugs } from "~/vexcms/api"
5
5
 
6
6
  import { query } from "./_generated/server"
7
7
 
@@ -29,3 +29,22 @@ export const getBySlug = query({
29
29
  })
30
30
  },
31
31
  })
32
+
33
+ /**
34
+ * Returns `{ slug, createdAt, updatedAt? }` for every page document.
35
+ *
36
+ * Consumed by `app/sitemap.ts` and `[slug]/page.tsx`'s `generateStaticParams`.
37
+ * Access is bypassed for the same reason `getBySlug` bypasses it: both are
38
+ * read at build time and by anonymous crawlers, neither of which carries a
39
+ * session.
40
+ */
41
+ export const publishedSlugs = query({
42
+ args: {},
43
+ handler: async (ctx) => {
44
+ return await readPublishedSlugs({
45
+ access: { bypass: true },
46
+ collection: TABLE_SLUG_PAGES,
47
+ ctx,
48
+ })
49
+ },
50
+ })
@@ -160,6 +160,7 @@ export const pages = defineTable({
160
160
  metaTitle: v.optional(v.string()),
161
161
  metaDescription: v.optional(v.string()),
162
162
  ogImage: v.optional(v.array(v.id("images"))),
163
+ updatedAt: v.optional(v.number()),
163
164
  }).index("by_slug", ["slug"])
164
165
 
165
166
  export const headers = defineTable({
@@ -188,6 +189,7 @@ export const headers = defineTable({
188
189
  })
189
190
  )
190
191
  ),
192
+ updatedAt: v.optional(v.number()),
191
193
  }).index("by_name", ["name"])
192
194
 
193
195
  export const footers = defineTable({
@@ -210,6 +212,7 @@ export const footers = defineTable({
210
212
  })
211
213
  )
212
214
  ),
215
+ updatedAt: v.optional(v.number()),
213
216
  }).index("by_name", ["name"])
214
217
 
215
218
  export const themes = defineTable({
@@ -288,6 +291,7 @@ export const themes = defineTable({
288
291
  sidebarRing: v.optional(v.string()),
289
292
  })
290
293
  ),
294
+ updatedAt: v.optional(v.number()),
291
295
  }).index("by_name", ["name"])
292
296
 
293
297
  export const session = defineTable({
@@ -370,6 +374,7 @@ export const images = defineTable({
370
374
  src: v.string(),
371
375
  width: v.optional(v.number()),
372
376
  height: v.optional(v.number()),
377
+ updatedAt: v.optional(v.number()),
373
378
  })
374
379
  .index("by_deleted", ["deleted"])
375
380
  .searchIndex("search_filename", {
@@ -1,12 +1,30 @@
1
1
  import { api } from "@convex/_generated/api"
2
- import { fetchQuery } from "convex/nextjs"
2
+ import { vexStaticParams } from "@vexcms/next/seo"
3
+ import { notFound } from "next/navigation"
3
4
 
4
5
  import type { PagesDocument } from "~/vex.types"
5
6
 
6
7
  import { generatePageMetadata } from "~/lib/metadata"
8
+ import { vex } from "~/lib/vex"
7
9
 
8
10
  import { PageContent } from "../PageContent"
9
11
 
12
+ // Next requires this to be an inline literal — it is read by static analysis
13
+ // before any module executes, so neither `vexConfig.revalidate.revalidateSeconds`
14
+ // nor an imported constant is accepted ("Invalid segment configuration export").
15
+ export const revalidate = 3600
16
+
17
+ export async function generateStaticParams() {
18
+ const entries = await vexStaticParams({
19
+ client: vex,
20
+ query: api.pages.publishedSlugs,
21
+ paramName: "slug",
22
+ getSlug: (item) => item.slug,
23
+ })
24
+
25
+ return entries.filter((entry) => entry.slug !== "home")
26
+ }
27
+
10
28
  export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
11
29
  const { slug } = await params
12
30
  return generatePageMetadata({ slug: slug && slug.length > 0 ? slug : "home" })
@@ -16,11 +34,24 @@ export default async function PublicPage({ params }: { params: Promise<{ slug: s
16
34
  const { slug } = await params
17
35
  const normalized = slug && slug.length > 0 ? slug : "home"
18
36
 
37
+ // The two failure modes are deliberately NOT the same:
38
+ //
39
+ // - The query THROWS: the deployment is unreachable. A scaffold builds with
40
+ // placeholder env, so throwing here would fail `next build` outright.
41
+ // Render with no seed and let the client's live subscription hydrate.
42
+ // - The query SUCCEEDS and returns `[]`: the deployment answered, and this
43
+ // page genuinely does not exist. That must be a real 404, not an empty
44
+ // 200 — an empty 200 gets the URL indexed as a live, blank page.
19
45
  let initialData: PagesDocument[] | undefined
46
+ let reachable = true
20
47
  try {
21
- initialData = await fetchQuery(api.pages.getBySlug, { slug: normalized })
48
+ initialData = await vex.query(api.pages.getBySlug, { slug: normalized })
22
49
  } catch {
23
- // Fall back to client-only fetch
50
+ reachable = false
51
+ }
52
+
53
+ if (reachable && (!initialData || initialData.length === 0)) {
54
+ notFound()
24
55
  }
25
56
 
26
57
  return <PageContent initialData={initialData} slug={normalized} />
@@ -1,12 +1,13 @@
1
1
  import type { ReactNode } from "react"
2
2
 
3
3
  import { api } from "@convex/_generated/api"
4
- import { fetchQuery } from "convex/nextjs"
5
4
 
6
5
  import type { FootersDocument, HeadersDocument } from "~/vex.types"
7
6
 
8
7
  import { SiteFooter } from "~/components/SiteFooter"
9
8
  import { SiteHeader } from "~/components/SiteHeader"
9
+ import { ThemeStyle } from "~/components/ThemeStyle"
10
+ import { vex } from "~/lib/vex"
10
11
 
11
12
  /**
12
13
  * Marketing chrome: header + footer around every site page. Auth routes live
@@ -22,8 +23,8 @@ export default async function SiteLayout({
22
23
 
23
24
  try {
24
25
  ;[headerData, footerData] = await Promise.all([
25
- fetchQuery(api.headers.getFirst),
26
- fetchQuery(api.footers.getFirst),
26
+ vex.query(api.headers.getFirst),
27
+ vex.query(api.footers.getFirst),
27
28
  ])
28
29
  } catch {
29
30
  // Convex not available — fall back to client-only fetch
@@ -31,6 +32,7 @@ export default async function SiteLayout({
31
32
 
32
33
  return (
33
34
  <>
35
+ <ThemeStyle />
34
36
  {/* Sits above the sticky header so it is the first tab stop on every
35
37
  page. Visually hidden until focused. */}
36
38
  <a
@@ -1,12 +1,17 @@
1
1
  import { api } from "@convex/_generated/api"
2
- import { fetchQuery } from "convex/nextjs"
3
2
 
4
3
  import type { PagesDocument } from "~/vex.types"
5
4
 
6
5
  import { generatePageMetadata } from "~/lib/metadata"
6
+ import { vex } from "~/lib/vex"
7
7
 
8
8
  import { PageContent } from "./PageContent"
9
9
 
10
+ // Next requires this to be an inline literal — it is read by static analysis
11
+ // before any module executes, so neither `vexConfig.revalidate.revalidateSeconds`
12
+ // nor an imported constant is accepted ("Invalid segment configuration export").
13
+ export const revalidate = 3600
14
+
10
15
  export async function generateMetadata() {
11
16
  return generatePageMetadata({ slug: "home" })
12
17
  }
@@ -14,7 +19,7 @@ export async function generateMetadata() {
14
19
  export default async function HomePage() {
15
20
  let initialData: PagesDocument[] | undefined
16
21
  try {
17
- initialData = await fetchQuery(api.pages.getBySlug, { slug: "home" })
22
+ initialData = await vex.query(api.pages.getBySlug, { slug: "home" })
18
23
  } catch {
19
24
  // Fall back to client-only fetch
20
25
  }
@@ -2,6 +2,7 @@ import type { ReactNode } from "react";
2
2
 
3
3
  import { NextAdminLayout } from "@vexcms/next/client";
4
4
 
5
+ import { AuthServerProvider } from "~/components/providers/auth";
5
6
  import { getCurrentUser } from "~/auth/serverUtils";
6
7
  import { ThemeLive } from "~/components/ThemeLive";
7
8
  import { ThemeStyle } from "~/components/ThemeStyle";
@@ -21,12 +22,14 @@ import { ClientProviders } from "./clientProviders";
21
22
  export default async function AdminLayout({ children }: { children: ReactNode }) {
22
23
  const user = await getCurrentUser();
23
24
  return (
24
- <ClientProviders>
25
- <ThemeStyle scope="admin" />
26
- <ThemeLive scope="admin" />
27
- <NextAdminLayout config={config} user={user ?? undefined}>
28
- {children}
29
- </NextAdminLayout>
30
- </ClientProviders>
25
+ <AuthServerProvider>
26
+ <ClientProviders>
27
+ <ThemeStyle scope="admin" />
28
+ <ThemeLive scope="admin" />
29
+ <NextAdminLayout config={config} user={user ?? undefined}>
30
+ {children}
31
+ </NextAdminLayout>
32
+ </ClientProviders>
33
+ </AuthServerProvider>
31
34
  );
32
35
  }
@@ -8,7 +8,6 @@ import { Geist, Geist_Mono } from "next/font/google"
8
8
  import ClientProviders from "~/components/providers/client"
9
9
  import ServerProviders from "~/components/providers/server"
10
10
  import { ThemeLive } from "~/components/ThemeLive"
11
- import { ThemeStyle } from "~/components/ThemeStyle"
12
11
 
13
12
  /**
14
13
  * Geist carries the whole type system — display through body — so weight is
@@ -68,12 +67,10 @@ export default function RootLayout({
68
67
  suppressHydrationWarning
69
68
  >
70
69
  <head>
71
- {/* ThemeScript applies the persisted light/dark class before first
72
- paint (no flash); ThemeStyle server-renders the active site theme
73
- once for the whole app — the admin layout re-emits its own scope
74
- at higher specificity, so `siteSettings.adminTheme` opts out. */}
70
+ {/* Applies the persisted light/dark class before first paint (no
71
+ flash). Site theming now renders in `(frontend)/(site)/layout.tsx`
72
+ — the admin layout re-emits its own scope for `/admin`. */}
75
73
  <ThemeScript />
76
- <ThemeStyle />
77
74
  </head>
78
75
  <body>
79
76
  <ServerProviders>
@@ -0,0 +1,13 @@
1
+ import { createVexRobots } from "@vexcms/next/seo"
2
+
3
+ import { env } from "~/env.mjs"
4
+
5
+ /**
6
+ * Generates `/robots.txt`. Allows all crawlers on the public site and points
7
+ * them at the generated sitemap; disallows the authenticated `/admin` tree
8
+ * and its `/api` routes.
9
+ */
10
+ export default createVexRobots({
11
+ siteUrl: env.NEXT_PUBLIC_SITE_URL,
12
+ disallow: ["/admin", "/api"],
13
+ })
@@ -0,0 +1,18 @@
1
+ import { api } from "@convex/_generated/api"
2
+ import { createVexSitemap } from "@vexcms/next/seo"
3
+
4
+ import { env } from "~/env.mjs"
5
+ import { vex } from "~/lib/vex"
6
+
7
+ /**
8
+ * Generates `/sitemap.xml` from every published `pages` document plus the
9
+ * site root. Degrades to `[]` (no page entries) when Convex is unreachable —
10
+ * the packed-tarball scaffold this template ships into has no live
11
+ * deployment and builds against placeholder env (P-020).
12
+ */
13
+ export default createVexSitemap({
14
+ client: vex,
15
+ query: api.pages.publishedSlugs,
16
+ toUrl: (slug) => (slug === "home" ? env.NEXT_PUBLIC_SITE_URL : `${env.NEXT_PUBLIC_SITE_URL}/${slug}`),
17
+ getSlug: (item) => item.slug,
18
+ })
@@ -1,6 +1,7 @@
1
1
  import { api } from "@convex/_generated/api"
2
2
  import { buildThemeCss, type ThemeScope } from "@vexcms/core"
3
- import { fetchQuery } from "convex/nextjs"
3
+
4
+ import { vex } from "~/lib/vex"
4
5
 
5
6
  /**
6
7
  * Server component that inlines a theme's CSS custom properties.
@@ -37,7 +38,7 @@ export async function ThemeStyle(props: { scope?: ThemeScope }) {
37
38
 
38
39
  let theme: null | Record<string, unknown> = null
39
40
  try {
40
- theme = await fetchQuery(scope === "admin" ? api.theme.getAdmin : api.theme.getActive)
41
+ theme = await vex.query(scope === "admin" ? api.theme.getAdmin : api.theme.getActive)
41
42
  } catch {
42
43
  // No deployment reachable at build time — fall back to globals.css.
43
44
  return null
@@ -1,41 +1,61 @@
1
1
  import type { Metadata } from "next"
2
2
 
3
3
  import { api } from "@convex/_generated/api"
4
- import { fetchQuery } from "convex/nextjs"
4
+ import { vexMetadata } from "@vexcms/next/seo"
5
+
6
+ import { env } from "~/env.mjs"
7
+ import { vex } from "~/lib/vex"
5
8
 
6
9
  const TITLE_SUFFIX = " | Vex CMS"
7
10
 
11
+ /**
12
+ * Returns the first candidate that is a non-blank string.
13
+ *
14
+ * Optional text fields in a vexcms collection seed as `""` rather than being
15
+ * absent, so `??` chains cannot express "fall back to the site default" — an
16
+ * empty override would win. Mirrors `apps/www/src/lib/metadata.ts`'s helper
17
+ * of the same name.
18
+ */
19
+ function firstNonBlank(...candidates: unknown[]): string | undefined {
20
+ for (const candidate of candidates) {
21
+ if (typeof candidate === "string" && candidate.trim().length > 0) {
22
+ return candidate
23
+ }
24
+ }
25
+ return undefined
26
+ }
27
+
8
28
  /**
9
29
  * Generate Next.js Metadata for a page.
10
30
  *
11
31
  * Fetches site settings and, when a slug is given, the matching page
12
32
  * document, then merges them — page-level `metaTitle`/`metaDescription`/
13
- * `ogImage` win over the site's defaults from `siteSettings`.
33
+ * `ogImage` win over the site's defaults from `siteSettings` — and delegates
34
+ * the `Metadata` shape (title, description, `metadataBase`, canonical,
35
+ * conditional OG image) to `vexMetadata` (`@vexcms/next/seo`).
14
36
  *
15
37
  * @param props.slug - Optional page slug to fetch per-page SEO overrides
16
38
  */
17
39
  export async function generatePageMetadata(props: { slug?: string }): Promise<Metadata> {
18
40
  try {
19
- const settings = (await fetchQuery(api.siteSettings.get)) as null | Record<string, unknown>
41
+ const settings = (await vex.query(api.siteSettings.get)) as null | Record<string, unknown>
20
42
  if (!settings) {
21
43
  return { title: "Untitled" }
22
44
  }
23
45
 
24
46
  let pageData: Record<string, unknown> | undefined
25
47
  if (props.slug) {
26
- const pages = (await fetchQuery(api.pages.getBySlug, { slug: props.slug })) as
48
+ const pages = (await vex.query(api.pages.getBySlug, { slug: props.slug })) as
27
49
  | Record<string, unknown>[]
28
50
  | undefined
29
51
  pageData = pages?.[0]
30
52
  }
31
53
 
32
- const pageTitle = (pageData?.metaTitle as string | undefined) ?? (pageData?.title as string | undefined)
33
- const siteName = settings.name as string | undefined
34
- const title = (pageTitle ?? (settings.metaTitle as string | undefined) ?? siteName ?? "Untitled") + TITLE_SUFFIX
35
- const description =
36
- (pageData?.metaDescription as string | undefined) ??
37
- (settings.metaDescription as string | undefined) ??
38
- (settings.description as string | undefined)
54
+ const pageTitle = firstNonBlank(pageData?.metaTitle, pageData?.title)
55
+ const siteName = firstNonBlank(settings.name)
56
+ const title =
57
+ (firstNonBlank(pageTitle, settings.metaTitle, siteName) ?? "Untitled") + TITLE_SUFFIX
58
+ const description = firstNonBlank(pageData?.metaDescription, settings.metaDescription, settings.description)
39
59
 
40
60
  // `upload()` fields always store an array of media ids — the first entry
41
61
  // is the selection. Page-level ogImage wins over the site default.
@@ -44,13 +64,16 @@ export async function generatePageMetadata(props: { slug?: string }): Promise<Me
44
64
  const ogImageId = pageOgImageId ?? siteOgImageId
45
65
  const ogImageUrl = ogImageId ? await resolveMediaUrl(ogImageId) : undefined
46
66
 
47
- const twitterHandle = settings.twitterHandle as string | undefined
67
+ const twitterHandle = firstNonBlank(settings.twitterHandle)
68
+ const canonicalPath = props.slug && props.slug !== "home" ? `/${props.slug}` : "/"
48
69
 
49
- const metadata: Metadata = { title, description }
50
-
51
- if (ogImageUrl) {
52
- metadata.openGraph = { title, description, images: [{ url: ogImageUrl }] }
53
- }
70
+ const metadata = vexMetadata({
71
+ title,
72
+ description,
73
+ siteUrl: env.NEXT_PUBLIC_SITE_URL,
74
+ path: canonicalPath,
75
+ imageUrl: ogImageUrl,
76
+ })
54
77
 
55
78
  if (twitterHandle) {
56
79
  metadata.twitter = { card: "summary_large_image", site: twitterHandle }
@@ -70,10 +93,10 @@ export async function generatePageMetadata(props: { slug?: string }): Promise<Me
70
93
  */
71
94
  async function resolveMediaUrl(mediaId: string): Promise<string | undefined> {
72
95
  try {
73
- const result = (await fetchQuery(api.vex.media.getUrl, {
96
+ const result = (await vex.query(api.vex.media.getUrl, {
74
97
  adapter: "convex",
75
98
  mediaId,
76
- })) as { error?: string; url?: string; }
99
+ })) as { error?: string; url?: string }
77
100
  return result.url
78
101
  } catch {
79
102
  return undefined
@@ -0,0 +1,18 @@
1
+ import { createVexServerClient } from "@vexcms/next/cache"
2
+
3
+ import { env } from "~/env.mjs"
4
+
5
+ /**
6
+ * The single shared Convex read client for every server component and route
7
+ * in this project.
8
+ *
9
+ * `createVexServerClient` (`@vexcms/next/cache`) wraps its `query` method in
10
+ * a `React.cache` created fresh on every call — so two files that each call
11
+ * `createVexServerClient()` themselves get two independent caches and never
12
+ * share a round trip, no matter how identical their reads are. Importing
13
+ * this one instance everywhere is what lets `generatePageMetadata`
14
+ * (`~/lib/metadata`) and the page calling it collapse their identical
15
+ * `pages.getBySlug` read into a single Convex call within one request,
16
+ * instead of two.
17
+ */
18
+ export const vex = createVexServerClient({ url: env.NEXT_PUBLIC_CONVEX_URL })
@@ -29,6 +29,14 @@ const vexConfig = defineConfig({
29
29
  },
30
30
  collections: [users, pages, headers, footers, themes],
31
31
  globals: [siteSettings],
32
+ routes: {
33
+ map: ({ collection, doc }) => {
34
+ if (collection !== pages.slug) return []
35
+ const { slug } = doc
36
+ if (typeof slug !== "string") return []
37
+ return [slug === "home" ? "/" : `/${slug}`]
38
+ },
39
+ },
32
40
  })
33
41
 
34
42
  export default vexConfig
@@ -202,6 +202,7 @@ export interface PagesDocument extends VexDocument {
202
202
  */
203
203
  metaDescription?: string
204
204
  ogImage?: Id<"images">[]
205
+ updatedAt?: number
205
206
  }
206
207
  export type HeaderBlock = {
207
208
  blockType: "header"
@@ -225,6 +226,7 @@ export interface HeadersDocument extends VexDocument {
225
226
  _id: Id<"headers">
226
227
  name: string
227
228
  content?: HeaderBlock[]
229
+ updatedAt?: number
228
230
  }
229
231
  export type FooterBlock = {
230
232
  blockType: "footer"
@@ -251,6 +253,7 @@ export interface FootersDocument extends VexDocument {
251
253
  _id: Id<"footers">
252
254
  name: string
253
255
  content?: FooterBlock[]
256
+ updatedAt?: number
254
257
  }
255
258
 
256
259
  export interface Theme extends VexDocument {
@@ -343,6 +346,7 @@ export interface Theme extends VexDocument {
343
346
  sidebarBorder?: string
344
347
  sidebarRing?: string
345
348
  }
349
+ updatedAt?: number
346
350
  }
347
351
 
348
352
  export interface SessionDocument extends VexDocument {
@@ -426,6 +430,7 @@ export interface Image extends VexDocument {
426
430
  src: string
427
431
  width?: number
428
432
  height?: number
433
+ updatedAt?: number
429
434
  }
430
435
 
431
436
  export interface SiteSettingsGlobal extends VexDocumentGlobal<"siteSettings"> {
@@ -549,18 +554,22 @@ declare module "@vexcms/core" {
549
554
  text: "title" | "slug" | "metaTitle" | "metaDescription"
550
555
  blocks: "blocks"
551
556
  upload: "ogImage"
557
+ number: "updatedAt"
552
558
  }
553
559
  headers: {
554
560
  text: "name"
555
561
  blocks: "content"
562
+ number: "updatedAt"
556
563
  }
557
564
  footers: {
558
565
  text: "name"
559
566
  blocks: "content"
567
+ number: "updatedAt"
560
568
  }
561
569
  themes: {
562
570
  text: "name" | "fontFamily" | "radius"
563
571
  group: "light" | "dark"
572
+ number: "updatedAt"
564
573
  }
565
574
  session: {
566
575
  date: "expiresAt" | "createdAt" | "updatedAt"
@@ -608,7 +617,7 @@ declare module "@vexcms/core" {
608
617
  }
609
618
  images: {
610
619
  text: "filename" | "alt" | "mimeType" | "storageId" | "src"
611
- number: "size" | "width" | "height"
620
+ number: "size" | "width" | "height" | "updatedAt"
612
621
  checkbox: "deleted"
613
622
  }
614
623
  }
@@ -12,7 +12,7 @@ import config from "~/vex.config"
12
12
  *
13
13
  * Mirrors `apps/test/src/vexcms/api.ts`'s `vexServerApi` binding.
14
14
  */
15
- export const { get, find, search, create, remove, update, globals } = vexServerApi<DataModel>({
15
+ export const { get, find, search, create, remove, update, globals, publishedSlugs } = vexServerApi<DataModel>({
16
16
  config,
17
17
  getAuth: createGetAuth({
18
18
  orgCollectionSlug: TABLE_SLUG_ORGANIZATIONS,