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 +2 -2
- package/templates/base-nextjs/package.json +6 -6
- package/templates/marketing-site/src/app/(frontend)/(site)/PageContent.tsx +26 -14
- package/templates/marketing-site/src/app/(frontend)/(site)/SiteLivePreviewProvider.tsx +31 -0
- package/templates/marketing-site/src/app/(frontend)/(site)/[slug]/page.tsx +6 -1
- package/templates/marketing-site/src/app/(frontend)/(site)/layout.tsx +3 -2
- package/templates/marketing-site/src/app/(frontend)/(site)/page.tsx +6 -1
- package/templates/marketing-site/src/app/globals.css +39 -0
- package/templates/marketing-site/src/components/CodeHighlightContext.tsx +76 -0
- package/templates/marketing-site/src/components/CodePane.tsx +19 -7
- package/templates/marketing-site/src/components/SiteFooter.tsx +10 -6
- package/templates/marketing-site/src/components/SiteHeader.tsx +11 -6
- package/templates/marketing-site/src/lib/codeHighlightKey.ts +27 -0
- package/templates/marketing-site/src/lib/highlight.ts +67 -4
- package/templates/marketing-site/src/lib/highlightClient.ts +37 -0
- package/templates/marketing-site/src/lib/resolvePagePath.ts +8 -0
- package/templates/marketing-site/src/proxy.ts +68 -12
- package/templates/marketing-site/src/vex.config.ts +9 -1
- package/templates/marketing-site/src/vexcms/collections/headers.ts +3 -0
- package/templates/marketing-site/src/vexcms/collections/pages.ts +4 -0
- package/templates/marketing-site/src/lib/shikiStarkEmber.json +0 -57
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-vexcms",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
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.
|
|
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.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.
|
|
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 {
|
|
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)
|
|
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:
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
<
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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.
|
|
26
|
-
*
|
|
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
|
|
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 =
|
|
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
|
-
{/*
|
|
81
|
-
|
|
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 {
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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 {
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
1
|
+
import { createHighlighter, type Highlighter } from "shiki"
|
|
2
2
|
|
|
3
|
-
import
|
|
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:
|
|
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: [
|
|
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
|
-
|
|
66
|
-
return redirectToSignIn(request);
|
|
67
|
-
}
|
|
125
|
+
return session?.user ? "authenticated" : "unauthenticated";
|
|
68
126
|
} catch {
|
|
69
|
-
// Fail closed
|
|
70
|
-
//
|
|
71
|
-
return
|
|
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
|
-
|
|
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
|
|
@@ -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
|
-
}
|