@ciderpress/ui 1.0.0-rc.1 → 1.0.0-rc.11

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 (162) 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/index.d.ts +1 -1
  10. package/dist/index.mjs +4 -3
  11. package/dist/node.d.ts +1 -1
  12. package/dist/node.mjs +732 -55
  13. package/dist/plugins/mermaid/MermaidRenderer.tsx +49 -6
  14. package/dist/theme/components/footer/site-footer.tsx +68 -9
  15. package/dist/theme/components/home/cta.css +15 -0
  16. package/dist/theme/components/home/cta.tsx +10 -2
  17. package/dist/theme/components/home/feature-card.css +1 -1
  18. package/dist/theme/components/home/feature-card.tsx +8 -10
  19. package/dist/theme/components/home/feature.tsx +54 -31
  20. package/dist/theme/components/home/hero-demo.css +38 -0
  21. package/dist/theme/components/home/home-visual.tsx +226 -0
  22. package/dist/theme/components/home/layout.tsx +410 -108
  23. package/dist/theme/components/home/split.css +46 -19
  24. package/dist/theme/components/home/split.tsx +28 -8
  25. package/dist/theme/components/home/tabs.css +299 -0
  26. package/dist/theme/components/home/tabs.tsx +351 -0
  27. package/dist/theme/components/home/trust-strip.css +41 -0
  28. package/dist/theme/components/home/trust-strip.tsx +168 -11
  29. package/dist/theme/components/home/workspaces.tsx +176 -38
  30. package/dist/theme/components/nav/branch-tag.tsx +7 -7
  31. package/dist/theme/components/nav/ciderpress-docs-bar.css +13 -1
  32. package/dist/theme/components/nav/ciderpress-docs-bar.tsx +33 -2
  33. package/dist/theme/components/nav/ciderpress-header.css +2 -1
  34. package/dist/theme/components/nav/ciderpress-header.tsx +26 -3
  35. package/dist/theme/components/nav/ciderpress-nav-hamburger.css +50 -0
  36. package/dist/theme/components/nav/ciderpress-nav-hamburger.tsx +95 -9
  37. package/dist/theme/components/nav/ciderpress-nav-menu.css +88 -0
  38. package/dist/theme/components/nav/ciderpress-nav-menu.tsx +397 -38
  39. package/dist/theme/components/nav/ciderpress-nav-social-links.tsx +27 -22
  40. package/dist/theme/components/nav/floating-branch-indicator.tsx +7 -7
  41. package/dist/theme/components/nav/header-icon.css +24 -0
  42. package/dist/theme/components/nav/header-icon.tsx +119 -0
  43. package/dist/theme/components/nav/header-logo.css +16 -0
  44. package/dist/theme/components/nav/header-logo.tsx +200 -0
  45. package/dist/theme/components/nav/layout.tsx +117 -16
  46. package/dist/theme/components/nav/nav-logo.tsx +16 -19
  47. package/dist/theme/components/openapi/index.ts +0 -2
  48. package/dist/theme/components/openapi/openapi.css +0 -14
  49. package/dist/theme/components/shared/card-icon.tsx +37 -0
  50. package/dist/theme/components/shared/ciderpress-logo.tsx +5 -1
  51. package/dist/theme/components/shared/icon.tsx +120 -35
  52. package/dist/theme/components/shared/resolve-card-icon.ts +53 -12
  53. package/dist/theme/components/shared/section-card.tsx +37 -6
  54. package/dist/theme/components/sidebar/sidebar-badge.css +50 -0
  55. package/dist/theme/components/sidebar/sidebar-badge.tsx +153 -0
  56. package/dist/theme/components/sidebar/sidebar-links.tsx +6 -2
  57. package/dist/theme/components/sidebar/sidebar-scope.tsx +24 -1
  58. package/dist/theme/components/theme-provider.tsx +27 -11
  59. package/dist/theme/components/workspaces/card.css +4 -0
  60. package/dist/theme/components/workspaces/card.tsx +10 -12
  61. package/dist/theme/components/workspaces/grid.tsx +18 -6
  62. package/dist/theme/hooks/use-ciderpress.ts +65 -5
  63. package/dist/theme/hooks/use-nav-items.ts +82 -11
  64. package/dist/theme/index.tsx +10 -3
  65. package/dist/theme/lib/read-social-links.ts +4 -3
  66. package/dist/theme/lib/rich-text-parse.ts +740 -0
  67. package/dist/theme/lib/rich-text.test.tsx +342 -0
  68. package/dist/theme/lib/rich-text.tsx +86 -0
  69. package/dist/theme/lib/theme-favicon.ts +1 -1
  70. package/dist/theme/lib/with-mount-base.ts +31 -0
  71. package/dist/theme/styles/overrides/fonts.css +7 -4
  72. package/dist/theme/styles/overrides/footnotes.css +54 -0
  73. package/dist/theme/styles/overrides/home.css +11 -2
  74. package/dist/theme/styles/overrides/lists.css +83 -0
  75. package/dist/theme/styles/overrides/rail.css +27 -10
  76. package/dist/theme/styles/overrides/rspress.css +10 -5
  77. package/dist/theme/styles/overrides/section-card.css +9 -0
  78. package/dist/theme/styles/overrides/sidebar.css +22 -0
  79. package/dist/theme/styles/overrides/tokens.css +41 -23
  80. package/dist/theme/styles/rich-text.css +106 -0
  81. package/dist/theme/styles/themes/amber.css +32 -2
  82. package/dist/theme/styles/themes/arcade.css +16 -1
  83. package/dist/theme/styles/themes/grannysmith.css +32 -2
  84. package/dist/theme/styles/themes/honeycrisp.css +37 -7
  85. package/dist/theme/styles/themes/midnight.css +16 -1
  86. package/dist/theme/styles/themes/mulled.css +54 -9
  87. package/package.json +18 -16
  88. package/src/theme/components/footer/site-footer.tsx +68 -9
  89. package/src/theme/components/home/cta.css +15 -0
  90. package/src/theme/components/home/cta.tsx +10 -2
  91. package/src/theme/components/home/feature-card.css +1 -1
  92. package/src/theme/components/home/feature-card.tsx +8 -10
  93. package/src/theme/components/home/feature.tsx +54 -31
  94. package/src/theme/components/home/hero-demo.css +38 -0
  95. package/src/theme/components/home/home-visual.tsx +226 -0
  96. package/src/theme/components/home/layout.tsx +410 -108
  97. package/src/theme/components/home/split.css +46 -19
  98. package/src/theme/components/home/split.tsx +28 -8
  99. package/src/theme/components/home/tabs.css +299 -0
  100. package/src/theme/components/home/tabs.tsx +351 -0
  101. package/src/theme/components/home/trust-strip.css +41 -0
  102. package/src/theme/components/home/trust-strip.tsx +168 -11
  103. package/src/theme/components/home/workspaces.tsx +176 -38
  104. package/src/theme/components/nav/branch-tag.tsx +7 -7
  105. package/src/theme/components/nav/ciderpress-docs-bar.css +13 -1
  106. package/src/theme/components/nav/ciderpress-docs-bar.tsx +33 -2
  107. package/src/theme/components/nav/ciderpress-header.css +2 -1
  108. package/src/theme/components/nav/ciderpress-header.tsx +26 -3
  109. package/src/theme/components/nav/ciderpress-nav-hamburger.css +50 -0
  110. package/src/theme/components/nav/ciderpress-nav-hamburger.tsx +95 -9
  111. package/src/theme/components/nav/ciderpress-nav-menu.css +88 -0
  112. package/src/theme/components/nav/ciderpress-nav-menu.tsx +397 -38
  113. package/src/theme/components/nav/ciderpress-nav-social-links.tsx +27 -22
  114. package/src/theme/components/nav/floating-branch-indicator.tsx +7 -7
  115. package/src/theme/components/nav/header-icon.css +24 -0
  116. package/src/theme/components/nav/header-icon.tsx +119 -0
  117. package/src/theme/components/nav/header-logo.css +16 -0
  118. package/src/theme/components/nav/header-logo.tsx +200 -0
  119. package/src/theme/components/nav/layout.tsx +117 -16
  120. package/src/theme/components/nav/nav-logo.tsx +16 -19
  121. package/src/theme/components/openapi/index.ts +0 -2
  122. package/src/theme/components/openapi/openapi.css +0 -14
  123. package/src/theme/components/shared/card-icon.tsx +37 -0
  124. package/src/theme/components/shared/ciderpress-logo.tsx +5 -1
  125. package/src/theme/components/shared/icon.tsx +120 -35
  126. package/src/theme/components/shared/resolve-card-icon.ts +53 -12
  127. package/src/theme/components/shared/section-card.tsx +37 -6
  128. package/src/theme/components/sidebar/sidebar-badge.css +50 -0
  129. package/src/theme/components/sidebar/sidebar-badge.tsx +153 -0
  130. package/src/theme/components/sidebar/sidebar-links.tsx +6 -2
  131. package/src/theme/components/sidebar/sidebar-scope.tsx +24 -1
  132. package/src/theme/components/theme-provider.tsx +27 -11
  133. package/src/theme/components/workspaces/card.css +4 -0
  134. package/src/theme/components/workspaces/card.tsx +10 -12
  135. package/src/theme/components/workspaces/grid.tsx +18 -6
  136. package/src/theme/hooks/use-ciderpress.ts +65 -5
  137. package/src/theme/hooks/use-nav-items.ts +82 -11
  138. package/src/theme/index.tsx +10 -3
  139. package/src/theme/lib/read-social-links.ts +4 -3
  140. package/src/theme/lib/rich-text-parse.ts +740 -0
  141. package/src/theme/lib/rich-text.test.tsx +342 -0
  142. package/src/theme/lib/rich-text.tsx +86 -0
  143. package/src/theme/lib/theme-favicon.ts +1 -1
  144. package/src/theme/lib/with-mount-base.ts +31 -0
  145. package/src/theme/styles/overrides/fonts.css +7 -4
  146. package/src/theme/styles/overrides/footnotes.css +54 -0
  147. package/src/theme/styles/overrides/home.css +11 -2
  148. package/src/theme/styles/overrides/lists.css +83 -0
  149. package/src/theme/styles/overrides/rail.css +27 -10
  150. package/src/theme/styles/overrides/rspress.css +10 -5
  151. package/src/theme/styles/overrides/section-card.css +9 -0
  152. package/src/theme/styles/overrides/sidebar.css +22 -0
  153. package/src/theme/styles/overrides/tokens.css +41 -23
  154. package/src/theme/styles/rich-text.css +106 -0
  155. package/src/theme/styles/themes/amber.css +32 -2
  156. package/src/theme/styles/themes/arcade.css +16 -1
  157. package/src/theme/styles/themes/grannysmith.css +32 -2
  158. package/src/theme/styles/themes/honeycrisp.css +37 -7
  159. package/src/theme/styles/themes/midnight.css +16 -1
  160. package/src/theme/styles/themes/mulled.css +54 -9
  161. package/dist/theme/components/openapi/copy-markdown-button.tsx +0 -41
  162. package/src/theme/components/openapi/copy-markdown-button.tsx +0 -41
@@ -1,183 +1,485 @@
1
+ import type { ButtonConfig, HomeVisual, TruncateConfig } from '@ciderpress/config'
1
2
  import { useFrontmatter } from '@rspress/core/runtime'
2
- import { match } from 'massaman/match'
3
- import type React from 'react'
3
+ import { match, P } from 'massaman/match'
4
+ import React from 'react'
4
5
 
6
+ import { hasAccentMarker, isPlainText, renderRichText } from '../../lib/rich-text.tsx'
5
7
  import { SiteFooter } from '../footer/site-footer'
6
8
  import { CTA } from './cta'
7
9
  import { HomeFeature } from './feature'
10
+ import type { FeatureItem } from './feature-card'
8
11
  import { Hero } from './hero'
9
12
  import type { HeroAction } from './hero'
10
13
  import { HeroDemo } from './hero-demo'
14
+ import { HomeVisualView } from './home-visual'
11
15
  import { PageRail } from './page-rail'
12
16
  import { HomeSplit } from './split'
17
+ import { HomeTabs } from './tabs'
18
+ import type { HomeTabEntry } from './tabs'
13
19
  import { TrustStrip } from './trust-strip'
20
+ import type { TrustItem } from './trust-strip'
14
21
  import { HomeWorkspaces } from './workspaces'
22
+ import type { ShowcaseCard } from './workspaces'
15
23
 
16
24
  interface HomeLayoutProps {
17
25
  readonly beforeHero?: React.ReactNode
18
26
  readonly afterHero?: React.ReactNode
19
- readonly beforeFeatures?: React.ReactNode
20
- readonly afterFeatures?: React.ReactNode
21
27
  }
22
28
 
23
29
  interface FrontmatterHero {
24
30
  readonly name?: string
25
31
  readonly text?: string
26
32
  readonly tagline?: string
27
- readonly actions?: readonly HeroAction[]
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
+ */
28
40
  readonly eyebrow?: string
29
41
  }
30
42
 
31
- interface FrontmatterTrust {
43
+ /**
44
+ * Flat heading trio carried by every copy-bearing block.
45
+ *
46
+ * @private
47
+ */
48
+ interface FrontmatterHeading {
49
+ readonly label?: string
50
+ readonly title?: string
51
+ readonly body?: string
52
+ }
53
+
54
+ interface FrontmatterProofBlock {
55
+ readonly type: 'proof'
32
56
  readonly lead?: string
33
- readonly names?: readonly string[]
57
+ readonly names?: readonly TrustItem[]
34
58
  }
35
59
 
36
- 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']
37
94
  readonly title?: string
38
- readonly subtitle?: string
39
- readonly actions?: readonly HeroAction[]
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
40
106
  }
41
107
 
108
+ interface FrontmatterCtaBlock extends FrontmatterHeading {
109
+ readonly type: 'cta'
110
+ readonly actions?: readonly ButtonConfig[]
111
+ }
112
+
113
+ type FrontmatterBlock =
114
+ | FrontmatterProofBlock
115
+ | FrontmatterFeaturesBlock
116
+ | FrontmatterShowcaseBlock
117
+ | FrontmatterSplitBlock
118
+ | FrontmatterTabsBlock
119
+ | FrontmatterCtaBlock
120
+
42
121
  /**
43
122
  * Custom HomeLayout for ciderpress.
44
123
  *
45
- * Renders the approved mockup landing surface inside a continuous PageRail:
46
- * Hero → TrustStrip → Features → Workspaces → CTA → SiteFooter. Sections
47
- * render only when their data is present in frontmatter, so consumers opt
48
- * 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.
49
128
  *
50
- * @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).
51
130
  * @returns React element with the home page.
52
131
  */
53
132
  export function HomeLayout(props: HomeLayoutProps): React.ReactElement {
54
133
  const { frontmatter } = useFrontmatter()
134
+
135
+ // SSG-MD short-circuit: the React tree is walked by
136
+ // `react-render-to-markdown` to produce `.md` files served to
137
+ // `<LlmsCopyButton />`. Rendering the home shell would dump hero
138
+ // text, demo blocks, and footer columns into every copied page.
139
+ // Return an empty fragment for SSG-MD — the home page has no
140
+ // canonical markdown body. The `useFrontmatter` hook above runs in
141
+ // both passes so hook order stays consistent.
142
+ if (import.meta.env.SSG_MD) {
143
+ return <></>
144
+ }
145
+
55
146
  const fm = frontmatter as Record<string, unknown>
56
147
 
57
148
  const hero = fm.hero as FrontmatterHero | undefined
58
- const trust = fm.trust as FrontmatterTrust | undefined
59
- const cta = fm.cta as FrontmatterCTA | undefined
149
+ // heroDemo frontmatter:
150
+ // undefined → render the framework default
151
+ // false → suppress entirely
152
+ // object → render the user-supplied custom variant
153
+ const heroDemoFm = fm.heroDemo as false | HomeVisual | undefined
154
+ const blocks = (fm.blocks as readonly FrontmatterBlock[] | undefined) ?? []
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.
159
+ const heroDemoEl = match(heroDemoFm)
160
+ .with(false, () => null)
161
+ .with(P.nullish, () => <HeroDemo />)
162
+ .otherwise((d) => <HomeVisualView visual={d} context="hero" />)
60
163
 
61
164
  const heroSection = match(hero)
62
165
  .with(undefined, () => null)
63
166
  .otherwise((h) => (
64
167
  <Hero
65
- eyebrow={h.eyebrow}
168
+ eyebrow={renderOptionalRichText(h.eyebrow)}
66
169
  title={renderTitle(h.text ?? h.name ?? '')}
67
- tagline={h.tagline}
68
- actions={h.actions}
69
- demo={<HeroDemo />}
170
+ tagline={renderOptionalRichText(h.tagline)}
171
+ actions={mapButtonsToHeroActions(h.actions)}
172
+ demo={heroDemoEl}
70
173
  />
71
174
  ))
72
175
 
73
- const trustSection = match(trust)
74
- .with(undefined, () => null)
75
- .otherwise((t) => {
76
- const names = t.names ?? []
77
- return match(names.length === 0)
78
- .with(true, () => null)
79
- .otherwise(() => <TrustStrip lead={t.lead} names={names} />)
80
- })
81
-
82
- const ctaSection = match(cta)
83
- .with(undefined, () => null)
84
- .otherwise((c) =>
85
- match(c.title === undefined)
86
- .with(true, () => null)
87
- .otherwise(() => <CTA title={c.title ?? ''} subtitle={c.subtitle} actions={c.actions} />)
88
- )
89
-
90
176
  return (
91
177
  <PageRail>
92
178
  {props.beforeHero}
93
179
  {heroSection}
94
180
  {props.afterHero}
95
- {trustSection}
96
- {props.beforeFeatures}
97
- <HomeFeature />
98
- {props.afterFeatures}
99
- <HomeSplit
100
- eyebrow="Configuration"
101
- title="One file. Validated. Type-safe."
102
- body="Define your docs site in ciderpress.config.ts. Zod validates at boot — no surprises in prod."
103
- bullets={[
104
- 'Type-safe config with full IntelliSense',
105
- 'Hot-reloads on every save',
106
- 'Composable presets for OpenAPI, blog, changelog',
107
- 'First-class i18n out of the box',
108
- ]}
109
- action={{ theme: 'brand', text: 'Read the docs', link: '/getting-started/quick-start' }}
110
- visual={<ConfigPreview />}
111
- />
112
- <HomeWorkspaces />
113
- {ctaSection}
181
+ {blocks.map((block, index) => (
182
+ <React.Fragment key={`${block.type}-${index}`}>{renderBlock(block)}</React.Fragment>
183
+ ))}
114
184
  <SiteFooter />
115
185
  </PageRail>
116
186
  )
117
187
  }
118
188
 
119
189
  /**
120
- * ConfigPreview — minimal `defineConfig` code preview shown inside the
121
- * Split section. Imports come exclusively from `ciderpress` so the
122
- * sample resolves against the published package set.
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.
123
198
  *
124
- * @returns React element.
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.
125
202
  */
126
- function ConfigPreview(): React.ReactElement {
127
- return (
128
- <pre>
129
- <span className="tok-kw">import</span> {'{ defineConfig }'}{' '}
130
- <span className="tok-kw">from</span> <span className="tok-str">'ciderpress'</span>
131
- {'\n\n'}
132
- <span className="tok-kw">export default</span> <span className="tok-fn">defineConfig</span>
133
- {'({\n'}
134
- {' title: '}
135
- <span className="tok-str">'Acme Docs'</span>
136
- {',\n'}
137
- {' sections: [\n'}
138
- {' { title: '}
139
- <span className="tok-str">'Guides'</span>
140
- {', include: '}
141
- <span className="tok-str">'docs/guides/*.md'</span>
142
- {' },\n'}
143
- {' ],\n'}
144
- {' theme: { name: '}
145
- <span className="tok-str">'mulled'</span>
146
- {' },\n'}
147
- {'})'}
148
- </pre>
149
- )
203
+ function renderBlock(block: FrontmatterBlock): React.ReactNode {
204
+ return match(block)
205
+ .with({ type: 'proof' }, (b) => {
206
+ const names = b.names ?? []
207
+ return match(names.length === 0)
208
+ .with(true, () => null)
209
+ .otherwise(() => <TrustStrip lead={b.lead} names={names} />)
210
+ })
211
+ .with({ type: 'features' }, (b) => (
212
+ <HomeFeature
213
+ items={b.items}
214
+ heading={toHeading(b)}
215
+ columns={b.columns}
216
+ truncate={b.truncate}
217
+ />
218
+ ))
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) => (
228
+ <HomeSplit
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)
237
+ .otherwise((v) => (
238
+ <HomeVisualView visual={v} context="split" />
239
+ ))}
240
+ />
241
+ ))
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
+ }
290
+
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 []
307
+ }
308
+ return items.filter(isTabItem).map(toTabEntry)
309
+ }
310
+
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
+ }
329
+ }
330
+
331
+ /**
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.
334
+ *
335
+ * @private
336
+ * @param value - Unvalidated entry from frontmatter
337
+ * @returns True when the entry can be rendered
338
+ */
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'
361
+ }
362
+
363
+ /**
364
+ * Collect a block's flat `label` / `title` / `body` keys into the heading
365
+ * object the grid components take.
366
+ *
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 }
150
373
  }
151
374
 
152
375
  /**
153
- * Render a hero title with the trailing segment styled as a gradient.
376
+ * Render optional rich-text copy, collapsing absent or blank values to
377
+ * `undefined` rather than an empty node.
154
378
  *
155
- * Splits the title on its last word break and wraps the tail in
156
- * `<span className="cp-hero__grad">`. When the title is a single word
157
- * (or empty), it renders verbatim.
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.
158
411
  *
159
412
  * @private
160
413
  * @param raw - The raw title string from frontmatter.
161
- * @returns Title fragment with a gradient tail when applicable.
414
+ * @returns Title fragment.
162
415
  */
163
416
  function renderTitle(raw: string): React.ReactNode {
164
417
  const trimmed = raw.trim()
165
- return match(trimmed.length === 0)
166
- .with(true, () => null)
167
- .otherwise(() => {
168
- const words = trimmed.split(/\s+/)
169
- return match(words.length <= 1)
170
- .with(true, () => trimmed)
171
- .otherwise(() => {
172
- const tailCount = Math.max(1, Math.ceil(words.length / 2))
173
- const headWords = words.slice(0, words.length - tailCount).join(' ')
174
- const tailWords = words.slice(words.length - tailCount).join(' ')
175
- return (
176
- <>
177
- {headWords}
178
- <span className="cp-hero__grad"> {tailWords}</span>
179
- </>
180
- )
181
- })
182
- })
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
+ )
440
+ }
441
+
442
+ /**
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'`,
446
+ * `'secondary' | 'ghost'` → `'alt'`, `undefined` → `undefined`.
447
+ *
448
+ * @private
449
+ * @param actions - Optional list of unified button configs from frontmatter
450
+ * @returns Hero-action array consumed by the existing component API
451
+ */
452
+ function mapButtonsToHeroActions(
453
+ actions: readonly ButtonConfig[] | undefined
454
+ ): readonly HeroAction[] | undefined {
455
+ if (!Array.isArray(actions)) {
456
+ return undefined
457
+ }
458
+ return actions.filter(isButtonConfig).map((action) => ({
459
+ text: action.text,
460
+ link: action.href,
461
+ theme: mapButtonVariantToHeroTheme(action.variant),
462
+ }))
463
+ }
464
+
465
+ /**
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.
473
+ *
474
+ * @private
475
+ * @param variant - Optional variant from the unified button config
476
+ * @returns `'brand'`, `'alt'`, or `undefined`
477
+ */
478
+ function mapButtonVariantToHeroTheme(
479
+ variant: ButtonConfig['variant']
480
+ ): 'brand' | 'alt' | undefined {
481
+ return match(variant)
482
+ .with('primary', () => 'brand' as const)
483
+ .with(P.union('secondary', 'ghost'), () => 'alt' as const)
484
+ .otherwise(() => undefined)
183
485
  }