create-vexcms 0.1.0-alpha.2 → 0.1.0-alpha.21

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 (115) hide show
  1. package/README.md +48 -14
  2. package/dist/index.js +1714 -1504
  3. package/package.json +2 -2
  4. package/templates/base-nextjs/README.md +18 -5
  5. package/templates/base-nextjs/convex/auth/index.ts +2 -2
  6. package/templates/base-nextjs/convex/auth/options.ts +27 -26
  7. package/templates/base-nextjs/convex/auth/schema.ts +51 -0
  8. package/templates/base-nextjs/convex/auth/sessions.ts +3 -3
  9. package/templates/base-nextjs/convex/tsconfig.json +2 -2
  10. package/templates/base-nextjs/convex/vex/firstUser.ts +2 -2
  11. package/templates/base-nextjs/convex/vex/globals.ts +3 -2
  12. package/templates/base-nextjs/convex/vex/media.ts +3 -2
  13. package/templates/base-nextjs/convex/vex.schema.ts +1 -0
  14. package/templates/base-nextjs/convex/vex.ts +17 -4
  15. package/templates/base-nextjs/next.config.ts +52 -6
  16. package/templates/base-nextjs/package.json +18 -7
  17. package/templates/base-nextjs/public/favicons/favicon.ico +0 -0
  18. package/templates/base-nextjs/src/app/(frontend)/@auth/(...)auth/[pathname]/view.tsx +27 -15
  19. package/templates/base-nextjs/src/app/(vexcms)/admin/[[...path]]/page.tsx +2 -2
  20. package/templates/base-nextjs/src/app/(vexcms)/admin/clientProviders.tsx +9 -14
  21. package/templates/base-nextjs/src/app/(vexcms)/admin/layout.tsx +8 -6
  22. package/templates/base-nextjs/src/app/api/vex/revalidate/route.ts +19 -0
  23. package/templates/base-nextjs/src/app/globals.css +10 -9
  24. package/templates/base-nextjs/src/app/layout.tsx +6 -0
  25. package/templates/base-nextjs/src/auth/access.ts +14 -2
  26. package/templates/base-nextjs/src/auth/serverUtils.ts +20 -6
  27. package/templates/base-nextjs/src/components/WelcomePage.tsx +54 -43
  28. package/templates/base-nextjs/src/components/component-example.tsx +1 -1
  29. package/templates/base-nextjs/src/components/providers/server.tsx +16 -8
  30. package/templates/base-nextjs/src/vex.config.server.ts +30 -0
  31. package/templates/base-nextjs/src/vex.config.ts +14 -11
  32. package/templates/base-nextjs/src/vex.types.ts +2 -1
  33. package/templates/base-nextjs/src/vexcms/collections/images.ts +1 -1
  34. package/templates/base-nextjs/src/vexcms/collections/users.ts +3 -1
  35. package/templates/marketing-site/convex/pages.ts +20 -1
  36. package/templates/marketing-site/convex/schema.ts +4 -4
  37. package/templates/marketing-site/convex/seed.ts +515 -70
  38. package/templates/marketing-site/convex/siteSettings.ts +1 -1
  39. package/templates/marketing-site/convex/theme.ts +3 -3
  40. package/templates/marketing-site/convex/vex.schema.ts +75 -38
  41. package/templates/marketing-site/public/favicon.svg +14 -0
  42. package/templates/marketing-site/public/favicons/apple-touch-icon.png +0 -0
  43. package/templates/marketing-site/public/favicons/favicon.ico +0 -0
  44. package/templates/marketing-site/src/app/(frontend)/(site)/PageContent.tsx +64 -0
  45. package/templates/marketing-site/src/app/(frontend)/(site)/SiteLivePreviewProvider.tsx +31 -0
  46. package/templates/marketing-site/src/app/(frontend)/(site)/[slug]/page.tsx +63 -0
  47. package/templates/marketing-site/src/app/(frontend)/(site)/layout.tsx +50 -0
  48. package/templates/marketing-site/src/app/(frontend)/(site)/page.tsx +33 -0
  49. package/templates/marketing-site/src/app/(frontend)/layout.tsx +10 -24
  50. package/templates/marketing-site/src/app/(vexcms)/admin/layout.tsx +32 -0
  51. package/templates/marketing-site/src/app/globals.css +413 -0
  52. package/templates/marketing-site/src/app/layout.tsx +85 -0
  53. package/templates/marketing-site/src/app/robots.ts +13 -0
  54. package/templates/marketing-site/src/app/sitemap.ts +18 -0
  55. package/templates/marketing-site/src/components/BrandMark.tsx +27 -0
  56. package/templates/marketing-site/src/components/CodeHighlightContext.tsx +76 -0
  57. package/templates/marketing-site/src/components/CodePane.tsx +98 -0
  58. package/templates/marketing-site/src/components/Container.tsx +25 -0
  59. package/templates/marketing-site/src/components/CopyButton.tsx +80 -0
  60. package/templates/marketing-site/src/components/FirstAdminBootstrap.tsx +54 -0
  61. package/templates/marketing-site/src/components/InstallCommand.tsx +29 -0
  62. package/templates/marketing-site/src/components/MediaImage.tsx +59 -0
  63. package/templates/marketing-site/src/components/SectionHeader.tsx +51 -0
  64. package/templates/marketing-site/src/components/SiteFooter.tsx +8 -6
  65. package/templates/marketing-site/src/components/SiteHeader.tsx +8 -6
  66. package/templates/marketing-site/src/components/ThemeLive.tsx +1 -2
  67. package/templates/marketing-site/src/components/ThemeStyle.tsx +3 -3
  68. package/templates/marketing-site/src/components/motion-primitives/animated-group.tsx +44 -120
  69. package/templates/marketing-site/src/components/motion-primitives/reveal.module.css +32 -0
  70. package/templates/marketing-site/src/components/motion-primitives/text-effect.tsx +47 -266
  71. package/templates/marketing-site/src/lib/codeHighlightKey.ts +27 -0
  72. package/templates/marketing-site/src/lib/highlight.ts +134 -0
  73. package/templates/marketing-site/src/lib/highlightClient.ts +37 -0
  74. package/templates/marketing-site/src/lib/metadata.ts +61 -33
  75. package/templates/marketing-site/src/lib/resolvePagePath.ts +8 -0
  76. package/templates/marketing-site/src/lib/vex.ts +18 -0
  77. package/templates/marketing-site/src/proxy.ts +179 -0
  78. package/templates/marketing-site/src/vex.config.server.ts +27 -0
  79. package/templates/marketing-site/src/vex.config.ts +27 -9
  80. package/templates/marketing-site/src/vex.types.ts +102 -76
  81. package/templates/marketing-site/src/vexcms/api.ts +2 -2
  82. package/templates/marketing-site/src/vexcms/blocks/CTA/config.ts +3 -2
  83. package/templates/marketing-site/src/vexcms/blocks/CTA/index.tsx +40 -24
  84. package/templates/marketing-site/src/vexcms/blocks/CodeShowcase/config.ts +62 -0
  85. package/templates/marketing-site/src/vexcms/blocks/CodeShowcase/index.tsx +106 -0
  86. package/templates/marketing-site/src/vexcms/blocks/FAQ/config.ts +4 -3
  87. package/templates/marketing-site/src/vexcms/blocks/FAQ/index.tsx +63 -50
  88. package/templates/marketing-site/src/vexcms/blocks/Features/config.ts +1 -0
  89. package/templates/marketing-site/src/vexcms/blocks/Features/index.tsx +43 -58
  90. package/templates/marketing-site/src/vexcms/blocks/Footer/config.ts +6 -3
  91. package/templates/marketing-site/src/vexcms/blocks/Footer/index.tsx +90 -53
  92. package/templates/marketing-site/src/vexcms/blocks/Header/config.ts +10 -3
  93. package/templates/marketing-site/src/vexcms/blocks/Header/index.tsx +205 -138
  94. package/templates/marketing-site/src/vexcms/blocks/Hero/config.ts +27 -12
  95. package/templates/marketing-site/src/vexcms/blocks/Hero/index.tsx +174 -119
  96. package/templates/marketing-site/src/vexcms/blocks/HowItWorks/config.ts +2 -1
  97. package/templates/marketing-site/src/vexcms/blocks/HowItWorks/index.tsx +59 -51
  98. package/templates/marketing-site/src/vexcms/blocks/Roadmap/config.ts +26 -5
  99. package/templates/marketing-site/src/vexcms/blocks/Roadmap/index.tsx +256 -73
  100. package/templates/marketing-site/src/vexcms/blocks/Split/config.ts +62 -0
  101. package/templates/marketing-site/src/vexcms/blocks/Split/index.tsx +186 -0
  102. package/templates/marketing-site/src/vexcms/blocks/Stats/config.ts +35 -0
  103. package/templates/marketing-site/src/vexcms/blocks/Stats/index.tsx +77 -0
  104. package/templates/marketing-site/src/vexcms/blocks/config.ts +27 -11
  105. package/templates/marketing-site/src/vexcms/blocks/constants.ts +3 -0
  106. package/templates/marketing-site/src/vexcms/blocks/index.ts +9 -0
  107. package/templates/marketing-site/src/vexcms/collections/footers.ts +1 -0
  108. package/templates/marketing-site/src/vexcms/collections/headers.ts +1 -0
  109. package/templates/marketing-site/src/vexcms/collections/pages.ts +23 -1
  110. package/templates/marketing-site/src/vexcms/collections/themeColors.ts +14 -14
  111. package/templates/marketing-site/src/vexcms/collections/users.ts +20 -0
  112. package/dist/index.mjs +0 -30976
  113. package/templates/marketing-site/src/app/(frontend)/PageContent.tsx +0 -53
  114. package/templates/marketing-site/src/app/(frontend)/[slug]/page.tsx +0 -25
  115. package/templates/marketing-site/src/app/(frontend)/page.tsx +0 -21
@@ -1,27 +1,28 @@
1
1
  import type { Metadata } from "next"
2
2
 
3
- import { fetchQuery } from "convex/nextjs"
4
-
5
3
  import { api } from "@convex/_generated/api"
6
- import { anyApi } from "convex/server"
4
+ import { vexMetadata } from "@vexcms/next/seo"
5
+
6
+ import { env } from "~/env.mjs"
7
+ import { vex } from "~/lib/vex"
7
8
 
8
9
  const TITLE_SUFFIX = " | Vex CMS"
9
10
 
10
11
  /**
11
- * Resolves an `upload()` field's stored media id to a public URL via the
12
- * Convex file storage adapter. Returns `undefined` when the media document
13
- * no longer exists or the deployment is unreachable.
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.
14
18
  */
15
- async function resolveMediaUrl(mediaId: string): Promise<string | undefined> {
16
- try {
17
- const result = (await fetchQuery(anyApi.vex.media.getUrl, {
18
- adapter: "convex",
19
- mediaId,
20
- })) as { url?: string; error?: string }
21
- return result.url
22
- } catch {
23
- return undefined
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
24
  }
25
+ return undefined
25
26
  }
26
27
 
27
28
  /**
@@ -29,32 +30,32 @@ async function resolveMediaUrl(mediaId: string): Promise<string | undefined> {
29
30
  *
30
31
  * Fetches site settings and, when a slug is given, the matching page
31
32
  * document, then merges them — page-level `metaTitle`/`metaDescription`/
32
- * `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`).
33
36
  *
34
37
  * @param props.slug - Optional page slug to fetch per-page SEO overrides
35
38
  */
36
39
  export async function generatePageMetadata(props: { slug?: string }): Promise<Metadata> {
37
40
  try {
38
- const settings = (await fetchQuery(api.siteSettings.get)) as Record<string, unknown> | null
41
+ const settings = (await vex.query(api.siteSettings.get)) as null | Record<string, unknown>
39
42
  if (!settings) {
40
43
  return { title: "Untitled" }
41
44
  }
42
45
 
43
46
  let pageData: Record<string, unknown> | undefined
44
47
  if (props.slug) {
45
- const pages = (await fetchQuery(api.pages.getBySlug, { slug: props.slug })) as
48
+ const pages = (await vex.query(api.pages.getBySlug, { slug: props.slug })) as
46
49
  | Record<string, unknown>[]
47
50
  | undefined
48
51
  pageData = pages?.[0]
49
52
  }
50
53
 
51
- const pageTitle = (pageData?.metaTitle as string | undefined) ?? (pageData?.title as string | undefined)
52
- const siteName = settings.name as string | undefined
53
- const title = (pageTitle ?? (settings.metaTitle as string | undefined) ?? siteName ?? "Untitled") + TITLE_SUFFIX
54
- const description =
55
- (pageData?.metaDescription as string | undefined) ??
56
- (settings.metaDescription as string | undefined) ??
57
- (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)
58
59
 
59
60
  // `upload()` fields always store an array of media ids — the first entry
60
61
  // is the selection. Page-level ogImage wins over the site default.
@@ -63,16 +64,26 @@ export async function generatePageMetadata(props: { slug?: string }): Promise<Me
63
64
  const ogImageId = pageOgImageId ?? siteOgImageId
64
65
  const ogImageUrl = ogImageId ? await resolveMediaUrl(ogImageId) : undefined
65
66
 
66
- const twitterHandle = settings.twitterHandle as string | undefined
67
+ const twitterHandle = firstNonBlank(settings.twitterHandle)
68
+ const canonicalPath = props.slug && props.slug !== "home" ? `/${props.slug}` : "/"
67
69
 
68
- const metadata: Metadata = { title, description }
69
-
70
- if (ogImageUrl) {
71
- metadata.openGraph = { title, description, images: [{ url: ogImageUrl }] }
72
- }
70
+ const metadata = vexMetadata({
71
+ title,
72
+ description,
73
+ siteUrl: env.NEXT_PUBLIC_SITE_URL,
74
+ path: canonicalPath,
75
+ imageUrl: ogImageUrl,
76
+ })
73
77
 
74
- if (twitterHandle) {
75
- metadata.twitter = { card: "summary_large_image", site: twitterHandle }
78
+ // Discord, Slack and X pick the large-image layout from `twitter:card`, not
79
+ // from `og:image` alone — and Next auto-fills `card: "summary"` (a small
80
+ // thumbnail) whenever `twitter` is omitted. So the card type must be emitted
81
+ // whenever an image resolved, independently of whether a handle exists.
82
+ if (ogImageUrl || twitterHandle) {
83
+ metadata.twitter = {
84
+ card: ogImageUrl ? "summary_large_image" : "summary",
85
+ ...(twitterHandle ? { site: twitterHandle } : {}),
86
+ }
76
87
  }
77
88
 
78
89
  return metadata
@@ -81,3 +92,20 @@ export async function generatePageMetadata(props: { slug?: string }): Promise<Me
81
92
  return { title: "Vex CMS" }
82
93
  }
83
94
  }
95
+
96
+ /**
97
+ * Resolves an `upload()` field's stored media id to a public URL via the
98
+ * Convex file storage adapter. Returns `undefined` when the media document
99
+ * no longer exists or the deployment is unreachable.
100
+ */
101
+ async function resolveMediaUrl(mediaId: string): Promise<string | undefined> {
102
+ try {
103
+ const result = (await vex.query(api.vex.media.getUrl, {
104
+ adapter: "convex",
105
+ mediaId,
106
+ })) as { error?: string; url?: string }
107
+ return result.url
108
+ } catch {
109
+ return undefined
110
+ }
111
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Maps a `pages` document's slug to its public path. Shared by `vex.config.ts`'s
3
+ * `routes.map` and `pages.ts`'s `admin.livePreview.url`.
4
+ */
5
+ export function resolvePagePath(slug: string | undefined): string | undefined {
6
+ if (!slug) return undefined
7
+ return slug === "home" ? "/" : `/${slug}`
8
+ }
@@ -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 })
@@ -0,0 +1,179 @@
1
+ import { api } from "@convex/_generated/api";
2
+ import {
3
+ LIVE_PREVIEW_COOKIE,
4
+ LIVE_PREVIEW_COOKIE_MAX_AGE_SECONDS,
5
+ LIVE_PREVIEW_QUERY_PARAM,
6
+ } from "@vexcms/core";
7
+ import { fetchQuery } from "convex/nextjs";
8
+ import { cookies } from "next/headers";
9
+ import { type NextRequest, NextResponse } from "next/server";
10
+
11
+ const SESSION_COOKIES = {
12
+ https: "__Secure-better-auth.session_token",
13
+ http: "better-auth.session_token",
14
+ } as const;
15
+
16
+ /**
17
+ * Paths the proxy must never guard, because they are where it sends people.
18
+ * Guarding them turns a Convex outage into an infinite redirect loop.
19
+ */
20
+ const ALWAYS_ALLOWED: Record<string, true> = { "/": true, "/unauthorized": true };
21
+
22
+ /**
23
+ * Session gate for the private surfaces named in `config.matcher`.
24
+ *
25
+ * **This is the marketing-site variant.** The base template guards every route
26
+ * except a small allowlist, which is right for a private app and wrong here:
27
+ * a marketing site's pages are public content served from the CMS, so a
28
+ * fail-closed gate over `/(.*)` sends anonymous visitors from `/features` and
29
+ * `/roadmap` straight to sign-in. The matcher below inverts that — only the
30
+ * admin panel is gated, and the public site is public.
31
+ *
32
+ * The admin panel is not defended by this gate alone: every collection query
33
+ * runs the RBAC rules from `vex.config.ts`, and unauthenticated callers
34
+ * resolve through `anonRole`. The proxy is defence in depth and a redirect
35
+ * convenience, not the access control.
36
+ *
37
+ * Fails **closed**: if the session cannot be verified — no cookie, no matching
38
+ * session, or the Convex lookup throwing — the request is redirected rather than
39
+ * served. A verification error is not evidence of a valid session, and serving
40
+ * the page anyway would drop authentication for every guarded route the moment
41
+ * Convex became unreachable.
42
+ *
43
+ * Two distinct outcomes, deliberately:
44
+ * - **No/invalid session** → `/auth/sign-in`, clearing the stale cookie so the
45
+ * presence check cannot lie on the next request.
46
+ * - **Verification failed** (Convex unreachable, timeout) → `/unauthorized`,
47
+ * leaving cookies intact: the session may well be fine, so signing the user
48
+ * out and discarding it would be wrong.
49
+ *
50
+ * @param request - The incoming request.
51
+ * @returns `NextResponse.next()` when the session is verified, otherwise a
52
+ * redirect to sign-in or to the unauthorized page.
53
+ */
54
+ export async function proxy(request: NextRequest) {
55
+ if (ALWAYS_ALLOWED[request.nextUrl.pathname]) {
56
+ return NextResponse.next();
57
+ }
58
+
59
+ if (request.nextUrl.pathname.startsWith("/admin")) {
60
+ return guardAdminRequest(request);
61
+ }
62
+
63
+ return grantPreviewIfRequested(request);
64
+ }
65
+
66
+ /** Session gate for the admin panel — fails closed, per the docstring above. */
67
+ async function guardAdminRequest(request: NextRequest) {
68
+ const sessionStatus = await resolveSessionStatus();
69
+
70
+ if (sessionStatus === "verification-failed") {
71
+ return redirectToUnauthorized(request);
72
+ }
73
+ if (sessionStatus === "unauthenticated") {
74
+ return redirectToSignIn(request);
75
+ }
76
+ return NextResponse.next();
77
+ }
78
+
79
+ /**
80
+ * Grants live-preview mode to a public-route request carrying `?vexLivePreview=1`
81
+ * and a verified admin session, by setting the `vex-live-preview` marker
82
+ * cookie that `LivePreviewProvider` gates on client-side. Fails **open** — an
83
+ * unauthenticated or unverifiable session here renders the page normally
84
+ * rather than redirecting.
85
+ *
86
+ * A cookie rather than a forwarded request header: a header would have to be
87
+ * read with `next/headers` in the site layout, which opts every public route
88
+ * out of static prerendering.
89
+ */
90
+ async function grantPreviewIfRequested(request: NextRequest) {
91
+ if (request.nextUrl.searchParams.get(LIVE_PREVIEW_QUERY_PARAM) !== "1") {
92
+ return NextResponse.next();
93
+ }
94
+
95
+ const sessionStatus = await resolveSessionStatus();
96
+ if (sessionStatus !== "authenticated") {
97
+ return NextResponse.next();
98
+ }
99
+
100
+ const response = NextResponse.next();
101
+ response.cookies.set(LIVE_PREVIEW_COOKIE, "1", {
102
+ path: "/",
103
+ maxAge: LIVE_PREVIEW_COOKIE_MAX_AGE_SECONDS,
104
+ sameSite: "lax",
105
+ // Read by `LivePreviewProvider` in the browser — the whole point of the
106
+ // cookie. It carries no secret, only the fact that a session verified.
107
+ httpOnly: false,
108
+ });
109
+ return response;
110
+ }
111
+
112
+ /** Reads and verifies the Better Auth session cookie — shared by both branches above. */
113
+ async function resolveSessionStatus(): Promise<
114
+ "authenticated" | "unauthenticated" | "verification-failed"
115
+ > {
116
+ const cookieStore = await cookies();
117
+ const sessionToken =
118
+ cookieStore.get(SESSION_COOKIES.https)?.value ?? cookieStore.get(SESSION_COOKIES.http)?.value;
119
+ if (!sessionToken) return "unauthenticated";
120
+
121
+ try {
122
+ const session = await fetchQuery(api.auth.sessions.getSessionWithUser, {
123
+ sessionToken: extractToken(sessionToken),
124
+ });
125
+ return session?.user ? "authenticated" : "unauthenticated";
126
+ } catch {
127
+ // Fail closed for the admin branch; grantPreviewIfRequested treats this as
128
+ // "no preview" rather than an error.
129
+ return "verification-failed";
130
+ }
131
+ }
132
+
133
+ /** Extract the raw token — better-auth stores "<raw_token>.<hmac_signature>" */
134
+ function extractToken(cookieValue: string) {
135
+ return cookieValue.includes(".") ? cookieValue.split(".")[0] : cookieValue;
136
+ }
137
+
138
+ /**
139
+ * Redirect for a caller with no usable session. Clears the session cookies so
140
+ * the presence check cannot lie on the next request.
141
+ *
142
+ * @param request - The request being rejected.
143
+ * @returns A redirect to sign-in preserving the intended destination.
144
+ */
145
+ function redirectToSignIn(request: NextRequest) {
146
+ const signInUrl = new URL("/auth/sign-in", request.url);
147
+ // Preserve where the user was headed (path + query)
148
+ signInUrl.searchParams.set("redirectTo", request.nextUrl.pathname + request.nextUrl.search);
149
+
150
+ const response = NextResponse.redirect(signInUrl);
151
+ for (const cookie of Object.values(SESSION_COOKIES)) {
152
+ // Clear the stale cookie so the presence-check never lies again
153
+ response.cookies.delete(cookie);
154
+ }
155
+ return response;
156
+ }
157
+
158
+ /**
159
+ * Redirect for a caller whose session could not be *verified* — as opposed to
160
+ * one who has none.
161
+ *
162
+ * Cookies are deliberately left intact: the session is probably valid and the
163
+ * verification path (Convex) is simply unavailable, so discarding it would sign
164
+ * out every user during an outage. `/unauthorized` is exempt from this proxy, so
165
+ * this cannot loop.
166
+ *
167
+ * @param request - The request that could not be verified.
168
+ * @returns A redirect to the unauthorized page.
169
+ */
170
+ function redirectToUnauthorized(request: NextRequest) {
171
+ return NextResponse.redirect(new URL("/unauthorized", request.url));
172
+ }
173
+
174
+ export const config = {
175
+ // Allowlist, not denylist. The base template denies everything except a few
176
+ // paths; here the public site IS the product, so the gate names the private
177
+ // surfaces explicitly. Anything added under `(vexcms)` must be added here.
178
+ matcher: ["/admin/:path*", "/((?!_next/static|_next/image|favicon.ico).*)"],
179
+ };
@@ -0,0 +1,27 @@
1
+ import { authOptions } from "@convex/auth/options"
2
+ import { betterAuthAdapter } from "@vexcms/better-auth"
3
+ import { defineServerConfig } from "@vexcms/core"
4
+ import { convexFileStorage } from "@vexcms/file-storage-convex"
5
+
6
+ import vexConfig from "./vex.config"
7
+
8
+ /**
9
+ * VexCMS server-only configuration for the marketing site.
10
+ *
11
+ * Layers the Better Auth adapter and Convex file storage onto the
12
+ * client-safe `vexConfig`. You write this file once at scaffold time and
13
+ * rarely revisit it — day-to-day settings live in `vex.config.ts`.
14
+ *
15
+ * @see ./vex.config for the client-safe half this layers onto
16
+ */
17
+ const config = defineServerConfig({
18
+ config: vexConfig,
19
+ server: {
20
+ auth: { adapter: betterAuthAdapter({ config: authOptions }) },
21
+ storage: {
22
+ adapters: [convexFileStorage()],
23
+ },
24
+ },
25
+ })
26
+
27
+ export default config
@@ -1,35 +1,53 @@
1
- import { betterAuthAdapter } from "@vexcms/better-auth"
1
+ import { betterAuthCollections } from "@vexcms/better-auth/client"
2
2
  import { defineConfig } from "@vexcms/core"
3
- import { convexFileStorage } from "@vexcms/file-storage-convex"
3
+ import { uploadFile } from "@vexcms/file-storage-convex/client"
4
4
 
5
- import { authOptions } from "@convex/auth/options"
5
+ import { authSchema } from "@convex/auth/schema"
6
6
 
7
7
  import { access } from "~/auth/access"
8
+ import { resolvePagePath } from "~/lib/resolvePagePath"
8
9
  import { footers, headers, images, pages, themes, users } from "~/vexcms/collections"
9
10
  import { siteSettings } from "~/vexcms/globals"
10
11
 
11
12
  /**
12
- * VexCMS configuration for the marketing site.
13
+ * VexCMS client-safe configuration for the marketing site.
13
14
  *
14
15
  * Replaces `templates/base-nextjs`'s bare config wholesale (overlay copy is
15
16
  * file-level, not a merge): carries base's `users`/`images` forward unchanged
16
- * and adds the four marketing collections plus `siteSettings`. `vex dev`/
17
- * `vex generate` consume this to produce the Convex schema and TypeScript
18
- * types.
17
+ * and adds the four marketing collections plus `siteSettings`. Imported
18
+ * directly by the browser via `VexConfigProvider`.
19
+ *
20
+ * @see ./vex.config.server for the auth adapter and storage adapter wiring
19
21
  */
20
22
  const vexConfig = defineConfig({
21
23
  access,
24
+ authCollections: betterAuthCollections(authSchema),
22
25
  admin: {
23
26
  sidebar: {
24
27
  side: "right",
25
28
  },
26
29
  },
27
- authAdapter: betterAuthAdapter({ config: authOptions }),
28
30
  storage: {
29
- adapters: [convexFileStorage({ mediaCollections: [images] })],
31
+ clientUploads: { convex: uploadFile },
30
32
  },
31
33
  collections: [users, pages, headers, footers, themes],
34
+ mediaCollections: [images],
32
35
  globals: [siteSettings],
36
+ routes: {
37
+ map: ({ collection, doc }) => {
38
+ if (collection !== pages.slug) return []
39
+ const { slug } = doc
40
+ if (typeof slug !== "string") return []
41
+ const path = resolvePagePath(slug)
42
+ return path === undefined ? [] : [path]
43
+ },
44
+ },
45
+ livePreview: {
46
+ allowedOrigins: [
47
+ "http://localhost:3000",
48
+ ...(process.env.NEXT_PUBLIC_SITE_URL ? [process.env.NEXT_PUBLIC_SITE_URL] : []),
49
+ ],
50
+ },
33
51
  })
34
52
 
35
53
  export default vexConfig