@ciderpress/ui 1.0.0-rc.3 → 1.0.0-rc.4

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 (65) hide show
  1. package/dist/index.d.ts +1 -1
  2. package/dist/index.mjs +4 -3
  3. package/dist/node.d.ts +1 -1
  4. package/dist/node.mjs +316 -50
  5. package/dist/theme/components/footer/site-footer.tsx +68 -9
  6. package/dist/theme/components/home/feature-card.tsx +5 -8
  7. package/dist/theme/components/home/feature.tsx +31 -11
  8. package/dist/theme/components/home/hero-demo-custom.tsx +116 -0
  9. package/dist/theme/components/home/hero-demo.css +27 -0
  10. package/dist/theme/components/home/layout.tsx +149 -26
  11. package/dist/theme/components/home/split-visual-custom.tsx +26 -0
  12. package/dist/theme/components/home/workspaces.tsx +39 -3
  13. package/dist/theme/components/nav/branch-tag.tsx +7 -7
  14. package/dist/theme/components/nav/ciderpress-header.css +2 -1
  15. package/dist/theme/components/nav/ciderpress-header.tsx +26 -3
  16. package/dist/theme/components/nav/ciderpress-nav-social-links.tsx +9 -7
  17. package/dist/theme/components/nav/floating-branch-indicator.tsx +7 -7
  18. package/dist/theme/components/nav/header-icon.css +24 -0
  19. package/dist/theme/components/nav/header-icon.tsx +119 -0
  20. package/dist/theme/components/nav/header-logo.css +16 -0
  21. package/dist/theme/components/nav/header-logo.tsx +200 -0
  22. package/dist/theme/components/nav/layout.tsx +45 -7
  23. package/dist/theme/components/nav/nav-logo.tsx +5 -1
  24. package/dist/theme/components/shared/card-icon.tsx +37 -0
  25. package/dist/theme/components/shared/ciderpress-logo.tsx +5 -1
  26. package/dist/theme/components/shared/resolve-card-icon.ts +53 -12
  27. package/dist/theme/components/shared/section-card.tsx +9 -6
  28. package/dist/theme/components/sidebar/sidebar-links.tsx +6 -2
  29. package/dist/theme/components/theme-provider.tsx +27 -11
  30. package/dist/theme/components/workspaces/card.tsx +7 -9
  31. package/dist/theme/hooks/use-ciderpress.ts +60 -5
  32. package/dist/theme/lib/read-social-links.ts +4 -3
  33. package/dist/theme/lib/theme-favicon.ts +1 -1
  34. package/dist/theme/lib/with-mount-base.ts +31 -0
  35. package/package.json +13 -13
  36. package/src/theme/components/footer/site-footer.tsx +68 -9
  37. package/src/theme/components/home/feature-card.tsx +5 -8
  38. package/src/theme/components/home/feature.tsx +31 -11
  39. package/src/theme/components/home/hero-demo-custom.tsx +116 -0
  40. package/src/theme/components/home/hero-demo.css +27 -0
  41. package/src/theme/components/home/layout.tsx +149 -26
  42. package/src/theme/components/home/split-visual-custom.tsx +26 -0
  43. package/src/theme/components/home/workspaces.tsx +39 -3
  44. package/src/theme/components/nav/branch-tag.tsx +7 -7
  45. package/src/theme/components/nav/ciderpress-header.css +2 -1
  46. package/src/theme/components/nav/ciderpress-header.tsx +26 -3
  47. package/src/theme/components/nav/ciderpress-nav-social-links.tsx +9 -7
  48. package/src/theme/components/nav/floating-branch-indicator.tsx +7 -7
  49. package/src/theme/components/nav/header-icon.css +24 -0
  50. package/src/theme/components/nav/header-icon.tsx +119 -0
  51. package/src/theme/components/nav/header-logo.css +16 -0
  52. package/src/theme/components/nav/header-logo.tsx +200 -0
  53. package/src/theme/components/nav/layout.tsx +45 -7
  54. package/src/theme/components/nav/nav-logo.tsx +5 -1
  55. package/src/theme/components/shared/card-icon.tsx +37 -0
  56. package/src/theme/components/shared/ciderpress-logo.tsx +5 -1
  57. package/src/theme/components/shared/resolve-card-icon.ts +53 -12
  58. package/src/theme/components/shared/section-card.tsx +9 -6
  59. package/src/theme/components/sidebar/sidebar-links.tsx +6 -2
  60. package/src/theme/components/theme-provider.tsx +27 -11
  61. package/src/theme/components/workspaces/card.tsx +7 -9
  62. package/src/theme/hooks/use-ciderpress.ts +60 -5
  63. package/src/theme/lib/read-social-links.ts +4 -3
  64. package/src/theme/lib/theme-favicon.ts +1 -1
  65. package/src/theme/lib/with-mount-base.ts +31 -0
@@ -1,14 +1,15 @@
1
+ import type { CopyrightConfig, FooterConfig } from '@ciderpress/config'
1
2
  import { useSite } from '@rspress/core/runtime'
2
- import { match } from 'massaman/match'
3
+ import { match, P } from 'massaman/match'
3
4
  import type React from 'react'
4
5
 
5
6
  import { useCiderpress } from '../../hooks/use-ciderpress'
6
7
  import { readSocialLinks } from '../../lib/read-social-links'
7
8
  import { RouteLink } from '../../lib/route-link.tsx'
8
9
  import { safeUrl } from '../../lib/safe-url.ts'
10
+ import { withMountBase } from '../../lib/with-mount-base.ts'
9
11
  import { CiderpressNavSocialLinks } from '../nav/ciderpress-nav-social-links'
10
12
  import { ThemeSwitcher } from '../nav/theme-switcher'
11
- import { CiderpressMark } from '../shared/ciderpress-mark'
12
13
 
13
14
  import './site-footer.css'
14
15
 
@@ -35,14 +36,17 @@ export function SiteFooter(): React.ReactElement | null {
35
36
  const { message, copyright, socials } = ciderpressFooter ?? {}
36
37
  const { columns, tagline, brandMark } = siteFooter ?? {}
37
38
 
38
- const hasRspressContent = message !== undefined || copyright !== undefined || socials === true
39
+ const copyrightText = resolveCopyright(copyright, rspressSite.title)
40
+ const shouldRenderSocials = socials === true && socialLinks.length > 0
41
+
42
+ const hasFooterContent = message !== undefined || copyrightText !== null || shouldRenderSocials
39
43
  const hasSiteContent = columns !== undefined || tagline !== undefined
40
44
  // The footer also hosts the theme switcher when it's enabled — so a
41
- // config with `theme.switcher: true` but no other footer content
45
+ // config with `theme.themeSwitcher: true` but no other footer content
42
46
  // still needs the footer shell rendered.
43
47
  const hasSwitcher = __CIDERPRESS_THEME_SWITCHER__
44
48
 
45
- if (!hasRspressContent && !hasSiteContent && !hasSwitcher) {
49
+ if (!hasFooterContent && !hasSiteContent && !hasSwitcher) {
46
50
  return null
47
51
  }
48
52
 
@@ -56,7 +60,16 @@ export function SiteFooter(): React.ReactElement | null {
56
60
  <div className="cp-site-footer__brand-mark">
57
61
  {match(brandMark)
58
62
  .with(undefined, () => (
59
- <CiderpressMark className="cp-site-footer__brand-icon" title="" />
63
+ // Default to the project's `/icon.svg` (auto-generated
64
+ // from `config.title` at sync time, or overridden by
65
+ // shipping a `public/icon.svg`). This was previously
66
+ // the hardcoded `<CiderpressMark />` apple, which
67
+ // leaked ciderpress branding into every footer.
68
+ <img
69
+ src={withMountBase('/icon.svg')}
70
+ alt=""
71
+ className="cp-site-footer__brand-icon"
72
+ />
60
73
  ))
61
74
  .otherwise((mark) => mark)}
62
75
  </div>
@@ -65,7 +78,7 @@ export function SiteFooter(): React.ReactElement | null {
65
78
  .otherwise((msg) => (
66
79
  <p className="cp-site-footer__message">{msg}</p>
67
80
  ))}
68
- {match(socials === true && socialLinks.length > 0)
81
+ {match(shouldRenderSocials)
69
82
  .with(true, () => (
70
83
  <div className="cp-site-footer__socials">
71
84
  <CiderpressNavSocialLinks links={socialLinks} />
@@ -95,8 +108,8 @@ export function SiteFooter(): React.ReactElement | null {
95
108
  </div>
96
109
  <div className="cp-site-footer__bottom">
97
110
  <div className="cp-site-footer__bottom-inner">
98
- {match(copyright)
99
- .with(undefined, () => null)
111
+ {match(copyrightText)
112
+ .with(null, () => null)
100
113
  .otherwise((cr) => (
101
114
  <span className="cp-site-footer__copyright">{cr}</span>
102
115
  ))}
@@ -113,3 +126,49 @@ export function SiteFooter(): React.ReactElement | null {
113
126
  </footer>
114
127
  )
115
128
  }
129
+
130
+ /**
131
+ * Resolve the `footer.copyright` config union into a string for rendering.
132
+ *
133
+ * Accepts the new public shape: `true` for auto-generation from the site
134
+ * title + current year, a verbatim string, or a `CopyrightConfig` object
135
+ * with `company` / `dba` / `year`. `undefined` returns `null` so the
136
+ * caller can skip the render.
137
+ *
138
+ * @private
139
+ * @param copyright - The footer copyright config value
140
+ * @param siteTitle - The Rspress site title to use as fallback owner name
141
+ * @returns The formatted copyright string, or `null` when nothing should render
142
+ */
143
+ function resolveCopyright(
144
+ copyright: FooterConfig['copyright'],
145
+ siteTitle: string | undefined
146
+ ): string | null {
147
+ return match(copyright)
148
+ .with(undefined, () => null)
149
+ .with(true, () => formatCopyright({}, siteTitle))
150
+ .with(P.string, (s) => s)
151
+ .otherwise((cfg) => formatCopyright(cfg, siteTitle))
152
+ }
153
+
154
+ /**
155
+ * Build a copyright string from a partial `CopyrightConfig`. Falls back to
156
+ * the site title for the owner name and the current year for the date.
157
+ *
158
+ * @private
159
+ * @param cfg - Copyright config object (may be empty)
160
+ * @param siteTitle - Fallback owner name
161
+ * @returns Formatted copyright string
162
+ */
163
+ function formatCopyright(cfg: CopyrightConfig, siteTitle: string | undefined): string {
164
+ const currentYear = new Date().getFullYear()
165
+ const yearPart = match(cfg.year)
166
+ .with(undefined, () => String(currentYear))
167
+ .with(P.number, (n) => String(n))
168
+ .otherwise(({ from }) => `${from}–${currentYear}`)
169
+ const owner = cfg.dba ?? cfg.company ?? siteTitle ?? ''
170
+ const ownerPart = match(owner.length === 0)
171
+ .with(true, () => '')
172
+ .otherwise(() => ` ${owner}`)
173
+ return `© ${yearPart}${ownerPart}`
174
+ }
@@ -3,14 +3,15 @@ import type React from 'react'
3
3
 
4
4
  import './feature-card.css'
5
5
  import { Card } from '../shared/card'
6
- import { Icon } from '../shared/icon'
6
+ import { CardIcon } from '../shared/card-icon'
7
+ import type { CardIconInput } from '../shared/resolve-card-icon'
7
8
  import { resolveCardIcon } from '../shared/resolve-card-icon'
8
9
 
9
10
  export interface FeatureCardProps {
10
11
  readonly title: string
11
12
  readonly description: string
12
13
  readonly href?: string
13
- readonly icon?: string | { readonly id: string; readonly color: string }
14
+ readonly icon?: CardIconInput
14
15
  readonly span?: 2 | 3 | 4 | 6
15
16
  readonly titleLines?: number
16
17
  readonly descriptionLines?: number
@@ -23,7 +24,7 @@ export interface FeatureItem {
23
24
  readonly title: string
24
25
  readonly details: string
25
26
  readonly link?: string
26
- readonly icon?: string | { readonly id: string; readonly color: string }
27
+ readonly icon?: CardIconInput
27
28
  readonly span?: 2 | 3 | 4 | 6
28
29
  }
29
30
 
@@ -46,11 +47,7 @@ export function FeatureCard({
46
47
  const resolved = resolveCardIcon(icon)
47
48
 
48
49
  const iconEl = match(resolved)
49
- .with(P.nonNullable, (r) => (
50
- <span className={`cp-card__icon cp-card__icon--${r.color}`}>
51
- <Icon icon={r.id} />
52
- </span>
53
- ))
50
+ .with(P.nonNullable, (r) => <CardIcon resolved={r} className="cp-card__icon" />)
54
51
  .otherwise(() => null)
55
52
 
56
53
  const linkTail = match(href)
@@ -1,4 +1,4 @@
1
- import type { HomeGridConfig } from '@ciderpress/config'
1
+ import type { HomeFeaturesConfig } from '@ciderpress/config'
2
2
  import { useFrontmatter } from '@rspress/core/runtime'
3
3
  import { match, P } from 'massaman/match'
4
4
  import type React from 'react'
@@ -7,6 +7,17 @@ import { useCiderpress } from '../../hooks/use-ciderpress'
7
7
  import { FeatureCard } from './feature-card'
8
8
  import type { FeatureItem } from './feature-card'
9
9
 
10
+ interface FrontmatterFeaturesHeading {
11
+ readonly label?: string
12
+ readonly title?: string
13
+ readonly subtitle?: string
14
+ }
15
+
16
+ const DEFAULT_HEADING_EYEBROW = 'Features'
17
+ const DEFAULT_HEADING_TITLE = 'Built for the way you ship.'
18
+ const DEFAULT_HEADING_SUBTITLE =
19
+ "Everything you need, nothing you don't. Configured in TypeScript, validated at boot."
20
+
10
21
  /**
11
22
  * Custom HomeFeature override for ciderpress.
12
23
  * Uses useFrontmatter() hook to read features and renders with FeatureCard/FeatureGrid styling.
@@ -21,9 +32,21 @@ export function HomeFeature(): React.ReactElement | null {
21
32
  // Rspress types frontmatter as its own FrontMatterMeta shape which does not
22
33
  // include ciderpress-specific `features`. The double cast is necessary because
23
34
  // no shared Zod schema exists for frontmatter validation at runtime.
24
- const features = (frontmatter as Record<string, unknown>).features as
25
- | readonly FeatureItem[]
26
- | undefined
35
+ const fm = frontmatter as Record<string, unknown>
36
+ const features = fm.features as readonly FeatureItem[] | undefined
37
+ const heading = fm.featuresHeading as FrontmatterFeaturesHeading | undefined
38
+ // Frontmatter is unvalidated user content — a `featuresHeading.title: {}`
39
+ // would otherwise render as `[object Object]` in the H2. Treat any
40
+ // non-string value as missing and fall through to the framework default.
41
+ const headingEyebrow = match(heading && heading.label)
42
+ .with(P.string, (s) => s)
43
+ .otherwise(() => DEFAULT_HEADING_EYEBROW)
44
+ const headingTitle = match(heading && heading.title)
45
+ .with(P.string, (s) => s)
46
+ .otherwise(() => DEFAULT_HEADING_TITLE)
47
+ const headingSubtitle = match(heading && heading.subtitle)
48
+ .with(P.string, (s) => s)
49
+ .otherwise(() => DEFAULT_HEADING_SUBTITLE)
27
50
 
28
51
  return match(features)
29
52
  .with(
@@ -31,12 +54,9 @@ export function HomeFeature(): React.ReactElement | null {
31
54
  (items) => (
32
55
  <div className="cp-feature-section">
33
56
  <div className="cp-feature-section-head">
34
- <div className="cp-feature-section-head__eyebrow">Features</div>
35
- <h2 className="cp-feature-section-head__title">Built for the way you ship.</h2>
36
- <p className="cp-feature-section-head__sub">
37
- Everything you need, nothing you don&apos;t. Configured in TypeScript, validated at
38
- boot.
39
- </p>
57
+ <div className="cp-feature-section-head__eyebrow">{headingEyebrow}</div>
58
+ <h2 className="cp-feature-section-head__title">{headingTitle}</h2>
59
+ <p className="cp-feature-section-head__sub">{headingSubtitle}</p>
40
60
  </div>
41
61
  <div className="cp-feature-grid">
42
62
  {items.map((f, i) => renderFeature(f, i, gridConfig))}
@@ -60,7 +80,7 @@ export function HomeFeature(): React.ReactElement | null {
60
80
  function renderFeature(
61
81
  feature: FeatureItem,
62
82
  index: number,
63
- gridConfig: HomeGridConfig | undefined
83
+ gridConfig: HomeFeaturesConfig | undefined
64
84
  ): React.ReactElement {
65
85
  const titleLines = gridConfig && gridConfig.truncate && gridConfig.truncate.title
66
86
  const descLines = gridConfig && gridConfig.truncate && gridConfig.truncate.description
@@ -0,0 +1,116 @@
1
+ import type { HomeHeroDemoConfig, HomeHeroDemoLine, HomeHeroDemoTerminal } from '@ciderpress/config'
2
+ import { match } from 'massaman/match'
3
+ import type React from 'react'
4
+
5
+ import { withMountBase } from '../../lib/with-mount-base.ts'
6
+
7
+ import './hero-demo.css'
8
+
9
+ interface CustomHeroDemoProps {
10
+ readonly config: HomeHeroDemoConfig
11
+ }
12
+
13
+ /**
14
+ * Custom replacement for `<HeroDemo />`. Driven by `home.hero.demo` from
15
+ * `ciderpress.config.ts`:
16
+ *
17
+ * - **Image form** (`{ src, alt, width?, height? }`) — renders an
18
+ * `<img>` inside the framework's framed `cp-hero-demo` container
19
+ * (rounded corners + brand-soft glow shadow preserved).
20
+ * - **Terminal form** (`{ windowTitle?, command, lines }`) — keeps the
21
+ * fake-macOS chrome but paints the user's command + output lines.
22
+ *
23
+ * The discriminator is structural: image objects carry `src`, terminal
24
+ * objects carry `lines`.
25
+ *
26
+ * @param props - Validated `HomeHeroDemoConfig`
27
+ * @returns Custom hero demo element
28
+ */
29
+ export function CustomHeroDemo(props: CustomHeroDemoProps): React.ReactElement {
30
+ return match(props.config)
31
+ .when(
32
+ (c): c is HomeHeroDemoTerminal => 'lines' in c,
33
+ (c) => <CustomTerminal config={c} />
34
+ )
35
+ .otherwise((c) => (
36
+ <div className="cp-hero-demo cp-hero-demo--image">
37
+ <img
38
+ src={withMountBase(c.src)}
39
+ alt={c.alt ?? ''}
40
+ width={c.width}
41
+ height={c.height}
42
+ className="cp-hero-demo__img"
43
+ />
44
+ </div>
45
+ ))
46
+ }
47
+
48
+ interface CustomTerminalProps {
49
+ readonly config: HomeHeroDemoTerminal
50
+ }
51
+
52
+ /**
53
+ * Render the terminal-form hero demo with user-supplied command + lines.
54
+ *
55
+ * @private
56
+ * @param props - Validated terminal config
57
+ * @returns Terminal-shell hero demo element
58
+ */
59
+ function CustomTerminal(props: CustomTerminalProps): React.ReactElement {
60
+ const { windowTitle, command, lines } = props.config
61
+ return (
62
+ <div className="cp-hero-demo">
63
+ <div className="cp-hero-demo__bar">
64
+ <span className="cp-hero-demo__dot" />
65
+ <span className="cp-hero-demo__dot" />
66
+ <span className="cp-hero-demo__dot" />
67
+ {match(windowTitle)
68
+ .with(undefined, () => null)
69
+ .otherwise((t) => (
70
+ <span className="cp-hero-demo__title">{t}</span>
71
+ ))}
72
+ </div>
73
+ <pre className="cp-hero-demo__body">
74
+ <span className="cp-hero-demo__prompt">$ </span>
75
+ {command}
76
+ {'\n\n'}
77
+ {lines.map((line, i) => (
78
+ <TerminalLine key={`${line.kind}-${i}`} line={line} />
79
+ ))}
80
+ </pre>
81
+ </div>
82
+ )
83
+ }
84
+
85
+ interface TerminalLineProps {
86
+ readonly line: HomeHeroDemoLine
87
+ }
88
+
89
+ /**
90
+ * Render a single line of the structured terminal hero demo. Each
91
+ * kind gets a coloured prefix glyph mirroring the framework default.
92
+ *
93
+ * @private
94
+ * @param props - Single line entry
95
+ * @returns Glyph-prefixed line followed by a newline
96
+ */
97
+ function TerminalLine(props: TerminalLineProps): React.ReactElement {
98
+ const className = match(props.line.kind)
99
+ .with('ok', () => 'cp-hero-demo__ok')
100
+ .with('info', () => 'cp-hero-demo__info')
101
+ .with('cmt', () => 'cp-hero-demo__cmt')
102
+ .with('err', () => 'cp-hero-demo__err')
103
+ .exhaustive()
104
+ const glyph = match(props.line.kind)
105
+ .with('ok', () => ' ✓')
106
+ .with('info', () => ' ▸')
107
+ .with('cmt', () => ' ↻')
108
+ .with('err', () => ' ✗')
109
+ .exhaustive()
110
+ return (
111
+ <>
112
+ <span className={className}>{glyph}</span> {props.line.text}
113
+ {'\n'}
114
+ </>
115
+ )
116
+ }
@@ -64,3 +64,30 @@
64
64
  .cp-hero-demo__file {
65
65
  color: var(--cp-c-tint-amber-bright-fg);
66
66
  }
67
+
68
+ /* Image-form hero demo — the frame chrome (rounded corners + brand-soft
69
+ glow shadow) stays; the `<img>` simply fills the surface. The hero
70
+ slot already constrains aspect ratio. */
71
+ .cp-hero-demo--image {
72
+ padding: 0;
73
+ }
74
+
75
+ .cp-hero-demo__img {
76
+ display: block;
77
+ width: 100%;
78
+ height: auto;
79
+ border-radius: inherit;
80
+ }
81
+
82
+ /* Custom Split visual `<pre>` shares the `.cp-split__code` surface with
83
+ the default `ConfigPreview` block — same padding, font, scrolling. */
84
+ .cp-split__code {
85
+ margin: 0;
86
+ padding: var(--cp-s-20) var(--cp-s-24);
87
+ font-family: var(--cp-font-family-mono);
88
+ font-size: var(--cp-fs-code);
89
+ line-height: var(--cp-lh-code);
90
+ white-space: pre;
91
+ overflow-x: auto;
92
+ color: var(--cp-c-text-1);
93
+ }
@@ -1,6 +1,13 @@
1
+ import type {
2
+ ButtonConfig,
3
+ HomeHeroDemoConfig,
4
+ HomeSectionId,
5
+ HomeSplitConfig,
6
+ } from '@ciderpress/config'
7
+ import { DEFAULT_HOME_LAYOUT } from '@ciderpress/config'
1
8
  import { useFrontmatter } from '@rspress/core/runtime'
2
- import { match } from 'massaman/match'
3
- import type React from 'react'
9
+ import { match, P } from 'massaman/match'
10
+ import React from 'react'
4
11
 
5
12
  import { SiteFooter } from '../footer/site-footer'
6
13
  import { CTA } from './cta'
@@ -8,8 +15,10 @@ import { HomeFeature } from './feature'
8
15
  import { Hero } from './hero'
9
16
  import type { HeroAction } from './hero'
10
17
  import { HeroDemo } from './hero-demo'
18
+ import { CustomHeroDemo } from './hero-demo-custom'
11
19
  import { PageRail } from './page-rail'
12
20
  import { HomeSplit } from './split'
21
+ import { CustomSplitVisual } from './split-visual-custom'
13
22
  import { TrustStrip } from './trust-strip'
14
23
  import { HomeWorkspaces } from './workspaces'
15
24
 
@@ -24,11 +33,11 @@ interface FrontmatterHero {
24
33
  readonly name?: string
25
34
  readonly text?: string
26
35
  readonly tagline?: string
27
- readonly actions?: readonly HeroAction[]
28
- readonly eyebrow?: string
36
+ readonly actions?: readonly ButtonConfig[]
37
+ readonly label?: string
29
38
  }
30
39
 
31
- interface FrontmatterTrust {
40
+ interface FrontmatterProof {
32
41
  readonly lead?: string
33
42
  readonly names?: readonly string[]
34
43
  }
@@ -36,7 +45,7 @@ interface FrontmatterTrust {
36
45
  interface FrontmatterCTA {
37
46
  readonly title?: string
38
47
  readonly subtitle?: string
39
- readonly actions?: readonly HeroAction[]
48
+ readonly actions?: readonly ButtonConfig[]
40
49
  }
41
50
 
42
51
  /**
@@ -52,25 +61,48 @@ interface FrontmatterCTA {
52
61
  */
53
62
  export function HomeLayout(props: HomeLayoutProps): React.ReactElement {
54
63
  const { frontmatter } = useFrontmatter()
64
+
65
+ // SSG-MD short-circuit: the React tree is walked by
66
+ // `react-render-to-markdown` to produce `.md` files served to
67
+ // `<LlmsCopyButton />`. Rendering the home shell would dump hero
68
+ // text, demo blocks, and footer columns into every copied page.
69
+ // Return an empty fragment for SSG-MD — the home page has no
70
+ // canonical markdown body. The `useFrontmatter` hook above runs in
71
+ // both passes so hook order stays consistent.
72
+ if (import.meta.env.SSG_MD) {
73
+ return <></>
74
+ }
75
+
55
76
  const fm = frontmatter as Record<string, unknown>
56
77
 
57
78
  const hero = fm.hero as FrontmatterHero | undefined
58
- const trust = fm.trust as FrontmatterTrust | undefined
79
+ const proof = fm.proof as FrontmatterProof | undefined
59
80
  const cta = fm.cta as FrontmatterCTA | undefined
81
+ // heroDemo / split frontmatter:
82
+ // undefined → render the framework default
83
+ // false → suppress entirely
84
+ // 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
87
+
88
+ const heroDemoEl = match(heroDemoFm)
89
+ .with(false, () => null)
90
+ .with(undefined, () => <HeroDemo />)
91
+ .otherwise((d) => <CustomHeroDemo config={d} />)
60
92
 
61
93
  const heroSection = match(hero)
62
94
  .with(undefined, () => null)
63
95
  .otherwise((h) => (
64
96
  <Hero
65
- eyebrow={h.eyebrow}
97
+ eyebrow={h.label}
66
98
  title={renderTitle(h.text ?? h.name ?? '')}
67
99
  tagline={h.tagline}
68
- actions={h.actions}
69
- demo={<HeroDemo />}
100
+ actions={mapButtonsToHeroActions(h.actions)}
101
+ demo={heroDemoEl}
70
102
  />
71
103
  ))
72
104
 
73
- const trustSection = match(trust)
105
+ const proofSection = match(proof)
74
106
  .with(undefined, () => null)
75
107
  .otherwise((t) => {
76
108
  const names = t.names ?? []
@@ -84,18 +116,18 @@ export function HomeLayout(props: HomeLayoutProps): React.ReactElement {
84
116
  .otherwise((c) =>
85
117
  match(c.title === undefined)
86
118
  .with(true, () => null)
87
- .otherwise(() => <CTA title={c.title ?? ''} subtitle={c.subtitle} actions={c.actions} />)
119
+ .otherwise(() => (
120
+ <CTA
121
+ title={c.title ?? ''}
122
+ subtitle={c.subtitle}
123
+ actions={mapButtonsToHeroActions(c.actions)}
124
+ />
125
+ ))
88
126
  )
89
127
 
90
- return (
91
- <PageRail>
92
- {props.beforeHero}
93
- {heroSection}
94
- {props.afterHero}
95
- {trustSection}
96
- {props.beforeFeatures}
97
- <HomeFeature />
98
- {props.afterFeatures}
128
+ const splitSection = match(splitFm)
129
+ .with(false, () => null)
130
+ .with(undefined, () => (
99
131
  <HomeSplit
100
132
  eyebrow="Configuration"
101
133
  title="One file. Validated. Type-safe."
@@ -109,8 +141,57 @@ export function HomeLayout(props: HomeLayoutProps): React.ReactElement {
109
141
  action={{ theme: 'brand', text: 'Read the docs', link: '/getting-started/quick-start' }}
110
142
  visual={<ConfigPreview />}
111
143
  />
112
- <HomeWorkspaces />
113
- {ctaSection}
144
+ ))
145
+ .with(P.nonNullable, (s) => (
146
+ <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)
156
+ .otherwise((v) => (
157
+ <CustomSplitVisual code={v.code} language={v.language} />
158
+ ))}
159
+ />
160
+ ))
161
+ .exhaustive()
162
+
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,
188
+ }
189
+
190
+ return (
191
+ <PageRail>
192
+ {layout.map((id) => (
193
+ <React.Fragment key={id}>{sectionsById[id]}</React.Fragment>
194
+ ))}
114
195
  <SiteFooter />
115
196
  </PageRail>
116
197
  )
@@ -134,16 +215,16 @@ function ConfigPreview(): React.ReactElement {
134
215
  {' title: '}
135
216
  <span className="tok-str">'Acme Docs'</span>
136
217
  {',\n'}
137
- {' sections: [\n'}
218
+ {' pages: [\n'}
138
219
  {' { title: '}
139
220
  <span className="tok-str">'Guides'</span>
140
221
  {', include: '}
141
222
  <span className="tok-str">'docs/guides/*.md'</span>
142
223
  {' },\n'}
143
224
  {' ],\n'}
144
- {' theme: { name: '}
225
+ {' theme: { themes: ['}
145
226
  <span className="tok-str">'mulled'</span>
146
- {' },\n'}
227
+ {'] },\n'}
147
228
  {'})'}
148
229
  </pre>
149
230
  )
@@ -181,3 +262,45 @@ function renderTitle(raw: string): React.ReactNode {
181
262
  })
182
263
  })
183
264
  }
265
+
266
+ /**
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'`,
270
+ * `'secondary' | 'ghost'` → `'alt'`, `undefined` → `undefined`.
271
+ *
272
+ * @private
273
+ * @param actions - Optional list of unified button configs from frontmatter
274
+ * @returns Hero-action array consumed by the existing component API
275
+ */
276
+ function mapButtonsToHeroActions(
277
+ actions: readonly ButtonConfig[] | undefined
278
+ ): readonly HeroAction[] | undefined {
279
+ if (actions === undefined) {
280
+ return undefined
281
+ }
282
+ return actions.map((action) => ({
283
+ text: action.text,
284
+ link: action.href,
285
+ theme: mapButtonVariantToHeroTheme(action.variant),
286
+ }))
287
+ }
288
+
289
+ /**
290
+ * Project the new `'primary' | 'secondary' | 'ghost'` variant token
291
+ * back into the legacy `'brand' | 'alt'` token that `<Hero />` accepts.
292
+ *
293
+ * @private
294
+ * @param variant - Optional variant from the unified button config
295
+ * @returns `'brand'`, `'alt'`, or `undefined`
296
+ */
297
+ function mapButtonVariantToHeroTheme(
298
+ variant: ButtonConfig['variant']
299
+ ): 'brand' | 'alt' | undefined {
300
+ return match(variant)
301
+ .with(undefined, () => undefined)
302
+ .with('primary', () => 'brand' as const)
303
+ .with('secondary', () => 'alt' as const)
304
+ .with('ghost', () => 'alt' as const)
305
+ .exhaustive()
306
+ }
@@ -0,0 +1,26 @@
1
+ import type React from 'react'
2
+
3
+ interface CustomSplitVisualProps {
4
+ readonly code: string
5
+ readonly language?: string
6
+ }
7
+
8
+ /**
9
+ * Custom Split visual — renders a user-supplied code snippet inside the
10
+ * same `<pre>` shell as the default `ConfigPreview`. Language hint is
11
+ * surfaced as a `data-language` attribute so themes can hook into it
12
+ * for syntax-highlighter overlays. No client-side highlighting is
13
+ * performed in the framework (keeps the bundle slim); the rendered
14
+ * `<pre>` matches the default surface so themes that style the
15
+ * default block also style the custom one.
16
+ *
17
+ * @param props - Code snippet + optional language hint
18
+ * @returns Pre-formatted code block
19
+ */
20
+ export function CustomSplitVisual(props: CustomSplitVisualProps): React.ReactElement {
21
+ return (
22
+ <pre data-language={props.language ?? 'ts'} className="cp-split__code">
23
+ {props.code}
24
+ </pre>
25
+ )
26
+ }