@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.
Files changed (92) hide show
  1. package/dist/head/css/loader-apple.css +1 -1
  2. package/dist/head/css/loader-dots.css +1 -1
  3. package/dist/head/css/themes/amber.css +1 -1
  4. package/dist/head/css/themes/arcade.css +1 -1
  5. package/dist/head/css/themes/grannysmith.css +1 -1
  6. package/dist/head/css/themes/honeycrisp.css +1 -1
  7. package/dist/head/css/themes/midnight.css +1 -1
  8. package/dist/head/css/themes/mulled.css +1 -1
  9. package/dist/node.mjs +1110 -28
  10. package/dist/theme/components/home/cta.css +15 -0
  11. package/dist/theme/components/home/cta.tsx +10 -2
  12. package/dist/theme/components/home/feature-card.css +1 -1
  13. package/dist/theme/components/home/feature-card.tsx +3 -2
  14. package/dist/theme/components/home/feature.tsx +34 -31
  15. package/dist/theme/components/home/hero-demo.css +11 -0
  16. package/dist/theme/components/home/home-visual.tsx +226 -0
  17. package/dist/theme/components/home/layout.tsx +350 -171
  18. package/dist/theme/components/home/split.css +46 -19
  19. package/dist/theme/components/home/split.tsx +28 -8
  20. package/dist/theme/components/home/tabs.css +299 -0
  21. package/dist/theme/components/home/tabs.tsx +351 -0
  22. package/dist/theme/components/home/trust-strip.css +41 -0
  23. package/dist/theme/components/home/trust-strip.tsx +168 -11
  24. package/dist/theme/components/home/workspaces.tsx +171 -69
  25. package/dist/theme/components/seo-head-data.test.ts +196 -0
  26. package/dist/theme/components/seo-head-data.ts +490 -0
  27. package/dist/theme/components/seo-head.tsx +102 -0
  28. package/dist/theme/components/shared/IssueLinkIcon.tsx +42 -0
  29. package/dist/theme/components/shared/issue-link.css +74 -0
  30. package/dist/theme/components/workspaces/card.css +4 -0
  31. package/dist/theme/components/workspaces/card.tsx +3 -3
  32. package/dist/theme/components/workspaces/grid.tsx +18 -6
  33. package/dist/theme/index.tsx +6 -0
  34. package/dist/theme/lib/rich-text-parse.ts +740 -0
  35. package/dist/theme/lib/rich-text.test.tsx +342 -0
  36. package/dist/theme/lib/rich-text.tsx +86 -0
  37. package/dist/theme/lib/seo-url.ts +28 -0
  38. package/dist/theme/styles/overrides/fonts.css +7 -4
  39. package/dist/theme/styles/overrides/home.css +11 -2
  40. package/dist/theme/styles/overrides/rspress.css +10 -5
  41. package/dist/theme/styles/overrides/tokens.css +41 -23
  42. package/dist/theme/styles/rich-text.css +106 -0
  43. package/dist/theme/styles/themes/amber.css +32 -2
  44. package/dist/theme/styles/themes/arcade.css +16 -1
  45. package/dist/theme/styles/themes/grannysmith.css +32 -2
  46. package/dist/theme/styles/themes/honeycrisp.css +37 -7
  47. package/dist/theme/styles/themes/midnight.css +16 -1
  48. package/dist/theme/styles/themes/mulled.css +54 -9
  49. package/package.json +6 -5
  50. package/src/theme/components/home/cta.css +15 -0
  51. package/src/theme/components/home/cta.tsx +10 -2
  52. package/src/theme/components/home/feature-card.css +1 -1
  53. package/src/theme/components/home/feature-card.tsx +3 -2
  54. package/src/theme/components/home/feature.tsx +34 -31
  55. package/src/theme/components/home/hero-demo.css +11 -0
  56. package/src/theme/components/home/home-visual.tsx +226 -0
  57. package/src/theme/components/home/layout.tsx +350 -171
  58. package/src/theme/components/home/split.css +46 -19
  59. package/src/theme/components/home/split.tsx +28 -8
  60. package/src/theme/components/home/tabs.css +299 -0
  61. package/src/theme/components/home/tabs.tsx +351 -0
  62. package/src/theme/components/home/trust-strip.css +41 -0
  63. package/src/theme/components/home/trust-strip.tsx +168 -11
  64. package/src/theme/components/home/workspaces.tsx +171 -69
  65. package/src/theme/components/seo-head-data.test.ts +196 -0
  66. package/src/theme/components/seo-head-data.ts +490 -0
  67. package/src/theme/components/seo-head.tsx +102 -0
  68. package/src/theme/components/shared/IssueLinkIcon.tsx +42 -0
  69. package/src/theme/components/shared/issue-link.css +74 -0
  70. package/src/theme/components/workspaces/card.css +4 -0
  71. package/src/theme/components/workspaces/card.tsx +3 -3
  72. package/src/theme/components/workspaces/grid.tsx +18 -6
  73. package/src/theme/index.tsx +6 -0
  74. package/src/theme/lib/rich-text-parse.ts +740 -0
  75. package/src/theme/lib/rich-text.test.tsx +342 -0
  76. package/src/theme/lib/rich-text.tsx +86 -0
  77. package/src/theme/lib/seo-url.ts +28 -0
  78. package/src/theme/styles/overrides/fonts.css +7 -4
  79. package/src/theme/styles/overrides/home.css +11 -2
  80. package/src/theme/styles/overrides/rspress.css +10 -5
  81. package/src/theme/styles/overrides/tokens.css +41 -23
  82. package/src/theme/styles/rich-text.css +106 -0
  83. package/src/theme/styles/themes/amber.css +32 -2
  84. package/src/theme/styles/themes/arcade.css +16 -1
  85. package/src/theme/styles/themes/grannysmith.css +32 -2
  86. package/src/theme/styles/themes/honeycrisp.css +37 -7
  87. package/src/theme/styles/themes/midnight.css +16 -1
  88. package/src/theme/styles/themes/mulled.css +54 -9
  89. package/dist/theme/components/home/hero-demo-custom.tsx +0 -116
  90. package/dist/theme/components/home/split-visual-custom.tsx +0 -26
  91. package/src/theme/components/home/hero-demo-custom.tsx +0 -116
  92. 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 { CustomHeroDemo } from './hero-demo-custom'
14
+ import { HomeVisualView } from './home-visual'
19
15
  import { PageRail } from './page-rail'
20
16
  import { HomeSplit } from './split'
21
- import { CustomSplitVisual } from './split-visual-custom'
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 FrontmatterProof {
54
+ interface FrontmatterProofBlock {
55
+ readonly type: 'proof'
41
56
  readonly lead?: string
42
- readonly names?: readonly string[]
57
+ readonly names?: readonly TrustItem[]
43
58
  }
44
59
 
45
- interface FrontmatterCTA {
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 subtitle?: string
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 approved mockup landing surface inside a continuous PageRail:
55
- * Hero → TrustStrip → Features → Workspaces → CTA → SiteFooter. Sections
56
- * render only when their data is present in frontmatter, so consumers opt
57
- * in to each band individually.
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 - Slot props (kept for API compatibility with Rspress's HomeLayout).
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
- const proof = fm.proof as FrontmatterProof | undefined
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 | HomeHeroDemoConfig | undefined
86
- const splitFm = fm.split as false | HomeSplitConfig | undefined
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(undefined, () => <HeroDemo />)
91
- .otherwise((d) => <CustomHeroDemo config={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.label}
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
- const proofSection = match(proof)
106
- .with(undefined, () => null)
107
- .otherwise((t) => {
108
- const names = t.names ?? []
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={t.lead} names={names} />)
209
+ .otherwise(() => <TrustStrip lead={b.lead} names={names} />)
112
210
  })
113
-
114
- const ctaSection = match(cta)
115
- .with(undefined, () => null)
116
- .otherwise((c) =>
117
- match(c.title === undefined)
118
- .with(true, () => null)
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(P.nonNullable, (s) => (
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={s.label}
148
- title={s.title}
149
- body={s.body}
150
- bullets={s.bullets ?? []}
151
- action={match(s.cta)
152
- .with(undefined, () => undefined)
153
- .otherwise((c) => ({ theme: 'brand' as const, text: c.text, link: c.href }))}
154
- visual={match(s.visual)
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
- <CustomSplitVisual code={v.code} language={v.language} />
238
+ <HomeVisualView visual={v} context="split" />
158
239
  ))}
159
240
  />
160
241
  ))
161
- .exhaustive()
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
- // Section render order. `home.layout` (when provided) controls both
164
- // order and visibility — sections omitted from the array are not
165
- // rendered. The framework default (`DEFAULT_HOME_LAYOUT`) preserves
166
- // the historical fixed order. Unknown ids are ignored at render time
167
- // (the schema rejects them at config-load anyway).
168
- const layout = (fm.layout as readonly HomeSectionId[] | undefined) ?? DEFAULT_HOME_LAYOUT
169
- const sectionsById: Readonly<Record<HomeSectionId, React.ReactNode>> = {
170
- hero: (
171
- <>
172
- {props.beforeHero}
173
- {heroSection}
174
- {props.afterHero}
175
- </>
176
- ),
177
- proof: proofSection,
178
- features: (
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
- return (
191
- <PageRail>
192
- {layout.map((id) => (
193
- <React.Fragment key={id}>{sectionsById[id]}</React.Fragment>
194
- ))}
195
- <SiteFooter />
196
- </PageRail>
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
- * ConfigPreview — minimal `defineConfig` code preview shown inside the
202
- * Split section. Imports come exclusively from `ciderpress` so the
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
- * @returns React element.
335
+ * @private
336
+ * @param value - Unvalidated entry from frontmatter
337
+ * @returns True when the entry can be rendered
206
338
  */
207
- function ConfigPreview(): React.ReactElement {
208
- return (
209
- <pre>
210
- <span className="tok-kw">import</span> {'{ defineConfig }'}{' '}
211
- <span className="tok-kw">from</span> <span className="tok-str">'ciderpress'</span>
212
- {'\n\n'}
213
- <span className="tok-kw">export default</span> <span className="tok-fn">defineConfig</span>
214
- {'({\n'}
215
- {' title: '}
216
- <span className="tok-str">'Acme Docs'</span>
217
- {',\n'}
218
- {' pages: [\n'}
219
- {' { title: '}
220
- <span className="tok-str">'Guides'</span>
221
- {', include: '}
222
- <span className="tok-str">'docs/guides/*.md'</span>
223
- {' },\n'}
224
- {' ],\n'}
225
- {' theme: { themes: ['}
226
- <span className="tok-str">'mulled'</span>
227
- {'] },\n'}
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
- * Render a hero title with the trailing segment styled as a gradient.
364
+ * Collect a block's flat `label` / `title` / `body` keys into the heading
365
+ * object the grid components take.
235
366
  *
236
- * Splits the title on its last word break and wraps the tail in
237
- * `<span className="cp-hero__grad">`. When the title is a single word
238
- * (or empty), it renders verbatim.
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 with a gradient tail when applicable.
414
+ * @returns Title fragment.
243
415
  */
244
416
  function renderTitle(raw: string): React.ReactNode {
245
417
  const trimmed = raw.trim()
246
- return match(trimmed.length === 0)
247
- .with(true, () => null)
248
- .otherwise(() => {
249
- const words = trimmed.split(/\s+/)
250
- return match(words.length <= 1)
251
- .with(true, () => trimmed)
252
- .otherwise(() => {
253
- const tailCount = Math.max(1, Math.ceil(words.length / 2))
254
- const headWords = words.slice(0, words.length - tailCount).join(' ')
255
- const tailWords = words.slice(words.length - tailCount).join(' ')
256
- return (
257
- <>
258
- {headWords}
259
- <span className="cp-hero__grad"> {tailWords}</span>
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 new `home.hero`
268
- * / `home.cta` configs) into the legacy `HeroAction[]` shape still
269
- * consumed by `<Hero />` and `<CTA />`. `'primary'` → `'brand'`,
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 === undefined) {
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 new `'primary' | 'secondary' | 'ghost'` variant token
291
- * back into the legacy `'brand' | 'alt'` token that `<Hero />` accepts.
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
- .with('ghost', () => 'alt' as const)
305
- .exhaustive()
483
+ .with(P.union('secondary', 'ghost'), () => 'alt' as const)
484
+ .otherwise(() => undefined)
306
485
  }