@ciderpress/ui 1.0.0-rc.3 → 1.0.0-rc.5
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/index.d.ts +1 -1
- package/dist/index.mjs +4 -3
- package/dist/node.d.ts +1 -1
- package/dist/node.mjs +316 -50
- package/dist/theme/components/footer/site-footer.tsx +68 -9
- package/dist/theme/components/home/feature-card.tsx +5 -8
- package/dist/theme/components/home/feature.tsx +31 -11
- package/dist/theme/components/home/hero-demo-custom.tsx +116 -0
- package/dist/theme/components/home/hero-demo.css +27 -0
- package/dist/theme/components/home/layout.tsx +149 -26
- package/dist/theme/components/home/split-visual-custom.tsx +26 -0
- package/dist/theme/components/home/workspaces.tsx +39 -3
- package/dist/theme/components/nav/branch-tag.tsx +7 -7
- package/dist/theme/components/nav/ciderpress-header.css +2 -1
- package/dist/theme/components/nav/ciderpress-header.tsx +26 -3
- package/dist/theme/components/nav/ciderpress-nav-social-links.tsx +9 -7
- package/dist/theme/components/nav/floating-branch-indicator.tsx +7 -7
- package/dist/theme/components/nav/header-icon.css +24 -0
- package/dist/theme/components/nav/header-icon.tsx +119 -0
- package/dist/theme/components/nav/header-logo.css +16 -0
- package/dist/theme/components/nav/header-logo.tsx +200 -0
- package/dist/theme/components/nav/layout.tsx +45 -7
- package/dist/theme/components/nav/nav-logo.tsx +5 -1
- package/dist/theme/components/shared/card-icon.tsx +37 -0
- package/dist/theme/components/shared/ciderpress-logo.tsx +5 -1
- package/dist/theme/components/shared/icon.tsx +119 -35
- package/dist/theme/components/shared/resolve-card-icon.ts +53 -12
- package/dist/theme/components/shared/section-card.tsx +9 -6
- package/dist/theme/components/sidebar/sidebar-links.tsx +6 -2
- package/dist/theme/components/theme-provider.tsx +27 -11
- package/dist/theme/components/workspaces/card.tsx +7 -9
- package/dist/theme/hooks/use-ciderpress.ts +60 -5
- package/dist/theme/lib/read-social-links.ts +4 -3
- package/dist/theme/lib/theme-favicon.ts +1 -1
- package/dist/theme/lib/with-mount-base.ts +31 -0
- package/package.json +13 -13
- package/src/theme/components/footer/site-footer.tsx +68 -9
- package/src/theme/components/home/feature-card.tsx +5 -8
- package/src/theme/components/home/feature.tsx +31 -11
- package/src/theme/components/home/hero-demo-custom.tsx +116 -0
- package/src/theme/components/home/hero-demo.css +27 -0
- package/src/theme/components/home/layout.tsx +149 -26
- package/src/theme/components/home/split-visual-custom.tsx +26 -0
- package/src/theme/components/home/workspaces.tsx +39 -3
- package/src/theme/components/nav/branch-tag.tsx +7 -7
- package/src/theme/components/nav/ciderpress-header.css +2 -1
- package/src/theme/components/nav/ciderpress-header.tsx +26 -3
- package/src/theme/components/nav/ciderpress-nav-social-links.tsx +9 -7
- package/src/theme/components/nav/floating-branch-indicator.tsx +7 -7
- package/src/theme/components/nav/header-icon.css +24 -0
- package/src/theme/components/nav/header-icon.tsx +119 -0
- package/src/theme/components/nav/header-logo.css +16 -0
- package/src/theme/components/nav/header-logo.tsx +200 -0
- package/src/theme/components/nav/layout.tsx +45 -7
- package/src/theme/components/nav/nav-logo.tsx +5 -1
- package/src/theme/components/shared/card-icon.tsx +37 -0
- package/src/theme/components/shared/ciderpress-logo.tsx +5 -1
- package/src/theme/components/shared/icon.tsx +119 -35
- package/src/theme/components/shared/resolve-card-icon.ts +53 -12
- package/src/theme/components/shared/section-card.tsx +9 -6
- package/src/theme/components/sidebar/sidebar-links.tsx +6 -2
- package/src/theme/components/theme-provider.tsx +27 -11
- package/src/theme/components/workspaces/card.tsx +7 -9
- package/src/theme/hooks/use-ciderpress.ts +60 -5
- package/src/theme/lib/read-social-links.ts +4 -3
- package/src/theme/lib/theme-favicon.ts +1 -1
- 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
|
|
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.
|
|
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 (!
|
|
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
|
-
|
|
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(
|
|
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(
|
|
99
|
-
.with(
|
|
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 {
|
|
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?:
|
|
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?:
|
|
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 {
|
|
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
|
|
25
|
-
|
|
26
|
-
|
|
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">
|
|
35
|
-
<h2 className="cp-feature-section-head__title">
|
|
36
|
-
<p className="cp-feature-section-head__sub">
|
|
37
|
-
Everything you need, nothing you don'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:
|
|
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
|
|
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
|
|
28
|
-
readonly
|
|
36
|
+
readonly actions?: readonly ButtonConfig[]
|
|
37
|
+
readonly label?: string
|
|
29
38
|
}
|
|
30
39
|
|
|
31
|
-
interface
|
|
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
|
|
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
|
|
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.
|
|
97
|
+
eyebrow={h.label}
|
|
66
98
|
title={renderTitle(h.text ?? h.name ?? '')}
|
|
67
99
|
tagline={h.tagline}
|
|
68
|
-
actions={h.actions}
|
|
69
|
-
demo={
|
|
100
|
+
actions={mapButtonsToHeroActions(h.actions)}
|
|
101
|
+
demo={heroDemoEl}
|
|
70
102
|
/>
|
|
71
103
|
))
|
|
72
104
|
|
|
73
|
-
const
|
|
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(() =>
|
|
119
|
+
.otherwise(() => (
|
|
120
|
+
<CTA
|
|
121
|
+
title={c.title ?? ''}
|
|
122
|
+
subtitle={c.subtitle}
|
|
123
|
+
actions={mapButtonsToHeroActions(c.actions)}
|
|
124
|
+
/>
|
|
125
|
+
))
|
|
88
126
|
)
|
|
89
127
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
113
|
-
|
|
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
|
-
{'
|
|
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: {
|
|
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
|
+
}
|