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.
- package/README.md +48 -14
- package/dist/index.js +1714 -1504
- package/package.json +2 -2
- package/templates/base-nextjs/README.md +18 -5
- package/templates/base-nextjs/convex/auth/index.ts +2 -2
- package/templates/base-nextjs/convex/auth/options.ts +27 -26
- package/templates/base-nextjs/convex/auth/schema.ts +51 -0
- package/templates/base-nextjs/convex/auth/sessions.ts +3 -3
- package/templates/base-nextjs/convex/tsconfig.json +2 -2
- package/templates/base-nextjs/convex/vex/firstUser.ts +2 -2
- package/templates/base-nextjs/convex/vex/globals.ts +3 -2
- package/templates/base-nextjs/convex/vex/media.ts +3 -2
- package/templates/base-nextjs/convex/vex.schema.ts +1 -0
- package/templates/base-nextjs/convex/vex.ts +17 -4
- package/templates/base-nextjs/next.config.ts +52 -6
- package/templates/base-nextjs/package.json +18 -7
- package/templates/base-nextjs/public/favicons/favicon.ico +0 -0
- package/templates/base-nextjs/src/app/(frontend)/@auth/(...)auth/[pathname]/view.tsx +27 -15
- package/templates/base-nextjs/src/app/(vexcms)/admin/[[...path]]/page.tsx +2 -2
- package/templates/base-nextjs/src/app/(vexcms)/admin/clientProviders.tsx +9 -14
- package/templates/base-nextjs/src/app/(vexcms)/admin/layout.tsx +8 -6
- package/templates/base-nextjs/src/app/api/vex/revalidate/route.ts +19 -0
- package/templates/base-nextjs/src/app/globals.css +10 -9
- package/templates/base-nextjs/src/app/layout.tsx +6 -0
- package/templates/base-nextjs/src/auth/access.ts +14 -2
- package/templates/base-nextjs/src/auth/serverUtils.ts +20 -6
- package/templates/base-nextjs/src/components/WelcomePage.tsx +54 -43
- package/templates/base-nextjs/src/components/component-example.tsx +1 -1
- package/templates/base-nextjs/src/components/providers/server.tsx +16 -8
- package/templates/base-nextjs/src/vex.config.server.ts +30 -0
- package/templates/base-nextjs/src/vex.config.ts +14 -11
- package/templates/base-nextjs/src/vex.types.ts +2 -1
- package/templates/base-nextjs/src/vexcms/collections/images.ts +1 -1
- package/templates/base-nextjs/src/vexcms/collections/users.ts +3 -1
- package/templates/marketing-site/convex/pages.ts +20 -1
- package/templates/marketing-site/convex/schema.ts +4 -4
- package/templates/marketing-site/convex/seed.ts +515 -70
- package/templates/marketing-site/convex/siteSettings.ts +1 -1
- package/templates/marketing-site/convex/theme.ts +3 -3
- package/templates/marketing-site/convex/vex.schema.ts +75 -38
- package/templates/marketing-site/public/favicon.svg +14 -0
- package/templates/marketing-site/public/favicons/apple-touch-icon.png +0 -0
- package/templates/marketing-site/public/favicons/favicon.ico +0 -0
- package/templates/marketing-site/src/app/(frontend)/(site)/PageContent.tsx +64 -0
- package/templates/marketing-site/src/app/(frontend)/(site)/SiteLivePreviewProvider.tsx +31 -0
- package/templates/marketing-site/src/app/(frontend)/(site)/[slug]/page.tsx +63 -0
- package/templates/marketing-site/src/app/(frontend)/(site)/layout.tsx +50 -0
- package/templates/marketing-site/src/app/(frontend)/(site)/page.tsx +33 -0
- package/templates/marketing-site/src/app/(frontend)/layout.tsx +10 -24
- package/templates/marketing-site/src/app/(vexcms)/admin/layout.tsx +32 -0
- package/templates/marketing-site/src/app/globals.css +413 -0
- package/templates/marketing-site/src/app/layout.tsx +85 -0
- package/templates/marketing-site/src/app/robots.ts +13 -0
- package/templates/marketing-site/src/app/sitemap.ts +18 -0
- package/templates/marketing-site/src/components/BrandMark.tsx +27 -0
- package/templates/marketing-site/src/components/CodeHighlightContext.tsx +76 -0
- package/templates/marketing-site/src/components/CodePane.tsx +98 -0
- package/templates/marketing-site/src/components/Container.tsx +25 -0
- package/templates/marketing-site/src/components/CopyButton.tsx +80 -0
- package/templates/marketing-site/src/components/FirstAdminBootstrap.tsx +54 -0
- package/templates/marketing-site/src/components/InstallCommand.tsx +29 -0
- package/templates/marketing-site/src/components/MediaImage.tsx +59 -0
- package/templates/marketing-site/src/components/SectionHeader.tsx +51 -0
- package/templates/marketing-site/src/components/SiteFooter.tsx +8 -6
- package/templates/marketing-site/src/components/SiteHeader.tsx +8 -6
- package/templates/marketing-site/src/components/ThemeLive.tsx +1 -2
- package/templates/marketing-site/src/components/ThemeStyle.tsx +3 -3
- package/templates/marketing-site/src/components/motion-primitives/animated-group.tsx +44 -120
- package/templates/marketing-site/src/components/motion-primitives/reveal.module.css +32 -0
- package/templates/marketing-site/src/components/motion-primitives/text-effect.tsx +47 -266
- package/templates/marketing-site/src/lib/codeHighlightKey.ts +27 -0
- package/templates/marketing-site/src/lib/highlight.ts +134 -0
- package/templates/marketing-site/src/lib/highlightClient.ts +37 -0
- package/templates/marketing-site/src/lib/metadata.ts +61 -33
- package/templates/marketing-site/src/lib/resolvePagePath.ts +8 -0
- package/templates/marketing-site/src/lib/vex.ts +18 -0
- package/templates/marketing-site/src/proxy.ts +179 -0
- package/templates/marketing-site/src/vex.config.server.ts +27 -0
- package/templates/marketing-site/src/vex.config.ts +27 -9
- package/templates/marketing-site/src/vex.types.ts +102 -76
- package/templates/marketing-site/src/vexcms/api.ts +2 -2
- package/templates/marketing-site/src/vexcms/blocks/CTA/config.ts +3 -2
- package/templates/marketing-site/src/vexcms/blocks/CTA/index.tsx +40 -24
- package/templates/marketing-site/src/vexcms/blocks/CodeShowcase/config.ts +62 -0
- package/templates/marketing-site/src/vexcms/blocks/CodeShowcase/index.tsx +106 -0
- package/templates/marketing-site/src/vexcms/blocks/FAQ/config.ts +4 -3
- package/templates/marketing-site/src/vexcms/blocks/FAQ/index.tsx +63 -50
- package/templates/marketing-site/src/vexcms/blocks/Features/config.ts +1 -0
- package/templates/marketing-site/src/vexcms/blocks/Features/index.tsx +43 -58
- package/templates/marketing-site/src/vexcms/blocks/Footer/config.ts +6 -3
- package/templates/marketing-site/src/vexcms/blocks/Footer/index.tsx +90 -53
- package/templates/marketing-site/src/vexcms/blocks/Header/config.ts +10 -3
- package/templates/marketing-site/src/vexcms/blocks/Header/index.tsx +205 -138
- package/templates/marketing-site/src/vexcms/blocks/Hero/config.ts +27 -12
- package/templates/marketing-site/src/vexcms/blocks/Hero/index.tsx +174 -119
- package/templates/marketing-site/src/vexcms/blocks/HowItWorks/config.ts +2 -1
- package/templates/marketing-site/src/vexcms/blocks/HowItWorks/index.tsx +59 -51
- package/templates/marketing-site/src/vexcms/blocks/Roadmap/config.ts +26 -5
- package/templates/marketing-site/src/vexcms/blocks/Roadmap/index.tsx +256 -73
- package/templates/marketing-site/src/vexcms/blocks/Split/config.ts +62 -0
- package/templates/marketing-site/src/vexcms/blocks/Split/index.tsx +186 -0
- package/templates/marketing-site/src/vexcms/blocks/Stats/config.ts +35 -0
- package/templates/marketing-site/src/vexcms/blocks/Stats/index.tsx +77 -0
- package/templates/marketing-site/src/vexcms/blocks/config.ts +27 -11
- package/templates/marketing-site/src/vexcms/blocks/constants.ts +3 -0
- package/templates/marketing-site/src/vexcms/blocks/index.ts +9 -0
- package/templates/marketing-site/src/vexcms/collections/footers.ts +1 -0
- package/templates/marketing-site/src/vexcms/collections/headers.ts +1 -0
- package/templates/marketing-site/src/vexcms/collections/pages.ts +23 -1
- package/templates/marketing-site/src/vexcms/collections/themeColors.ts +14 -14
- package/templates/marketing-site/src/vexcms/collections/users.ts +20 -0
- package/dist/index.mjs +0 -30976
- package/templates/marketing-site/src/app/(frontend)/PageContent.tsx +0 -53
- package/templates/marketing-site/src/app/(frontend)/[slug]/page.tsx +0 -25
- 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
|
-
|
|
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
|
-
|
|
16
|
-
|
|
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
|
-
|
|
13
|
+
/** Seconds before the first segment starts animating. */
|
|
23
14
|
delay?: number
|
|
24
|
-
|
|
25
|
-
onAnimationStart?: () => void
|
|
15
|
+
/** Split granularity for the stagger. Lines split on `\n`. */
|
|
26
16
|
per?: PerType
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
217
|
-
segmentTransition,
|
|
218
|
-
segmentWrapperClassName,
|
|
219
|
-
speedReveal = 1,
|
|
220
|
-
speedSegment = 1,
|
|
221
|
-
style,
|
|
222
|
-
trigger = true,
|
|
223
|
-
variants,
|
|
38
|
+
stagger,
|
|
224
39
|
}: TextEffectProps) {
|
|
225
|
-
const
|
|
226
|
-
const
|
|
227
|
-
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
<
|
|
267
|
-
{
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
</
|
|
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
|
+
}
|