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
@@ -0,0 +1,32 @@
1
+ /*
2
+ * Shared "reveal" animation for AnimatedGroup and TextEffect — the
3
+ * blur + fade + rise entrance the hero uses, implemented in pure CSS so the
4
+ * template carries no animation-library dependency.
5
+ *
6
+ * `both` fill-mode keeps elements hidden until their delay elapses, which is
7
+ * what makes the stagger read as a sequence. Delay per element arrives via
8
+ * the `--reveal-delay` custom property.
9
+ */
10
+ @keyframes reveal {
11
+ from {
12
+ opacity: 0;
13
+ filter: blur(12px);
14
+ transform: translateY(12px);
15
+ }
16
+ to {
17
+ opacity: 1;
18
+ filter: blur(0);
19
+ transform: translateY(0);
20
+ }
21
+ }
22
+
23
+ .item {
24
+ animation: reveal 1.1s cubic-bezier(0.25, 1, 0.5, 1) both;
25
+ animation-delay: var(--reveal-delay, 0s);
26
+ }
27
+
28
+ @media (prefers-reduced-motion: reduce) {
29
+ .item {
30
+ animation: none;
31
+ }
32
+ }
@@ -1,292 +1,73 @@
1
- "use client"
2
-
3
- import {
4
- AnimatePresence,
5
- motion,
6
- type TargetAndTransition,
7
- type Transition,
8
- type Variant,
9
- type Variants,
10
- } from "motion/react"
11
- import React from "react"
1
+ import React, { type CSSProperties } from "react"
12
2
 
13
3
  import { cn } from "~/lib/utils"
14
4
 
15
- export type PerType = "char" | "line" | "word"
16
- export type PresetType = "blur" | "fade" | "fade-in-blur" | "scale" | "slide"
5
+ import styles from "./reveal.module.css"
6
+
7
+ export type PerType = "line" | "word"
17
8
 
18
9
  export type TextEffectProps = {
19
10
  as?: keyof React.JSX.IntrinsicElements
20
11
  children: string
21
12
  className?: string
22
- containerTransition?: Transition
13
+ /** Seconds before the first segment starts animating. */
23
14
  delay?: number
24
- onAnimationComplete?: () => void
25
- onAnimationStart?: () => void
15
+ /** Split granularity for the stagger. Lines split on `\n`. */
26
16
  per?: PerType
27
- preset?: PresetType
28
- segmentTransition?: Transition
29
- segmentWrapperClassName?: string
30
- speedReveal?: number
31
- speedSegment?: number
32
- style?: React.CSSProperties
33
- trigger?: boolean
34
- variants?: {
35
- container?: Variants
36
- item?: Variants
37
- }
17
+ /** Seconds between each segment's start. */
18
+ stagger?: number
38
19
  }
39
20
 
40
- const defaultStaggerTimes: Record<PerType, number> = {
41
- char: 0.03,
42
- line: 0.1,
43
- word: 0.05,
44
- }
45
-
46
- const defaultContainerVariants: Variants = {
47
- hidden: { opacity: 0 },
48
- exit: {
49
- transition: { staggerChildren: 0.05, staggerDirection: -1 },
50
- },
51
- visible: {
52
- opacity: 1,
53
- transition: {
54
- staggerChildren: 0.05,
55
- },
56
- },
57
- }
58
-
59
- const defaultItemVariants: Variants = {
60
- hidden: { opacity: 0 },
61
- exit: { opacity: 0 },
62
- visible: {
63
- opacity: 1,
64
- },
65
- }
66
-
67
- const presetVariants: Record<
68
- PresetType,
69
- { container: Variants; item: Variants }
70
- > = {
71
- slide: {
72
- container: defaultContainerVariants,
73
- item: {
74
- hidden: { opacity: 0, y: 20 },
75
- exit: { opacity: 0, y: 20 },
76
- visible: { opacity: 1, y: 0 },
77
- },
78
- },
79
- blur: {
80
- container: defaultContainerVariants,
81
- item: {
82
- hidden: { filter: "blur(12px)", opacity: 0 },
83
- exit: { filter: "blur(12px)", opacity: 0 },
84
- visible: { filter: "blur(0px)", opacity: 1 },
85
- },
86
- },
87
- fade: {
88
- container: defaultContainerVariants,
89
- item: {
90
- hidden: { opacity: 0 },
91
- exit: { opacity: 0 },
92
- visible: { opacity: 1 },
93
- },
94
- },
95
- "fade-in-blur": {
96
- container: defaultContainerVariants,
97
- item: {
98
- hidden: { filter: "blur(12px)", opacity: 0, y: 20 },
99
- exit: { filter: "blur(12px)", opacity: 0, y: 20 },
100
- visible: { filter: "blur(0px)", opacity: 1, y: 0 },
101
- },
102
- },
103
- scale: {
104
- container: defaultContainerVariants,
105
- item: {
106
- hidden: { opacity: 0, scale: 0 },
107
- exit: { opacity: 0, scale: 0 },
108
- visible: { opacity: 1, scale: 1 },
109
- },
110
- },
111
- }
112
-
113
- const AnimationComponent: React.FC<{
114
- per: "char" | "line" | "word"
115
- segment: string
116
- segmentWrapperClassName?: string
117
- variants: Variants
118
- }> = React.memo(({ per, segment, segmentWrapperClassName, variants }) => {
119
- const content =
120
- per === "line" ? (
121
- <motion.span className="block" variants={variants}>
122
- {segment}
123
- </motion.span>
124
- ) : per === "word" ? (
125
- <motion.span
126
- aria-hidden="true"
127
- className="inline-block whitespace-pre"
128
- variants={variants}
129
- >
130
- {segment}
131
- </motion.span>
132
- ) : (
133
- <motion.span className="inline-block whitespace-pre">
134
- {segment.split("").map((char, charIndex) => (
135
- <motion.span
136
- aria-hidden="true"
137
- className="inline-block whitespace-pre"
138
- key={`char-${charIndex}`}
139
- variants={variants}
140
- >
141
- {char}
142
- </motion.span>
143
- ))}
144
- </motion.span>
145
- )
146
-
147
- if (!segmentWrapperClassName) {
148
- return content
149
- }
150
-
151
- const defaultWrapperClassName = per === "line" ? "block" : "inline-block"
152
-
153
- return (
154
- <span className={cn(defaultWrapperClassName, segmentWrapperClassName)}>
155
- {content}
156
- </span>
157
- )
158
- })
159
-
160
- AnimationComponent.displayName = "AnimationComponent"
161
-
162
- const splitText = (text: string, per: "char" | "line" | "word") => {
163
- if (per === "line") return text.split("\n")
164
- return text.split(/(\s+)/)
165
- }
166
-
167
- const hasTransition = (
168
- variant: Variant,
169
- ): variant is TargetAndTransition & { transition?: Transition } => {
170
- return (
171
- typeof variant === "object" && variant !== null && "transition" in variant
172
- )
173
- }
174
-
175
- const createVariantsWithTransition = (
176
- baseVariants: Variants,
177
- transition?: Transition & { exit?: Transition },
178
- ): Variants => {
179
- if (!transition) return baseVariants
180
-
181
- const { ...mainTransition } = transition
182
-
183
- return {
184
- ...baseVariants,
185
- exit: {
186
- ...baseVariants.exit,
187
- transition: {
188
- ...(hasTransition(baseVariants.exit)
189
- ? baseVariants.exit.transition
190
- : {}),
191
- ...mainTransition,
192
- staggerDirection: -1,
193
- },
194
- },
195
- visible: {
196
- ...baseVariants.visible,
197
- transition: {
198
- ...(hasTransition(baseVariants.visible)
199
- ? baseVariants.visible.transition
200
- : {}),
201
- ...mainTransition,
202
- },
203
- },
204
- }
21
+ const defaultStagger: Record<PerType, number> = {
22
+ line: 0.15,
23
+ word: 0.04,
205
24
  }
206
25
 
26
+ /**
27
+ * Staggered blur + fade + rise text entrance, split per word or per line —
28
+ * pure CSS (see `reveal.module.css`), no animation-library dependency.
29
+ * Word segments keep their trailing space inside the animated span so text
30
+ * still wraps naturally.
31
+ */
207
32
  export function TextEffect({
208
33
  as = "p",
209
34
  children,
210
35
  className,
211
- containerTransition,
212
36
  delay = 0,
213
- onAnimationComplete,
214
- onAnimationStart,
215
37
  per = "word",
216
- preset = "fade",
217
- segmentTransition,
218
- segmentWrapperClassName,
219
- speedReveal = 1,
220
- speedSegment = 1,
221
- style,
222
- trigger = true,
223
- variants,
38
+ stagger,
224
39
  }: TextEffectProps) {
225
- const segments = splitText(children, per)
226
- const MotionTag = motion[as as keyof typeof motion] as typeof motion.div
227
-
228
- const baseVariants = preset
229
- ? presetVariants[preset]
230
- : { container: defaultContainerVariants, item: defaultItemVariants }
231
-
232
- const stagger = defaultStaggerTimes[per] / speedReveal
233
-
234
- const baseDuration = 0.3 / speedSegment
235
-
236
- const customStagger = hasTransition(variants?.container?.visible ?? {})
237
- ? (variants?.container?.visible as TargetAndTransition).transition
238
- ?.staggerChildren
239
- : undefined
240
-
241
- const customDelay = hasTransition(variants?.container?.visible ?? {})
242
- ? (variants?.container?.visible as TargetAndTransition).transition
243
- ?.delayChildren
244
- : undefined
245
-
246
- const computedVariants = {
247
- container: createVariantsWithTransition(
248
- variants?.container ?? baseVariants.container,
249
- {
250
- delayChildren: customDelay ?? delay,
251
- staggerChildren: customStagger ?? stagger,
252
- ...containerTransition,
253
- exit: {
254
- staggerChildren: customStagger ?? stagger,
255
- staggerDirection: -1,
256
- },
257
- },
258
- ),
259
- item: createVariantsWithTransition(variants?.item ?? baseVariants.item, {
260
- duration: baseDuration,
261
- ...segmentTransition,
262
- }),
263
- }
40
+ const Tag = as as React.ElementType
41
+ const rawSegments =
42
+ per === "line" ? children.split("\n") : children.split(/(\s+)/).filter(Boolean)
43
+ const step = stagger ?? defaultStagger[per]
44
+
45
+ // Whitespace segments are re-emitted verbatim so words still wrap, but they
46
+ // must not consume a stagger slot. Both passes are pure: the React Compiler
47
+ // rejects a counter reassigned inside a render-scope closure, because a
48
+ // partially re-run render would resume from a stale count.
49
+ const gaps = rawSegments.map((text) => per === "word" && /^\s+$/.test(text))
50
+ const segments = rawSegments.map((text, index) => {
51
+ if (gaps[index]) {return { delay: null, text }}
52
+ const animatedBefore = gaps.slice(0, index).filter((isGap) => !isGap).length
53
+ return { delay: delay + animatedBefore * step, text }
54
+ })
264
55
 
265
56
  return (
266
- <AnimatePresence mode="popLayout">
267
- {trigger && (
268
- <MotionTag
269
- animate="visible"
270
- className={className}
271
- exit="exit"
272
- initial="hidden"
273
- onAnimationComplete={onAnimationComplete}
274
- onAnimationStart={onAnimationStart}
275
- style={style}
276
- variants={computedVariants.container}
277
- >
278
- {per !== "line" ? <span className="sr-only">{children}</span> : null}
279
- {segments.map((segment, index) => (
280
- <AnimationComponent
281
- key={`${per}-${index}-${segment}`}
282
- per={per}
283
- segment={segment}
284
- segmentWrapperClassName={segmentWrapperClassName}
285
- variants={computedVariants.item}
286
- />
287
- ))}
288
- </MotionTag>
57
+ <Tag className={className}>
58
+ {segments.map((segment, index) =>
59
+ segment.delay === null ? (
60
+ <React.Fragment key={index}>{segment.text}</React.Fragment>
61
+ ) : (
62
+ <span
63
+ className={cn(styles.item, per === "line" ? "block" : "inline-block")}
64
+ key={index}
65
+ style={{ "--reveal-delay": `${segment.delay}s` } as CSSProperties}
66
+ >
67
+ {segment.text}
68
+ </span>
69
+ )
289
70
  )}
290
- </AnimatePresence>
71
+ </Tag>
291
72
  )
292
73
  }
@@ -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
+ }
@@ -0,0 +1,134 @@
1
+ import { createHighlighter, type Highlighter } from "shiki"
2
+
3
+ import { codeHighlightKey } from "./codeHighlightKey"
4
+
5
+ /** Languages the `language` select on CodeShowcase and Split can produce. */
6
+ export const CODE_LANGUAGES = ["bash", "json", "ts", "tsx"] as const
7
+
8
+ export type CodeLanguage = (typeof CODE_LANGUAGES)[number]
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
+
20
+ /**
21
+ * Narrows an authored `language` value to one shiki is loaded for.
22
+ *
23
+ * The field is a `select`, so the value is an array and can also be a stale
24
+ * string left by a hand-edit. Anything unrecognised falls back to `ts`, which
25
+ * is what every pane in the seed uses.
26
+ *
27
+ * @param value - Raw field value.
28
+ * @returns A language shiki has a grammar loaded for.
29
+ */
30
+ export function toCodeLanguage(value: string | string[] | undefined): CodeLanguage {
31
+ const raw = Array.isArray(value) ? value[0] : value
32
+ return (CODE_LANGUAGES as readonly string[]).includes(raw ?? "")
33
+ ? (raw as CodeLanguage)
34
+ : "ts"
35
+ }
36
+
37
+ let highlighterPromise: null | Promise<Highlighter> = null
38
+
39
+ /**
40
+ * Renders source to highlighted HTML in a server component.
41
+ *
42
+ * Shiki's own `<pre>` wrapper is discarded — the pane supplies its own
43
+ * scrolling frame — so this returns only the inner markup.
44
+ *
45
+ * @param props - Input props.
46
+ * @param props.code - Source text, exactly as authored in the CMS.
47
+ * @param props.language - Grammar to highlight with.
48
+ * @returns Highlighted inner HTML, safe to inject.
49
+ */
50
+ export async function highlightCode(props: {
51
+ code: string
52
+ language: CodeLanguage
53
+ }): Promise<string> {
54
+ const highlighter = await getHighlighter()
55
+ const html = highlighter.codeToHtml(props.code, {
56
+ lang: props.language,
57
+ theme: CODE_THEME,
58
+ })
59
+ // `codeToHtml` returns `<pre …><code>…</code></pre>`; keep the inner code
60
+ // element's contents and let the pane own the scroll container.
61
+ const match = /<code[^>]*>([\s\S]*)<\/code>/.exec(html)
62
+ return match?.[1] ?? html
63
+ }
64
+
65
+ /**
66
+ * One process-wide highlighter, created lazily.
67
+ *
68
+ * Loaded with a single theme on purpose: code panes are fixed dark in both
69
+ * light and dark site themes (see the `--color-code-*` tokens in
70
+ * `globals.css`), so there is no second theme to switch between and the
71
+ * client ships no highlighter at all.
72
+ *
73
+ * @returns The shared shiki highlighter.
74
+ */
75
+ function getHighlighter(): Promise<Highlighter> {
76
+ highlighterPromise ??= createHighlighter({
77
+ langs: [...CODE_LANGUAGES],
78
+ themes: [CODE_THEME],
79
+ })
80
+ return highlighterPromise
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
+ }