create-vexcms 0.1.0-alpha.20 → 0.1.0-alpha.22

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.20",
3
+ "version": "0.1.0-alpha.22",
4
4
  "description": "Scaffold a new VexCMS project",
5
5
  "keywords": [
6
6
  "cms",
@@ -51,7 +51,7 @@
51
51
  },
52
52
  "scripts": {
53
53
  "build": "tsup src/index.ts --format esm --dts",
54
- "dev": "tsup src/index.ts --format esm --watch",
54
+ "dev": "tsup src/index.ts --format esm --watch src",
55
55
  "test": "vitest run"
56
56
  }
57
57
  }
@@ -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.20",
28
- "@vexcms/core": "~0.1.0-alpha.20",
29
- "@vexcms/file-storage-convex": "~0.1.0-alpha.20",
30
- "@vexcms/next": "~0.1.0-alpha.20",
31
- "@vexcms/react": "~0.1.0-alpha.20",
27
+ "@vexcms/better-auth": "~0.1.0-alpha.22",
28
+ "@vexcms/core": "~0.1.0-alpha.22",
29
+ "@vexcms/file-storage-convex": "~0.1.0-alpha.22",
30
+ "@vexcms/next": "~0.1.0-alpha.22",
31
+ "@vexcms/react": "~0.1.0-alpha.22",
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.20",
55
+ "@vexcms/cli": "~0.1.0-alpha.22",
56
56
  "babel-plugin-react-compiler": "1.0.0",
57
57
  "eslint": "9.39.5",
58
58
  "eslint-config-next": "15.5.9",
@@ -2,15 +2,22 @@
2
2
 
3
3
  import { convexQuery } from "@convex-dev/react-query"
4
4
  import { api } from "@convex/_generated/api"
5
- import { useQuery } from "@tanstack/react-query"
6
- import { RenderBlocks } from "@vexcms/react"
5
+ import { RenderBlocks, useLivePreviewQuery } from "@vexcms/react"
7
6
 
7
+ import type { CodeHighlightMap } from "~/lib/highlight"
8
8
  import type { PagesDocument } from "~/vex.types"
9
9
 
10
+ import { CodeHighlightProvider } from "~/components/CodeHighlightContext"
10
11
  import { WelcomePage } from "~/components/WelcomePage"
11
12
  import { blockComponents } from "~/vexcms/blocks"
12
13
 
13
14
  export interface PageContentProps {
15
+ /**
16
+ * Server-highlighted code panes for `initialData`, keyed by
17
+ * `codeHighlightKey`. Built by `highlightPageBlocks`, because nothing below
18
+ * this client boundary can await shiki.
19
+ */
20
+ codeHighlights?: CodeHighlightMap
14
21
  /** Server-fetched `pages.getBySlug` result, hydrated as the query's initial data. */
15
22
  initialData?: PagesDocument[]
16
23
  /** URL slug to render. Omit (or empty) for the home page. */
@@ -23,17 +30,20 @@ export interface PageContentProps {
23
30
  * exists yet — a fresh scaffold before `pnpm seed` has run (Contract 3).
24
31
  *
25
32
  * `pages.getBySlug` always returns an array (empty when no match — the same
26
- * shape every collection query returns), so this always reads `pages?.[0]`.
33
+ * shape every collection query returns); `useLivePreviewQuery` performs that
34
+ * narrowing itself and overlays any unsaved editor values for this document
35
+ * when the page is being rendered inside a live preview.
27
36
  */
28
- export function PageContent({ slug, initialData }: PageContentProps) {
37
+ export function PageContent({ codeHighlights, slug, initialData }: PageContentProps) {
29
38
  const normalizedSlug = slug && slug.length > 0 ? slug : "home"
30
39
 
31
- const { data: pages, isPending } = useQuery({
32
- ...convexQuery(api.pages.getBySlug, { slug: normalizedSlug }),
33
- initialData,
34
- })
35
-
36
- const page = pages?.[0]
40
+ const { data: page, isPending } = useLivePreviewQuery(
41
+ {
42
+ ...convexQuery(api.pages.getBySlug, { slug: normalizedSlug }),
43
+ initialData,
44
+ },
45
+ "pages",
46
+ )
37
47
 
38
48
  if (isPending && initialData === undefined) {
39
49
  return null
@@ -44,9 +54,11 @@ export function PageContent({ slug, initialData }: PageContentProps) {
44
54
  }
45
55
 
46
56
  return (
47
- <RenderBlocks
48
- blocks={page.blocks}
49
- components={blockComponents}
50
- />
57
+ <CodeHighlightProvider highlights={codeHighlights ?? {}}>
58
+ <RenderBlocks
59
+ blocks={page.blocks}
60
+ components={blockComponents}
61
+ />
62
+ </CodeHighlightProvider>
51
63
  )
52
64
  }
@@ -0,0 +1,31 @@
1
+ "use client"
2
+
3
+ import type { ReactNode } from "react"
4
+
5
+ import { LivePreviewProvider } from "@vexcms/react"
6
+
7
+ import config from "~/vex.config"
8
+
9
+ /**
10
+ * Mounts the live-preview listener for the public site's client tree.
11
+ *
12
+ * A `"use client"` component so `vex.config` is imported and owned on the
13
+ * client — it never crosses the RSC boundary as a prop (ADR-013; the config's
14
+ * `admin.livePreview.url` resolvers and field validators are functions, which
15
+ * React refuses to serialize). Mirrors `admin/clientProviders.tsx`.
16
+ *
17
+ * `LivePreviewProvider` gates itself on `?vexLivePreview=1` plus the
18
+ * session-verified `vex-live-preview` cookie `proxy.ts` sets, so wrapping the
19
+ * layout unconditionally costs a disabled provider and nothing else — and
20
+ * keeps every public route statically prerenderable.
21
+ */
22
+ export function SiteLivePreviewProvider({ children }: { children: ReactNode }) {
23
+ return (
24
+ <LivePreviewProvider
25
+ allowedOrigins={config.livePreview.allowedOrigins}
26
+ collections={config.collections}
27
+ >
28
+ {children}
29
+ </LivePreviewProvider>
30
+ )
31
+ }
@@ -5,6 +5,7 @@ import { notFound } from "next/navigation"
5
5
  import type { PagesDocument } from "~/vex.types"
6
6
 
7
7
  import { generatePageMetadata } from "~/lib/metadata"
8
+ import { highlightPageBlocks } from "~/lib/highlight"
8
9
  import { vex } from "~/lib/vex"
9
10
 
10
11
  import { PageContent } from "../PageContent"
@@ -54,5 +55,9 @@ export default async function PublicPage({ params }: { params: Promise<{ slug: s
54
55
  notFound()
55
56
  }
56
57
 
57
- return <PageContent initialData={initialData} slug={normalized} />
58
+ const codeHighlights = await highlightPageBlocks(initialData?.[0]?.blocks)
59
+
60
+ return (
61
+ <PageContent codeHighlights={codeHighlights} initialData={initialData} slug={normalized} />
62
+ )
58
63
  }
@@ -8,6 +8,7 @@ import { SiteFooter } from "~/components/SiteFooter"
8
8
  import { SiteHeader } from "~/components/SiteHeader"
9
9
  import { ThemeStyle } from "~/components/ThemeStyle"
10
10
  import { vex } from "~/lib/vex"
11
+ import { SiteLivePreviewProvider } from "./SiteLivePreviewProvider"
11
12
 
12
13
  /**
13
14
  * Marketing chrome: header + footer around every site page. Auth routes live
@@ -31,7 +32,7 @@ export default async function SiteLayout({
31
32
  }
32
33
 
33
34
  return (
34
- <>
35
+ <SiteLivePreviewProvider>
35
36
  <ThemeStyle />
36
37
  {/* Sits above the sticky header so it is the first tab stop on every
37
38
  page. Visually hidden until focused. */}
@@ -44,6 +45,6 @@ export default async function SiteLayout({
44
45
  <SiteHeader initialData={headerData} />
45
46
  <main id="main">{children}</main>
46
47
  <SiteFooter initialData={footerData} />
47
- </>
48
+ </SiteLivePreviewProvider>
48
49
  )
49
50
  }
@@ -3,6 +3,7 @@ import { api } from "@convex/_generated/api"
3
3
  import type { PagesDocument } from "~/vex.types"
4
4
 
5
5
  import { generatePageMetadata } from "~/lib/metadata"
6
+ import { highlightPageBlocks } from "~/lib/highlight"
6
7
  import { vex } from "~/lib/vex"
7
8
 
8
9
  import { PageContent } from "./PageContent"
@@ -24,5 +25,9 @@ export default async function HomePage() {
24
25
  // Fall back to client-only fetch
25
26
  }
26
27
 
27
- return <PageContent initialData={initialData} />
28
+ const codeHighlights = await highlightPageBlocks(initialData?.[0]?.blocks)
29
+
30
+ return (
31
+ <PageContent codeHighlights={codeHighlights} initialData={initialData} />
32
+ )
28
33
  }
@@ -372,3 +372,42 @@
372
372
  border-top: 2px dashed var(--border) !important;
373
373
  }
374
374
 
375
+ /* ── Scrollbar ────────────────────────────────────────────────────────────
376
+ Matches the admin panel's `.vex-scroll-area` treatment (@vexcms/react's
377
+ styles.css): transparent track so the scrollbar reads as part of the
378
+ surface, and a permanently visible semi-transparent thumb rather than an
379
+ overlay bar that hides how much content is left. Applied to the document
380
+ scroller here, since the marketing site scrolls the window.
381
+ ────────────────────────────────────────────────────────────────────────── */
382
+ html {
383
+ scrollbar-width: thin;
384
+ scrollbar-color: color-mix(in oklch, var(--foreground) 25%, transparent) transparent;
385
+ }
386
+
387
+ html::-webkit-scrollbar,
388
+ body ::-webkit-scrollbar {
389
+ width: 10px;
390
+ height: 10px;
391
+ }
392
+
393
+ html::-webkit-scrollbar-track,
394
+ html::-webkit-scrollbar-corner,
395
+ body ::-webkit-scrollbar-track,
396
+ body ::-webkit-scrollbar-corner {
397
+ background: transparent;
398
+ }
399
+
400
+ html::-webkit-scrollbar-thumb,
401
+ body ::-webkit-scrollbar-thumb {
402
+ /* Transparent border + `background-clip: content-box` is the only way to
403
+ inset a webkit thumb without painting the track. */
404
+ background-color: color-mix(in oklch, var(--foreground) 25%, transparent);
405
+ background-clip: content-box;
406
+ border: 2px solid transparent;
407
+ border-radius: 9999px;
408
+ }
409
+
410
+ html::-webkit-scrollbar-thumb:hover,
411
+ body ::-webkit-scrollbar-thumb:hover {
412
+ background-color: color-mix(in oklch, var(--foreground) 45%, transparent);
413
+ }
@@ -0,0 +1,76 @@
1
+ "use client"
2
+
3
+ import type { ReactNode } from "react"
4
+
5
+ import { createContext, useContext, useEffect, useState } from "react"
6
+
7
+ import type { CodeHighlightMap, CodeLanguage } from "~/lib/highlight"
8
+
9
+ import { codeHighlightKey } from "~/lib/codeHighlightKey"
10
+
11
+ const CodeHighlightContext = createContext<CodeHighlightMap>({})
12
+
13
+ /**
14
+ * Supplies the server-built highlight map to the client tree.
15
+ *
16
+ * @param props.highlights - Map produced by `highlightPageBlocks` on the server.
17
+ * @param props.children - The page subtree containing code panes.
18
+ * @returns The provider.
19
+ */
20
+ export function CodeHighlightProvider(props: {
21
+ children: ReactNode
22
+ highlights: CodeHighlightMap
23
+ }) {
24
+ return (
25
+ <CodeHighlightContext.Provider value={props.highlights}>
26
+ {props.children}
27
+ </CodeHighlightContext.Provider>
28
+ )
29
+ }
30
+
31
+ /**
32
+ * Resolves one pane's highlighted HTML.
33
+ *
34
+ * Server-rendered code hits the precomputed map and never touches a
35
+ * highlighter. Code that only exists in the browser — what an editor is
36
+ * typing right now in live preview — misses, so this lazily imports shiki and
37
+ * highlights client-side. The import is dynamic and inside the miss branch, so
38
+ * an ordinary visitor downloads no highlighter at all.
39
+ *
40
+ * @param props.code - Source text.
41
+ * @param props.language - Resolved grammar name.
42
+ * @returns The highlighted inner HTML, or `undefined` until it is available
43
+ * (render the raw source meanwhile).
44
+ */
45
+ export function useCodeHighlight(props: {
46
+ code: string
47
+ language: CodeLanguage
48
+ }): string | undefined {
49
+ const highlights = useContext(CodeHighlightContext)
50
+ const key = codeHighlightKey(props)
51
+ const precomputed = highlights[key]
52
+ const [clientHighlighted, setClientHighlighted] = useState<Record<string, string>>({})
53
+
54
+ useEffect(() => {
55
+ if (precomputed !== undefined || clientHighlighted[key] !== undefined) return
56
+
57
+ let cancelled = false
58
+ void (async () => {
59
+ // Dynamic on purpose, and the one case the static-import rule exempts:
60
+ // a static import would pull shiki's grammars and wasm into the page's
61
+ // main client chunk for every visitor, when only an editor previewing
62
+ // freshly-typed code ever reaches this branch.
63
+ const { highlightCodeInBrowser } = await import("~/lib/highlightClient")
64
+ const html = await highlightCodeInBrowser(props)
65
+ if (cancelled) return
66
+ setClientHighlighted((previous) => ({ ...previous, [key]: html }))
67
+ })()
68
+
69
+ return () => {
70
+ cancelled = true
71
+ }
72
+ // `props.code`/`props.language` are what `key` is derived from.
73
+ }, [key, precomputed, clientHighlighted, props])
74
+
75
+ return precomputed ?? clientHighlighted[key]
76
+ }
@@ -1,7 +1,10 @@
1
+ "use client"
2
+
1
3
  import { cn } from "@vexcms/react"
2
4
 
5
+ import { useCodeHighlight } from "~/components/CodeHighlightContext"
3
6
  import { CopyButton } from "~/components/CopyButton"
4
- import { highlightCode, toCodeLanguage } from "~/lib/highlight"
7
+ import { toCodeLanguage } from "~/lib/highlight"
5
8
 
6
9
  export type CodePaneProps = {
7
10
  /** Extra classes for the scroll body. */
@@ -22,10 +25,16 @@ export type CodePaneProps = {
22
25
  /**
23
26
  * One code pane: a chrome bar and a scrolling body on the fixed dark surface.
24
27
  *
25
- * Shared by CodeShowcase and Split so the two never drift. A server
26
- * component — shiki runs here and the client receives only markup.
28
+ * Shared by CodeShowcase and Split so the two never drift.
29
+ *
30
+ * Synchronous, and a client component, because it renders inside
31
+ * `PageContent` — which is client-side so live preview can overlay its query
32
+ * result. Highlighting therefore happens outside it: the server pre-highlights
33
+ * the fetched document's panes (`highlightPageBlocks`) and this reads the
34
+ * result through `useCodeHighlight`. An async component here is the error
35
+ * "<CodePane> is an async Client Component".
27
36
  */
28
- export async function CodePane({
37
+ export function CodePane({
29
38
  bodyClassName,
30
39
  code,
31
40
  filename,
@@ -34,7 +43,7 @@ export async function CodePane({
34
43
  tone = "generated",
35
44
  }: CodePaneProps) {
36
45
  const resolvedLanguage = toCodeLanguage(language)
37
- const html = await highlightCode({ code, language: resolvedLanguage })
46
+ const html = useCodeHighlight({ code, language: resolvedLanguage })
38
47
 
39
48
  return (
40
49
  // `min-w-0` is not optional. In a `1fr` grid track the longest line would
@@ -77,8 +86,11 @@ export async function CodePane({
77
86
  )}
78
87
  >
79
88
  <pre className="font-mono text-xs leading-[1.7] whitespace-pre text-[--color-code-fg] xl:text-[12.5px]">
80
- {/* Highlighted server-side; the client ships no highlighter. */}
81
- <code dangerouslySetInnerHTML={{ __html: html }} />
89
+ {/* Pre-highlighted on the server for anything the page fetched. A
90
+ pane the server never saw (code being typed in live preview)
91
+ renders as plain source until the lazy browser highlighter
92
+ resolves — never blank, never a spinner over a code block. */}
93
+ {html === undefined ? <code>{code}</code> : <code dangerouslySetInnerHTML={{ __html: html }} />}
82
94
  </pre>
83
95
  </div>
84
96
  </div>
@@ -2,8 +2,7 @@
2
2
 
3
3
  import { convexQuery } from "@convex-dev/react-query"
4
4
  import { api } from "@convex/_generated/api"
5
- import { useQuery } from "@tanstack/react-query"
6
- import { type RenderableBlock, RenderBlocks } from "@vexcms/react"
5
+ import { type RenderableBlock, RenderBlocks, useLivePreviewDocumentQuery } from "@vexcms/react"
7
6
 
8
7
  import type { FootersDocument } from "~/vex.types"
9
8
 
@@ -16,10 +15,15 @@ export function SiteFooter({
16
15
  }: {
17
16
  initialData?: FootersDocument | null
18
17
  }) {
19
- const { data: footer } = useQuery({
20
- ...convexQuery(api.footers.getFirst, {}),
21
- initialData: initialData ?? undefined,
22
- })
18
+ // See `SiteHeader`: the overlay only reaches documents a consumer hands it,
19
+ // so editing the footer while previewing a page needs this wrapper.
20
+ const { data: footer } = useLivePreviewDocumentQuery(
21
+ {
22
+ ...convexQuery(api.footers.getFirst, {}),
23
+ initialData: initialData ?? undefined,
24
+ },
25
+ "footers",
26
+ )
23
27
 
24
28
  const content = footer?.content as null | PageBlockLike[] | undefined
25
29
  if (!content) {return null}
@@ -2,8 +2,7 @@
2
2
 
3
3
  import { convexQuery } from "@convex-dev/react-query"
4
4
  import { api } from "@convex/_generated/api"
5
- import { useQuery } from "@tanstack/react-query"
6
- import { type RenderableBlock, RenderBlocks } from "@vexcms/react"
5
+ import { type RenderableBlock, RenderBlocks, useLivePreviewDocumentQuery } from "@vexcms/react"
7
6
 
8
7
  import type { HeadersDocument } from "~/vex.types"
9
8
 
@@ -16,10 +15,16 @@ export function SiteHeader({
16
15
  }: {
17
16
  initialData?: HeadersDocument | null
18
17
  }) {
19
- const { data: header } = useQuery({
20
- ...convexQuery(api.headers.getFirst, {}),
21
- initialData: initialData ?? undefined,
22
- })
18
+ // `useLivePreviewDocumentQuery`, not a bare `useQuery`: the live-preview
19
+ // overlay only reaches documents a consumer hands it, so a plain query here
20
+ // means editing the header while previewing a page changes nothing.
21
+ const { data: header } = useLivePreviewDocumentQuery(
22
+ {
23
+ ...convexQuery(api.headers.getFirst, {}),
24
+ initialData: initialData ?? undefined,
25
+ },
26
+ "headers",
27
+ )
23
28
 
24
29
  const content = header?.content as null | PageBlockLike[] | undefined
25
30
  if (!content) {return null}
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Stable key identifying one highlighted code pane.
3
+ *
4
+ * Client-safe by construction — this module imports nothing (shiki in
5
+ * particular), because both sides compute the key: the server when it builds
6
+ * the highlight map, and `CodePane` in the browser when it looks an entry up.
7
+ *
8
+ * A hash rather than the source text itself: the map is serialised into the
9
+ * RSC payload, and keying by the full code would ship every pane's source a
10
+ * second time.
11
+ *
12
+ * @param props.code - Source text exactly as authored.
13
+ * @param props.language - Resolved grammar name.
14
+ * @returns A short key, stable across server and client for identical input.
15
+ */
16
+ export function codeHighlightKey(props: { code: string; language: string }): string {
17
+ const input = `${props.language}\u0000${props.code}`
18
+ // FNV-1a, 32-bit. Collisions only ever mean "render the wrong highlighted
19
+ // HTML for a pane", not a correctness or security problem, and the input
20
+ // space here is a handful of panes per page.
21
+ let hash = 0x811c9dc5
22
+ for (let i = 0; i < input.length; i++) {
23
+ hash ^= input.charCodeAt(i)
24
+ hash = Math.imul(hash, 0x01000193)
25
+ }
26
+ return `${props.language}-${(hash >>> 0).toString(36)}-${props.code.length}`
27
+ }
@@ -1,12 +1,22 @@
1
- import { createHighlighter, type Highlighter, type ThemeRegistrationRaw } from "shiki"
1
+ import { createHighlighter, type Highlighter } from "shiki"
2
2
 
3
- import starkEmber from "./shikiStarkEmber.json"
3
+ import { codeHighlightKey } from "./codeHighlightKey"
4
4
 
5
5
  /** Languages the `language` select on CodeShowcase and Split can produce. */
6
6
  export const CODE_LANGUAGES = ["bash", "json", "ts", "tsx"] as const
7
7
 
8
8
  export type CodeLanguage = (typeof CODE_LANGUAGES)[number]
9
9
 
10
+ /**
11
+ * Shiki theme for every code pane, server and browser alike.
12
+ *
13
+ * Tokyo Night supplies the token colours only: `highlightCode` discards
14
+ * shiki's `<pre>` wrapper, which is where a theme's own background and
15
+ * default foreground live, so the pane keeps the Stark Ember surface from
16
+ * `--color-code-bg`/`--color-code-fg` and only the syntax colours change.
17
+ */
18
+ export const CODE_THEME = "tokyo-night"
19
+
10
20
  /**
11
21
  * Narrows an authored `language` value to one shiki is loaded for.
12
22
  *
@@ -44,7 +54,7 @@ export async function highlightCode(props: {
44
54
  const highlighter = await getHighlighter()
45
55
  const html = highlighter.codeToHtml(props.code, {
46
56
  lang: props.language,
47
- theme: "stark-ember",
57
+ theme: CODE_THEME,
48
58
  })
49
59
  // `codeToHtml` returns `<pre …><code>…</code></pre>`; keep the inner code
50
60
  // element's contents and let the pane own the scroll container.
@@ -65,7 +75,60 @@ export async function highlightCode(props: {
65
75
  function getHighlighter(): Promise<Highlighter> {
66
76
  highlighterPromise ??= createHighlighter({
67
77
  langs: [...CODE_LANGUAGES],
68
- themes: [starkEmber as unknown as ThemeRegistrationRaw],
78
+ themes: [CODE_THEME],
69
79
  })
70
80
  return highlighterPromise
71
81
  }
82
+
83
+ /**
84
+ * Map of {@link codeHighlightKey} to highlighted inner HTML, built on the
85
+ * server and handed to the client tree.
86
+ */
87
+ export type CodeHighlightMap = Record<string, string>
88
+
89
+ /**
90
+ * Pre-highlights every code pane a page's blocks contain.
91
+ *
92
+ * Exists because `PageContent` is a client component (live preview overlays
93
+ * its query result), so nothing below it can `await` shiki. The server walks
94
+ * the fetched document, highlights each pane, and passes plain strings across
95
+ * the boundary; `CodePane` then renders synchronously.
96
+ *
97
+ * The walk is structural rather than block-type-aware: any object carrying a
98
+ * string `code` is a pane, wherever a future block puts it, and its language
99
+ * comes from a sibling `language`/`codeLanguage` field.
100
+ *
101
+ * @param blocks - The document's `blocks` array, as fetched.
102
+ * @returns Highlighted HTML keyed by {@link codeHighlightKey}.
103
+ */
104
+ export async function highlightPageBlocks(blocks: unknown): Promise<CodeHighlightMap> {
105
+ const panes: { code: string; language: CodeLanguage }[] = []
106
+
107
+ const collect = (node: unknown): void => {
108
+ if (Array.isArray(node)) {
109
+ node.forEach(collect)
110
+ return
111
+ }
112
+ if (node === null || typeof node !== "object") return
113
+
114
+ const record = node as Record<string, unknown>
115
+ if (typeof record.code === "string" && record.code.length > 0) {
116
+ panes.push({
117
+ code: record.code,
118
+ language: toCodeLanguage(
119
+ (record.language ?? record.codeLanguage) as string | string[] | undefined
120
+ ),
121
+ })
122
+ }
123
+ Object.values(record).forEach(collect)
124
+ }
125
+ collect(blocks)
126
+
127
+ const highlighted: CodeHighlightMap = {}
128
+ for (const pane of panes) {
129
+ const key = codeHighlightKey(pane)
130
+ if (highlighted[key] !== undefined) continue
131
+ highlighted[key] = await highlightCode(pane)
132
+ }
133
+ return highlighted
134
+ }
@@ -0,0 +1,37 @@
1
+ import type { Highlighter } from "shiki"
2
+
3
+ import { createHighlighter } from "shiki"
4
+
5
+ import type { CodeLanguage } from "./highlight"
6
+
7
+ import { CODE_LANGUAGES, CODE_THEME } from "./highlight"
8
+
9
+ let browserHighlighterPromise: null | Promise<Highlighter> = null
10
+
11
+ /**
12
+ * Highlights code in the browser, for panes the server never saw.
13
+ *
14
+ * The only caller is `useCodeHighlight`'s miss branch — live preview, where
15
+ * the editor's unsaved source exists nowhere but this tab. Loaded through a
16
+ * dynamic import so shiki stays out of the page's main client chunk.
17
+ *
18
+ * @param props.code - Source text.
19
+ * @param props.language - Grammar to highlight with.
20
+ * @returns Highlighted inner HTML, matching `highlightCode`'s server output.
21
+ */
22
+ export async function highlightCodeInBrowser(props: {
23
+ code: string
24
+ language: CodeLanguage
25
+ }): Promise<string> {
26
+ browserHighlighterPromise ??= createHighlighter({
27
+ langs: [...CODE_LANGUAGES],
28
+ themes: [CODE_THEME],
29
+ })
30
+ const highlighter = await browserHighlighterPromise
31
+ const html = highlighter.codeToHtml(props.code, {
32
+ lang: props.language,
33
+ theme: CODE_THEME,
34
+ })
35
+ const match = /<code[^>]*>([\s\S]*)<\/code>/.exec(html)
36
+ return match?.[1] ?? html
37
+ }
@@ -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
+ }
@@ -1,4 +1,9 @@
1
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";
2
7
  import { fetchQuery } from "convex/nextjs";
3
8
  import { cookies } from "next/headers";
4
9
  import { type NextRequest, NextResponse } from "next/server";
@@ -51,27 +56,78 @@ export async function proxy(request: NextRequest) {
51
56
  return NextResponse.next();
52
57
  }
53
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
+ > {
54
116
  const cookieStore = await cookies();
55
117
  const sessionToken =
56
118
  cookieStore.get(SESSION_COOKIES.https)?.value ?? cookieStore.get(SESSION_COOKIES.http)?.value;
57
- if (!sessionToken) {
58
- return redirectToSignIn(request);
59
- }
119
+ if (!sessionToken) return "unauthenticated";
60
120
 
61
121
  try {
62
122
  const session = await fetchQuery(api.auth.sessions.getSessionWithUser, {
63
123
  sessionToken: extractToken(sessionToken),
64
124
  });
65
- if (!session?.user) {
66
- return redirectToSignIn(request);
67
- }
125
+ return session?.user ? "authenticated" : "unauthenticated";
68
126
  } catch {
69
- // Fail closed. Previously this returned `NextResponse.next()`, which served
70
- // every guarded route unauthenticated whenever this lookup threw.
71
- return redirectToUnauthorized(request);
127
+ // Fail closed for the admin branch; grantPreviewIfRequested treats this as
128
+ // "no preview" rather than an error.
129
+ return "verification-failed";
72
130
  }
73
-
74
- return NextResponse.next();
75
131
  }
76
132
 
77
133
  /** Extract the raw token — better-auth stores "<raw_token>.<hmac_signature>" */
@@ -119,5 +175,5 @@ export const config = {
119
175
  // Allowlist, not denylist. The base template denies everything except a few
120
176
  // paths; here the public site IS the product, so the gate names the private
121
177
  // surfaces explicitly. Anything added under `(vexcms)` must be added here.
122
- matcher: ["/admin/:path*"],
178
+ matcher: ["/admin/:path*", "/((?!_next/static|_next/image|favicon.ico).*)"],
123
179
  };
@@ -5,6 +5,7 @@ import { uploadFile } from "@vexcms/file-storage-convex/client"
5
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
 
@@ -37,9 +38,16 @@ const vexConfig = defineConfig({
37
38
  if (collection !== pages.slug) return []
38
39
  const { slug } = doc
39
40
  if (typeof slug !== "string") return []
40
- return [slug === "home" ? "/" : `/${slug}`]
41
+ const path = resolvePagePath(slug)
42
+ return path === undefined ? [] : [path]
41
43
  },
42
44
  },
45
+ livePreview: {
46
+ allowedOrigins: [
47
+ "http://localhost:3000",
48
+ ...(process.env.NEXT_PUBLIC_SITE_URL ? [process.env.NEXT_PUBLIC_SITE_URL] : []),
49
+ ],
50
+ },
43
51
  })
44
52
 
45
53
  export default vexConfig
@@ -8,6 +8,9 @@ export const headers = defineCollection({
8
8
  admin: {
9
9
  useAsTitle: "name",
10
10
  icon: "PanelTop",
11
+ livePreview: {
12
+ url: "/",
13
+ },
11
14
  },
12
15
  fields: {
13
16
  name: text({
@@ -2,6 +2,7 @@ import type { DataModel } from "@convex/_generated/dataModel"
2
2
 
3
3
  import { blocks, defineCollection, text, textValidator, upload } from "@vexcms/core"
4
4
 
5
+ import { resolvePagePath } from "~/lib/resolvePagePath"
5
6
  import { TABLE_SLUG_IMAGES, TABLE_SLUG_PAGES } from "~/db/constants"
6
7
  import { pageBlocks } from "~/vexcms/blocks/config"
7
8
 
@@ -11,6 +12,9 @@ export const pages = defineCollection({
11
12
  table: { defaultColumns: ["title", "slug", "_id"] },
12
13
  useAsTitle: "title",
13
14
  icon: "FileText",
15
+ livePreview: {
16
+ url: (doc) => resolvePagePath(doc.slug),
17
+ },
14
18
  },
15
19
  fields: {
16
20
  title: text({
@@ -1,57 +0,0 @@
1
- {
2
- "name": "stark-ember",
3
- "type": "dark",
4
- "colors": {
5
- "editor.background": "#0F0F0F",
6
- "editor.foreground": "#D4D4D4"
7
- },
8
- "tokenColors": [
9
- {
10
- "scope": ["comment", "punctuation.definition.comment", "string.comment"],
11
- "settings": { "foreground": "#7A7A7A" }
12
- },
13
- {
14
- "scope": [
15
- "keyword",
16
- "keyword.control",
17
- "keyword.operator.new",
18
- "storage",
19
- "storage.type",
20
- "storage.modifier",
21
- "constant.language",
22
- "support.type.primitive",
23
- "variable.language"
24
- ],
25
- "settings": { "foreground": "#F2864F" }
26
- },
27
- {
28
- "scope": [
29
- "entity.name.function",
30
- "entity.name.type",
31
- "entity.name.class",
32
- "support.class",
33
- "support.function",
34
- "meta.definition.variable entity.name.function"
35
- ],
36
- "settings": { "foreground": "#F4F4F4" }
37
- },
38
- {
39
- "scope": ["string", "string.quoted", "constant.numeric", "constant.character"],
40
- "settings": { "foreground": "#ABABAB" }
41
- },
42
- {
43
- "scope": [
44
- "punctuation",
45
- "meta.brace",
46
- "punctuation.separator",
47
- "punctuation.terminator",
48
- "keyword.operator"
49
- ],
50
- "settings": { "foreground": "#6B6B6B" }
51
- },
52
- {
53
- "scope": ["variable", "variable.other", "meta.object-literal.key", "support.variable"],
54
- "settings": { "foreground": "#D4D4D4" }
55
- }
56
- ]
57
- }