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 +1 -1
- package/templates/base-nextjs/convex/vex.schema.ts +1 -0
- package/templates/base-nextjs/package.json +18 -6
- package/templates/base-nextjs/src/app/(vexcms)/admin/layout.tsx +8 -5
- package/templates/base-nextjs/src/app/api/vex/revalidate/route.ts +19 -0
- package/templates/base-nextjs/src/components/providers/server.tsx +6 -11
- package/templates/base-nextjs/src/vex.types.ts +2 -1
- package/templates/marketing-site/convex/pages.ts +20 -1
- package/templates/marketing-site/convex/vex.schema.ts +5 -0
- package/templates/marketing-site/src/app/(frontend)/(site)/[slug]/page.tsx +34 -3
- package/templates/marketing-site/src/app/(frontend)/(site)/layout.tsx +5 -3
- package/templates/marketing-site/src/app/(frontend)/(site)/page.tsx +7 -2
- package/templates/marketing-site/src/app/(vexcms)/admin/layout.tsx +10 -7
- package/templates/marketing-site/src/app/layout.tsx +3 -6
- package/templates/marketing-site/src/app/robots.ts +13 -0
- package/templates/marketing-site/src/app/sitemap.ts +18 -0
- package/templates/marketing-site/src/components/ThemeStyle.tsx +3 -2
- package/templates/marketing-site/src/lib/metadata.ts +42 -19
- package/templates/marketing-site/src/lib/vex.ts +18 -0
- package/templates/marketing-site/src/vex.config.ts +8 -0
- package/templates/marketing-site/src/vex.types.ts +10 -1
- package/templates/marketing-site/src/vexcms/api.ts +1 -1
package/package.json
CHANGED
|
@@ -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.
|
|
28
|
-
"@vexcms/core": "~0.1.0-alpha.
|
|
29
|
-
"@vexcms/file-storage-convex": "~0.1.0-alpha.
|
|
30
|
-
"@vexcms/next": "~0.1.0-alpha.
|
|
31
|
-
"@vexcms/react": "~0.1.0-alpha.
|
|
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.
|
|
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
|
-
<
|
|
14
|
-
<
|
|
15
|
-
{
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
<
|
|
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 {
|
|
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
|
|
48
|
+
initialData = await vex.query(api.pages.getBySlug, { slug: normalized })
|
|
22
49
|
} catch {
|
|
23
|
-
|
|
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
|
-
|
|
26
|
-
|
|
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
|
|
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
|
-
<
|
|
25
|
-
<
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
{
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
{/*
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
33
|
-
const siteName = settings.name
|
|
34
|
-
const title =
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
67
|
+
const twitterHandle = firstNonBlank(settings.twitterHandle)
|
|
68
|
+
const canonicalPath = props.slug && props.slug !== "home" ? `/${props.slug}` : "/"
|
|
48
69
|
|
|
49
|
-
const metadata
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
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,
|