@ciderpress/ui 1.0.0-rc.10 → 1.0.0-rc.12
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/dist/head/css/loader-apple.css +1 -1
- package/dist/head/css/loader-dots.css +1 -1
- package/dist/head/css/themes/amber.css +1 -1
- package/dist/head/css/themes/arcade.css +1 -1
- package/dist/head/css/themes/grannysmith.css +1 -1
- package/dist/head/css/themes/honeycrisp.css +1 -1
- package/dist/head/css/themes/midnight.css +1 -1
- package/dist/head/css/themes/mulled.css +1 -1
- package/dist/node.mjs +1110 -28
- package/dist/theme/components/home/cta.css +15 -0
- package/dist/theme/components/home/cta.tsx +10 -2
- package/dist/theme/components/home/feature-card.css +1 -1
- package/dist/theme/components/home/feature-card.tsx +3 -2
- package/dist/theme/components/home/feature.tsx +34 -31
- package/dist/theme/components/home/hero-demo.css +11 -0
- package/dist/theme/components/home/home-visual.tsx +226 -0
- package/dist/theme/components/home/layout.tsx +350 -171
- package/dist/theme/components/home/split.css +46 -19
- package/dist/theme/components/home/split.tsx +28 -8
- package/dist/theme/components/home/tabs.css +299 -0
- package/dist/theme/components/home/tabs.tsx +351 -0
- package/dist/theme/components/home/trust-strip.css +41 -0
- package/dist/theme/components/home/trust-strip.tsx +168 -11
- package/dist/theme/components/home/workspaces.tsx +171 -69
- package/dist/theme/components/seo-head-data.test.ts +196 -0
- package/dist/theme/components/seo-head-data.ts +490 -0
- package/dist/theme/components/seo-head.tsx +102 -0
- package/dist/theme/components/shared/IssueLinkIcon.tsx +42 -0
- package/dist/theme/components/shared/issue-link.css +74 -0
- package/dist/theme/components/workspaces/card.css +4 -0
- package/dist/theme/components/workspaces/card.tsx +3 -3
- package/dist/theme/components/workspaces/grid.tsx +18 -6
- package/dist/theme/index.tsx +6 -0
- package/dist/theme/lib/rich-text-parse.ts +740 -0
- package/dist/theme/lib/rich-text.test.tsx +342 -0
- package/dist/theme/lib/rich-text.tsx +86 -0
- package/dist/theme/lib/seo-url.ts +28 -0
- package/dist/theme/styles/overrides/fonts.css +7 -4
- package/dist/theme/styles/overrides/home.css +11 -2
- package/dist/theme/styles/overrides/rspress.css +10 -5
- package/dist/theme/styles/overrides/tokens.css +41 -23
- package/dist/theme/styles/rich-text.css +106 -0
- package/dist/theme/styles/themes/amber.css +32 -2
- package/dist/theme/styles/themes/arcade.css +16 -1
- package/dist/theme/styles/themes/grannysmith.css +32 -2
- package/dist/theme/styles/themes/honeycrisp.css +37 -7
- package/dist/theme/styles/themes/midnight.css +16 -1
- package/dist/theme/styles/themes/mulled.css +54 -9
- package/package.json +6 -5
- package/src/theme/components/home/cta.css +15 -0
- package/src/theme/components/home/cta.tsx +10 -2
- package/src/theme/components/home/feature-card.css +1 -1
- package/src/theme/components/home/feature-card.tsx +3 -2
- package/src/theme/components/home/feature.tsx +34 -31
- package/src/theme/components/home/hero-demo.css +11 -0
- package/src/theme/components/home/home-visual.tsx +226 -0
- package/src/theme/components/home/layout.tsx +350 -171
- package/src/theme/components/home/split.css +46 -19
- package/src/theme/components/home/split.tsx +28 -8
- package/src/theme/components/home/tabs.css +299 -0
- package/src/theme/components/home/tabs.tsx +351 -0
- package/src/theme/components/home/trust-strip.css +41 -0
- package/src/theme/components/home/trust-strip.tsx +168 -11
- package/src/theme/components/home/workspaces.tsx +171 -69
- package/src/theme/components/seo-head-data.test.ts +196 -0
- package/src/theme/components/seo-head-data.ts +490 -0
- package/src/theme/components/seo-head.tsx +102 -0
- package/src/theme/components/shared/IssueLinkIcon.tsx +42 -0
- package/src/theme/components/shared/issue-link.css +74 -0
- package/src/theme/components/workspaces/card.css +4 -0
- package/src/theme/components/workspaces/card.tsx +3 -3
- package/src/theme/components/workspaces/grid.tsx +18 -6
- package/src/theme/index.tsx +6 -0
- package/src/theme/lib/rich-text-parse.ts +740 -0
- package/src/theme/lib/rich-text.test.tsx +342 -0
- package/src/theme/lib/rich-text.tsx +86 -0
- package/src/theme/lib/seo-url.ts +28 -0
- package/src/theme/styles/overrides/fonts.css +7 -4
- package/src/theme/styles/overrides/home.css +11 -2
- package/src/theme/styles/overrides/rspress.css +10 -5
- package/src/theme/styles/overrides/tokens.css +41 -23
- package/src/theme/styles/rich-text.css +106 -0
- package/src/theme/styles/themes/amber.css +32 -2
- package/src/theme/styles/themes/arcade.css +16 -1
- package/src/theme/styles/themes/grannysmith.css +32 -2
- package/src/theme/styles/themes/honeycrisp.css +37 -7
- package/src/theme/styles/themes/midnight.css +16 -1
- package/src/theme/styles/themes/mulled.css +54 -9
- package/dist/theme/components/home/hero-demo-custom.tsx +0 -116
- package/dist/theme/components/home/split-visual-custom.tsx +0 -26
- package/src/theme/components/home/hero-demo-custom.tsx +0 -116
- package/src/theme/components/home/split-visual-custom.tsx +0 -26
|
@@ -1,32 +1,29 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
ButtonConfig,
|
|
3
|
-
HomeHeroDemoConfig,
|
|
4
|
-
HomeSectionId,
|
|
5
|
-
HomeSplitConfig,
|
|
6
|
-
} from '@ciderpress/config'
|
|
7
|
-
import { DEFAULT_HOME_LAYOUT } from '@ciderpress/config'
|
|
1
|
+
import type { ButtonConfig, HomeVisual, TruncateConfig } from '@ciderpress/config'
|
|
8
2
|
import { useFrontmatter } from '@rspress/core/runtime'
|
|
9
3
|
import { match, P } from 'massaman/match'
|
|
10
4
|
import React from 'react'
|
|
11
5
|
|
|
6
|
+
import { hasAccentMarker, isPlainText, renderRichText } from '../../lib/rich-text.tsx'
|
|
12
7
|
import { SiteFooter } from '../footer/site-footer'
|
|
13
8
|
import { CTA } from './cta'
|
|
14
9
|
import { HomeFeature } from './feature'
|
|
10
|
+
import type { FeatureItem } from './feature-card'
|
|
15
11
|
import { Hero } from './hero'
|
|
16
12
|
import type { HeroAction } from './hero'
|
|
17
13
|
import { HeroDemo } from './hero-demo'
|
|
18
|
-
import {
|
|
14
|
+
import { HomeVisualView } from './home-visual'
|
|
19
15
|
import { PageRail } from './page-rail'
|
|
20
16
|
import { HomeSplit } from './split'
|
|
21
|
-
import {
|
|
17
|
+
import { HomeTabs } from './tabs'
|
|
18
|
+
import type { HomeTabEntry } from './tabs'
|
|
22
19
|
import { TrustStrip } from './trust-strip'
|
|
20
|
+
import type { TrustItem } from './trust-strip'
|
|
23
21
|
import { HomeWorkspaces } from './workspaces'
|
|
22
|
+
import type { ShowcaseCard } from './workspaces'
|
|
24
23
|
|
|
25
24
|
interface HomeLayoutProps {
|
|
26
25
|
readonly beforeHero?: React.ReactNode
|
|
27
26
|
readonly afterHero?: React.ReactNode
|
|
28
|
-
readonly beforeFeatures?: React.ReactNode
|
|
29
|
-
readonly afterFeatures?: React.ReactNode
|
|
30
27
|
}
|
|
31
28
|
|
|
32
29
|
interface FrontmatterHero {
|
|
@@ -34,29 +31,102 @@ interface FrontmatterHero {
|
|
|
34
31
|
readonly text?: string
|
|
35
32
|
readonly tagline?: string
|
|
36
33
|
readonly actions?: readonly ButtonConfig[]
|
|
34
|
+
/**
|
|
35
|
+
* Eyebrow chip copy. The sync engine reads `hero.label` from the config
|
|
36
|
+
* and emits it under this key (see `emitHero` in
|
|
37
|
+
* `@ciderpress/cli/lib/sync/home.ts`), so the frontmatter name is
|
|
38
|
+
* `eyebrow`, not `label`.
|
|
39
|
+
*/
|
|
40
|
+
readonly eyebrow?: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Flat heading trio carried by every copy-bearing block.
|
|
45
|
+
*
|
|
46
|
+
* @private
|
|
47
|
+
*/
|
|
48
|
+
interface FrontmatterHeading {
|
|
37
49
|
readonly label?: string
|
|
50
|
+
readonly title?: string
|
|
51
|
+
readonly body?: string
|
|
38
52
|
}
|
|
39
53
|
|
|
40
|
-
interface
|
|
54
|
+
interface FrontmatterProofBlock {
|
|
55
|
+
readonly type: 'proof'
|
|
41
56
|
readonly lead?: string
|
|
42
|
-
readonly names?: readonly
|
|
57
|
+
readonly names?: readonly TrustItem[]
|
|
43
58
|
}
|
|
44
59
|
|
|
45
|
-
interface
|
|
60
|
+
interface FrontmatterFeaturesBlock extends FrontmatterHeading {
|
|
61
|
+
readonly type: 'features'
|
|
62
|
+
readonly items?: readonly FeatureItem[]
|
|
63
|
+
readonly columns?: 1 | 2 | 3 | 4
|
|
64
|
+
readonly truncate?: TruncateConfig
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface FrontmatterShowcaseBlock extends FrontmatterHeading {
|
|
68
|
+
readonly type: 'showcase'
|
|
69
|
+
readonly cards?: readonly ShowcaseCard[]
|
|
70
|
+
readonly columns?: 1 | 2 | 3 | 4
|
|
71
|
+
readonly truncate?: TruncateConfig
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface FrontmatterSplitBlock {
|
|
75
|
+
readonly type: 'split'
|
|
76
|
+
readonly label?: string
|
|
77
|
+
readonly title: string
|
|
78
|
+
readonly body?: string
|
|
79
|
+
readonly bullets?: readonly string[]
|
|
80
|
+
readonly cta?: ButtonConfig
|
|
81
|
+
readonly visual?: HomeVisual
|
|
82
|
+
readonly reverse?: boolean
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Tab entry as it arrives from frontmatter — the CTA is still a
|
|
87
|
+
* `ButtonConfig`, not the component's `link`/`theme` shape.
|
|
88
|
+
*
|
|
89
|
+
* @private
|
|
90
|
+
*/
|
|
91
|
+
interface FrontmatterTabItem {
|
|
92
|
+
readonly label: string
|
|
93
|
+
readonly icon?: HomeTabEntry['icon']
|
|
46
94
|
readonly title?: string
|
|
47
|
-
readonly
|
|
95
|
+
readonly body?: string
|
|
96
|
+
readonly bullets?: readonly string[]
|
|
97
|
+
readonly cta?: ButtonConfig
|
|
98
|
+
readonly visual?: HomeVisual
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
interface FrontmatterTabsBlock extends FrontmatterHeading {
|
|
102
|
+
readonly type: 'tabs'
|
|
103
|
+
readonly items?: readonly FrontmatterTabItem[]
|
|
104
|
+
readonly orientation?: 'vertical' | 'horizontal'
|
|
105
|
+
readonly reverse?: boolean
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
interface FrontmatterCtaBlock extends FrontmatterHeading {
|
|
109
|
+
readonly type: 'cta'
|
|
48
110
|
readonly actions?: readonly ButtonConfig[]
|
|
49
111
|
}
|
|
50
112
|
|
|
113
|
+
type FrontmatterBlock =
|
|
114
|
+
| FrontmatterProofBlock
|
|
115
|
+
| FrontmatterFeaturesBlock
|
|
116
|
+
| FrontmatterShowcaseBlock
|
|
117
|
+
| FrontmatterSplitBlock
|
|
118
|
+
| FrontmatterTabsBlock
|
|
119
|
+
| FrontmatterCtaBlock
|
|
120
|
+
|
|
51
121
|
/**
|
|
52
122
|
* Custom HomeLayout for ciderpress.
|
|
53
123
|
*
|
|
54
|
-
* Renders the
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
124
|
+
* Renders the hero header followed by an ordered `blocks` array (proof,
|
|
125
|
+
* features, showcase, split, tabs, cta) inside a continuous PageRail. Block
|
|
126
|
+
* order is the array order and any block type may repeat — the whole deck
|
|
127
|
+
* is driven by `home.blocks`, compiled into frontmatter by the sync engine.
|
|
58
128
|
*
|
|
59
|
-
* @param props -
|
|
129
|
+
* @param props - Hero slot props (kept for API compatibility with Rspress's HomeLayout).
|
|
60
130
|
* @returns React element with the home page.
|
|
61
131
|
*/
|
|
62
132
|
export function HomeLayout(props: HomeLayoutProps): React.ReactElement {
|
|
@@ -76,197 +146,303 @@ export function HomeLayout(props: HomeLayoutProps): React.ReactElement {
|
|
|
76
146
|
const fm = frontmatter as Record<string, unknown>
|
|
77
147
|
|
|
78
148
|
const hero = fm.hero as FrontmatterHero | undefined
|
|
79
|
-
|
|
80
|
-
const cta = fm.cta as FrontmatterCTA | undefined
|
|
81
|
-
// heroDemo / split frontmatter:
|
|
149
|
+
// heroDemo frontmatter:
|
|
82
150
|
// undefined → render the framework default
|
|
83
151
|
// false → suppress entirely
|
|
84
152
|
// object → render the user-supplied custom variant
|
|
85
|
-
const heroDemoFm = fm.heroDemo as false |
|
|
86
|
-
const
|
|
153
|
+
const heroDemoFm = fm.heroDemo as false | HomeVisual | undefined
|
|
154
|
+
const blocks = (fm.blocks as readonly FrontmatterBlock[] | undefined) ?? []
|
|
87
155
|
|
|
156
|
+
// `P.nullish`, not `undefined`: a blank `heroDemo:` in a hand-authored
|
|
157
|
+
// index.md parses to null, which misses an `undefined` arm and would
|
|
158
|
+
// reach HomeVisualView with nothing to render.
|
|
88
159
|
const heroDemoEl = match(heroDemoFm)
|
|
89
160
|
.with(false, () => null)
|
|
90
|
-
.with(
|
|
91
|
-
.otherwise((d) => <
|
|
161
|
+
.with(P.nullish, () => <HeroDemo />)
|
|
162
|
+
.otherwise((d) => <HomeVisualView visual={d} context="hero" />)
|
|
92
163
|
|
|
93
164
|
const heroSection = match(hero)
|
|
94
165
|
.with(undefined, () => null)
|
|
95
166
|
.otherwise((h) => (
|
|
96
167
|
<Hero
|
|
97
|
-
eyebrow={h.
|
|
168
|
+
eyebrow={renderOptionalRichText(h.eyebrow)}
|
|
98
169
|
title={renderTitle(h.text ?? h.name ?? '')}
|
|
99
|
-
tagline={h.tagline}
|
|
170
|
+
tagline={renderOptionalRichText(h.tagline)}
|
|
100
171
|
actions={mapButtonsToHeroActions(h.actions)}
|
|
101
172
|
demo={heroDemoEl}
|
|
102
173
|
/>
|
|
103
174
|
))
|
|
104
175
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
176
|
+
return (
|
|
177
|
+
<PageRail>
|
|
178
|
+
{props.beforeHero}
|
|
179
|
+
{heroSection}
|
|
180
|
+
{props.afterHero}
|
|
181
|
+
{blocks.map((block, index) => (
|
|
182
|
+
<React.Fragment key={`${block.type}-${index}`}>{renderBlock(block)}</React.Fragment>
|
|
183
|
+
))}
|
|
184
|
+
<SiteFooter />
|
|
185
|
+
</PageRail>
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Render a single home block by its discriminated `type`.
|
|
191
|
+
*
|
|
192
|
+
* Terminates in `.otherwise`, not `.exhaustive`: `blocks` is read from
|
|
193
|
+
* frontmatter with a cast, and a hand-authored `index.md` short-circuits
|
|
194
|
+
* the sync engine's block compilation entirely, so an unrecognized `type`
|
|
195
|
+
* is reachable user input. `.exhaustive()` would throw `NonExhaustiveError`
|
|
196
|
+
* mid-render and take down the whole page — and the SSG build with it —
|
|
197
|
+
* over one typo. An unknown block renders as nothing instead.
|
|
198
|
+
*
|
|
199
|
+
* @private
|
|
200
|
+
* @param block - Frontmatter block compiled from `home.blocks`.
|
|
201
|
+
* @returns The block's React element, or null when it has no renderable data.
|
|
202
|
+
*/
|
|
203
|
+
function renderBlock(block: FrontmatterBlock): React.ReactNode {
|
|
204
|
+
return match(block)
|
|
205
|
+
.with({ type: 'proof' }, (b) => {
|
|
206
|
+
const names = b.names ?? []
|
|
109
207
|
return match(names.length === 0)
|
|
110
208
|
.with(true, () => null)
|
|
111
|
-
.otherwise(() => <TrustStrip lead={
|
|
209
|
+
.otherwise(() => <TrustStrip lead={b.lead} names={names} />)
|
|
112
210
|
})
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
.
|
|
119
|
-
.otherwise(() => (
|
|
120
|
-
<CTA
|
|
121
|
-
title={c.title ?? ''}
|
|
122
|
-
subtitle={c.subtitle}
|
|
123
|
-
actions={mapButtonsToHeroActions(c.actions)}
|
|
124
|
-
/>
|
|
125
|
-
))
|
|
126
|
-
)
|
|
127
|
-
|
|
128
|
-
const splitSection = match(splitFm)
|
|
129
|
-
.with(false, () => null)
|
|
130
|
-
.with(undefined, () => (
|
|
131
|
-
<HomeSplit
|
|
132
|
-
eyebrow="Configuration"
|
|
133
|
-
title="One file. Validated. Type-safe."
|
|
134
|
-
body="Define your docs site in ciderpress.config.ts. Zod validates at boot — no surprises in prod."
|
|
135
|
-
bullets={[
|
|
136
|
-
'Type-safe config with full IntelliSense',
|
|
137
|
-
'Hot-reloads on every save',
|
|
138
|
-
'Composable presets for OpenAPI, blog, changelog',
|
|
139
|
-
'First-class i18n out of the box',
|
|
140
|
-
]}
|
|
141
|
-
action={{ theme: 'brand', text: 'Read the docs', link: '/getting-started/quick-start' }}
|
|
142
|
-
visual={<ConfigPreview />}
|
|
211
|
+
.with({ type: 'features' }, (b) => (
|
|
212
|
+
<HomeFeature
|
|
213
|
+
items={b.items}
|
|
214
|
+
heading={toHeading(b)}
|
|
215
|
+
columns={b.columns}
|
|
216
|
+
truncate={b.truncate}
|
|
143
217
|
/>
|
|
144
218
|
))
|
|
145
|
-
.with(
|
|
219
|
+
.with({ type: 'showcase' }, (b) => (
|
|
220
|
+
<HomeWorkspaces
|
|
221
|
+
heading={toHeading(b)}
|
|
222
|
+
cards={b.cards}
|
|
223
|
+
columns={b.columns}
|
|
224
|
+
truncate={b.truncate}
|
|
225
|
+
/>
|
|
226
|
+
))
|
|
227
|
+
.with({ type: 'split' }, (b) => (
|
|
146
228
|
<HomeSplit
|
|
147
|
-
eyebrow={
|
|
148
|
-
title={
|
|
149
|
-
body={
|
|
150
|
-
bullets={
|
|
151
|
-
action={
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
.with(undefined, () => null)
|
|
229
|
+
eyebrow={b.label}
|
|
230
|
+
title={renderRichText(b.title)}
|
|
231
|
+
body={renderOptionalRichText(b.body)}
|
|
232
|
+
bullets={b.bullets ?? []}
|
|
233
|
+
action={mapButtonToAction(b.cta)}
|
|
234
|
+
reverse={b.reverse}
|
|
235
|
+
visual={match(b.visual)
|
|
236
|
+
.with(P.nullish, () => null)
|
|
156
237
|
.otherwise((v) => (
|
|
157
|
-
<
|
|
238
|
+
<HomeVisualView visual={v} context="split" />
|
|
158
239
|
))}
|
|
159
240
|
/>
|
|
160
241
|
))
|
|
161
|
-
.
|
|
242
|
+
.with({ type: 'tabs' }, (b) => (
|
|
243
|
+
<HomeTabs
|
|
244
|
+
eyebrow={b.label}
|
|
245
|
+
title={b.title}
|
|
246
|
+
body={b.body}
|
|
247
|
+
items={mapTabItems(b.items)}
|
|
248
|
+
orientation={b.orientation}
|
|
249
|
+
reverse={b.reverse}
|
|
250
|
+
/>
|
|
251
|
+
))
|
|
252
|
+
.with({ type: 'cta' }, (b) =>
|
|
253
|
+
// Blank/absent title, not just `undefined` — a hand-authored `title:`
|
|
254
|
+
// parses to null and `title: ''` is empty, and either would paint the
|
|
255
|
+
// full band (eyebrow, glow, buttons) around an empty headline.
|
|
256
|
+
match(renderOptionalRichText(b.title))
|
|
257
|
+
.with(P.nullish, () => null)
|
|
258
|
+
.otherwise((title) => (
|
|
259
|
+
<CTA
|
|
260
|
+
eyebrow={b.label}
|
|
261
|
+
title={title}
|
|
262
|
+
subtitle={renderOptionalRichText(b.body)}
|
|
263
|
+
actions={mapButtonsToHeroActions(b.actions)}
|
|
264
|
+
/>
|
|
265
|
+
))
|
|
266
|
+
)
|
|
267
|
+
.otherwise(() => null)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Map a single `ButtonConfig` into the `link`/`theme` action shape the
|
|
272
|
+
* split and tab components take, preserving the configured variant.
|
|
273
|
+
*
|
|
274
|
+
* @private
|
|
275
|
+
* @param button - Optional button config from frontmatter
|
|
276
|
+
* @returns Action object, or undefined when no button is configured
|
|
277
|
+
*/
|
|
278
|
+
function mapButtonToAction(
|
|
279
|
+
button: ButtonConfig | undefined
|
|
280
|
+
): { readonly text: string; readonly link: string; readonly theme?: 'brand' | 'alt' } | undefined {
|
|
281
|
+
if (!isButtonConfig(button)) {
|
|
282
|
+
return undefined
|
|
283
|
+
}
|
|
284
|
+
return {
|
|
285
|
+
text: button.text,
|
|
286
|
+
link: button.href,
|
|
287
|
+
theme: mapButtonVariantToHeroTheme(button.variant),
|
|
288
|
+
}
|
|
289
|
+
}
|
|
162
290
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
<>
|
|
180
|
-
{props.beforeFeatures}
|
|
181
|
-
<HomeFeature />
|
|
182
|
-
{props.afterFeatures}
|
|
183
|
-
</>
|
|
184
|
-
),
|
|
185
|
-
split: splitSection,
|
|
186
|
-
showcase: <HomeWorkspaces />,
|
|
187
|
-
cta: ctaSection,
|
|
291
|
+
/**
|
|
292
|
+
* Convert frontmatter tab entries into the component's entry shape. Only
|
|
293
|
+
* the CTA needs work — its `ButtonConfig` becomes a `link`/`theme`
|
|
294
|
+
* action; every other field passes through.
|
|
295
|
+
*
|
|
296
|
+
* Malformed entries are dropped rather than rendered: a YAML sequence
|
|
297
|
+
* with a blank item yields `null`, which would throw on the first
|
|
298
|
+
* property read.
|
|
299
|
+
*
|
|
300
|
+
* @private
|
|
301
|
+
* @param items - Tab entries from frontmatter, possibly absent or malformed
|
|
302
|
+
* @returns Component-ready tab entries
|
|
303
|
+
*/
|
|
304
|
+
function mapTabItems(items: readonly FrontmatterTabItem[] | undefined): readonly HomeTabEntry[] {
|
|
305
|
+
if (!Array.isArray(items)) {
|
|
306
|
+
return []
|
|
188
307
|
}
|
|
308
|
+
return items.filter(isTabItem).map(toTabEntry)
|
|
309
|
+
}
|
|
189
310
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
311
|
+
/**
|
|
312
|
+
* Project one validated frontmatter tab entry onto the component's entry
|
|
313
|
+
* shape — only the CTA changes form.
|
|
314
|
+
*
|
|
315
|
+
* @private
|
|
316
|
+
* @param item - Validated tab entry from frontmatter
|
|
317
|
+
* @returns Component-ready tab entry
|
|
318
|
+
*/
|
|
319
|
+
function toTabEntry(item: FrontmatterTabItem): HomeTabEntry {
|
|
320
|
+
return {
|
|
321
|
+
label: item.label,
|
|
322
|
+
icon: item.icon,
|
|
323
|
+
title: item.title,
|
|
324
|
+
body: item.body,
|
|
325
|
+
bullets: item.bullets,
|
|
326
|
+
cta: mapButtonToAction(item.cta),
|
|
327
|
+
visual: item.visual,
|
|
328
|
+
}
|
|
198
329
|
}
|
|
199
330
|
|
|
200
331
|
/**
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
* sample resolves against the published package set.
|
|
332
|
+
* Whether a frontmatter value is a usable tab entry. `label` is the one
|
|
333
|
+
* required field — it is the clickable text and the render key.
|
|
204
334
|
*
|
|
205
|
-
* @
|
|
335
|
+
* @private
|
|
336
|
+
* @param value - Unvalidated entry from frontmatter
|
|
337
|
+
* @returns True when the entry can be rendered
|
|
206
338
|
*/
|
|
207
|
-
function
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
</pre>
|
|
230
|
-
)
|
|
339
|
+
function isTabItem(value: unknown): value is FrontmatterTabItem {
|
|
340
|
+
if (typeof value !== 'object' || value === null) {
|
|
341
|
+
return false
|
|
342
|
+
}
|
|
343
|
+
const item = value as { label?: unknown }
|
|
344
|
+
return typeof item.label === 'string'
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Whether a frontmatter value is a usable button config. Both `text` and
|
|
349
|
+
* `href` must be strings — `safeUrl` would throw on a missing `href`.
|
|
350
|
+
*
|
|
351
|
+
* @private
|
|
352
|
+
* @param value - Unvalidated button from frontmatter
|
|
353
|
+
* @returns True when the button can be rendered
|
|
354
|
+
*/
|
|
355
|
+
function isButtonConfig(value: unknown): value is ButtonConfig {
|
|
356
|
+
if (typeof value !== 'object' || value === null) {
|
|
357
|
+
return false
|
|
358
|
+
}
|
|
359
|
+
const button = value as { text?: unknown; href?: unknown }
|
|
360
|
+
return typeof button.text === 'string' && typeof button.href === 'string'
|
|
231
361
|
}
|
|
232
362
|
|
|
233
363
|
/**
|
|
234
|
-
*
|
|
364
|
+
* Collect a block's flat `label` / `title` / `body` keys into the heading
|
|
365
|
+
* object the grid components take.
|
|
235
366
|
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
367
|
+
* @private
|
|
368
|
+
* @param block - Any block carrying the flat heading trio
|
|
369
|
+
* @returns Heading object for the grid components
|
|
370
|
+
*/
|
|
371
|
+
function toHeading(block: FrontmatterHeading): FrontmatterHeading {
|
|
372
|
+
return { label: block.label, title: block.title, body: block.body }
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Render optional rich-text copy, collapsing absent or blank values to
|
|
377
|
+
* `undefined` rather than an empty node.
|
|
378
|
+
*
|
|
379
|
+
* Callers treat `undefined` as "omit this element entirely". Passing
|
|
380
|
+
* `renderRichText('')` instead returns an empty-but-defined node, which
|
|
381
|
+
* slips past those checks and paints a styled shell with no content in
|
|
382
|
+
* it: an empty eyebrow chip renders as a stray pill above the headline.
|
|
383
|
+
*
|
|
384
|
+
* @private
|
|
385
|
+
* @param raw - Optional raw copy from frontmatter.
|
|
386
|
+
* @returns Rendered fragment, or undefined when there is nothing to show.
|
|
387
|
+
*/
|
|
388
|
+
function renderOptionalRichText(raw: string | undefined): React.ReactNode {
|
|
389
|
+
const value = raw ?? ''
|
|
390
|
+
return match(value.trim() === '')
|
|
391
|
+
.with(true, () => undefined)
|
|
392
|
+
.otherwise(() => renderRichText(value))
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Render a hero title, accenting part of it in the brand colour.
|
|
397
|
+
*
|
|
398
|
+
* Two modes, so the default is good and the override is exact:
|
|
399
|
+
*
|
|
400
|
+
* - **explicit** — a title carrying `**emphasis**` accents precisely
|
|
401
|
+
* what the author marked, and nothing else
|
|
402
|
+
* - **automatic** — a title of unmarked text accents the trailing half
|
|
403
|
+
* of its words, the long-standing behaviour
|
|
404
|
+
*
|
|
405
|
+
* A title carrying any other markup renders as written: the automatic
|
|
406
|
+
* pass works on the raw string, so it would cut links and code spans in
|
|
407
|
+
* half.
|
|
408
|
+
*
|
|
409
|
+
* Either way the copy runs through the inline renderer, so code and
|
|
410
|
+
* links work in a title regardless of which mode applies.
|
|
239
411
|
*
|
|
240
412
|
* @private
|
|
241
413
|
* @param raw - The raw title string from frontmatter.
|
|
242
|
-
* @returns Title fragment
|
|
414
|
+
* @returns Title fragment.
|
|
243
415
|
*/
|
|
244
416
|
function renderTitle(raw: string): React.ReactNode {
|
|
245
417
|
const trimmed = raw.trim()
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
418
|
+
if (trimmed.length === 0) {
|
|
419
|
+
return null
|
|
420
|
+
}
|
|
421
|
+
// The automatic accent slices the raw string, so it can only run on
|
|
422
|
+
// copy with no markup in it — splitting `[a b](/c)` or `` `a b` ``
|
|
423
|
+
// down the middle would produce two broken fragments.
|
|
424
|
+
if (hasAccentMarker(trimmed) || !isPlainText(trimmed)) {
|
|
425
|
+
return renderRichText(trimmed)
|
|
426
|
+
}
|
|
427
|
+
const words = trimmed.split(/\s+/)
|
|
428
|
+
if (words.length <= 1) {
|
|
429
|
+
return renderRichText(trimmed)
|
|
430
|
+
}
|
|
431
|
+
const tailCount = Math.max(1, Math.ceil(words.length / 2))
|
|
432
|
+
const headWords = words.slice(0, words.length - tailCount).join(' ')
|
|
433
|
+
const tailWords = words.slice(words.length - tailCount).join(' ')
|
|
434
|
+
return (
|
|
435
|
+
<>
|
|
436
|
+
{renderRichText(headWords)}
|
|
437
|
+
<span className="cp-hero__grad"> {renderRichText(tailWords)}</span>
|
|
438
|
+
</>
|
|
439
|
+
)
|
|
264
440
|
}
|
|
265
441
|
|
|
266
442
|
/**
|
|
267
|
-
* Map the unified `ButtonConfig[]` shape (used by the
|
|
268
|
-
* /
|
|
269
|
-
*
|
|
443
|
+
* Map the unified `ButtonConfig[]` shape (used by the `home.hero`
|
|
444
|
+
* / cta configs) into the legacy `HeroAction[]` shape still consumed by
|
|
445
|
+
* `<Hero />` and `<CTA />`. `'primary'` → `'brand'`,
|
|
270
446
|
* `'secondary' | 'ghost'` → `'alt'`, `undefined` → `undefined`.
|
|
271
447
|
*
|
|
272
448
|
* @private
|
|
@@ -276,10 +452,10 @@ function renderTitle(raw: string): React.ReactNode {
|
|
|
276
452
|
function mapButtonsToHeroActions(
|
|
277
453
|
actions: readonly ButtonConfig[] | undefined
|
|
278
454
|
): readonly HeroAction[] | undefined {
|
|
279
|
-
if (actions
|
|
455
|
+
if (!Array.isArray(actions)) {
|
|
280
456
|
return undefined
|
|
281
457
|
}
|
|
282
|
-
return actions.map((action) => ({
|
|
458
|
+
return actions.filter(isButtonConfig).map((action) => ({
|
|
283
459
|
text: action.text,
|
|
284
460
|
link: action.href,
|
|
285
461
|
theme: mapButtonVariantToHeroTheme(action.variant),
|
|
@@ -287,8 +463,13 @@ function mapButtonsToHeroActions(
|
|
|
287
463
|
}
|
|
288
464
|
|
|
289
465
|
/**
|
|
290
|
-
* Project the
|
|
291
|
-
*
|
|
466
|
+
* Project the `'primary' | 'secondary' | 'ghost'` variant token back into
|
|
467
|
+
* the legacy `'brand' | 'alt'` token that `<Hero />` accepts.
|
|
468
|
+
*
|
|
469
|
+
* Terminates in `.otherwise`: `isButtonConfig` only checks `text` and
|
|
470
|
+
* `href`, so an unrecognized `variant` on a hand-authored button reaches
|
|
471
|
+
* here. This runs for hero, split, and tab CTAs alike, and `.exhaustive()`
|
|
472
|
+
* threw on all three. An unknown variant falls back to the default style.
|
|
292
473
|
*
|
|
293
474
|
* @private
|
|
294
475
|
* @param variant - Optional variant from the unified button config
|
|
@@ -298,9 +479,7 @@ function mapButtonVariantToHeroTheme(
|
|
|
298
479
|
variant: ButtonConfig['variant']
|
|
299
480
|
): 'brand' | 'alt' | undefined {
|
|
300
481
|
return match(variant)
|
|
301
|
-
.with(undefined, () => undefined)
|
|
302
482
|
.with('primary', () => 'brand' as const)
|
|
303
|
-
.with('secondary', () => 'alt' as const)
|
|
304
|
-
.
|
|
305
|
-
.exhaustive()
|
|
483
|
+
.with(P.union('secondary', 'ghost'), () => 'alt' as const)
|
|
484
|
+
.otherwise(() => undefined)
|
|
306
485
|
}
|