@ciderpress/ui 1.0.0-rc.10 → 1.0.0-rc.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/head/css/loader-apple.css +1 -1
- package/dist/head/css/loader-dots.css +1 -1
- package/dist/head/css/themes/amber.css +1 -1
- package/dist/head/css/themes/arcade.css +1 -1
- package/dist/head/css/themes/grannysmith.css +1 -1
- package/dist/head/css/themes/honeycrisp.css +1 -1
- package/dist/head/css/themes/midnight.css +1 -1
- package/dist/head/css/themes/mulled.css +1 -1
- package/dist/node.mjs +1110 -28
- package/dist/theme/components/home/cta.css +15 -0
- package/dist/theme/components/home/cta.tsx +10 -2
- package/dist/theme/components/home/feature-card.css +1 -1
- package/dist/theme/components/home/feature-card.tsx +3 -2
- package/dist/theme/components/home/feature.tsx +34 -31
- package/dist/theme/components/home/hero-demo.css +11 -0
- package/dist/theme/components/home/home-visual.tsx +226 -0
- package/dist/theme/components/home/layout.tsx +350 -171
- package/dist/theme/components/home/split.css +46 -19
- package/dist/theme/components/home/split.tsx +28 -8
- package/dist/theme/components/home/tabs.css +299 -0
- package/dist/theme/components/home/tabs.tsx +351 -0
- package/dist/theme/components/home/trust-strip.css +41 -0
- package/dist/theme/components/home/trust-strip.tsx +168 -11
- package/dist/theme/components/home/workspaces.tsx +171 -69
- package/dist/theme/components/seo-head-data.test.ts +196 -0
- package/dist/theme/components/seo-head-data.ts +490 -0
- package/dist/theme/components/seo-head.tsx +102 -0
- package/dist/theme/components/shared/IssueLinkIcon.tsx +42 -0
- package/dist/theme/components/shared/issue-link.css +74 -0
- package/dist/theme/components/workspaces/card.css +4 -0
- package/dist/theme/components/workspaces/card.tsx +3 -3
- package/dist/theme/components/workspaces/grid.tsx +18 -6
- package/dist/theme/index.tsx +6 -0
- package/dist/theme/lib/rich-text-parse.ts +740 -0
- package/dist/theme/lib/rich-text.test.tsx +342 -0
- package/dist/theme/lib/rich-text.tsx +86 -0
- package/dist/theme/lib/seo-url.ts +28 -0
- package/dist/theme/styles/overrides/fonts.css +7 -4
- package/dist/theme/styles/overrides/home.css +11 -2
- package/dist/theme/styles/overrides/rspress.css +10 -5
- package/dist/theme/styles/overrides/tokens.css +41 -23
- package/dist/theme/styles/rich-text.css +106 -0
- package/dist/theme/styles/themes/amber.css +32 -2
- package/dist/theme/styles/themes/arcade.css +16 -1
- package/dist/theme/styles/themes/grannysmith.css +32 -2
- package/dist/theme/styles/themes/honeycrisp.css +37 -7
- package/dist/theme/styles/themes/midnight.css +16 -1
- package/dist/theme/styles/themes/mulled.css +54 -9
- package/package.json +6 -5
- package/src/theme/components/home/cta.css +15 -0
- package/src/theme/components/home/cta.tsx +10 -2
- package/src/theme/components/home/feature-card.css +1 -1
- package/src/theme/components/home/feature-card.tsx +3 -2
- package/src/theme/components/home/feature.tsx +34 -31
- package/src/theme/components/home/hero-demo.css +11 -0
- package/src/theme/components/home/home-visual.tsx +226 -0
- package/src/theme/components/home/layout.tsx +350 -171
- package/src/theme/components/home/split.css +46 -19
- package/src/theme/components/home/split.tsx +28 -8
- package/src/theme/components/home/tabs.css +299 -0
- package/src/theme/components/home/tabs.tsx +351 -0
- package/src/theme/components/home/trust-strip.css +41 -0
- package/src/theme/components/home/trust-strip.tsx +168 -11
- package/src/theme/components/home/workspaces.tsx +171 -69
- package/src/theme/components/seo-head-data.test.ts +196 -0
- package/src/theme/components/seo-head-data.ts +490 -0
- package/src/theme/components/seo-head.tsx +102 -0
- package/src/theme/components/shared/IssueLinkIcon.tsx +42 -0
- package/src/theme/components/shared/issue-link.css +74 -0
- package/src/theme/components/workspaces/card.css +4 -0
- package/src/theme/components/workspaces/card.tsx +3 -3
- package/src/theme/components/workspaces/grid.tsx +18 -6
- package/src/theme/index.tsx +6 -0
- package/src/theme/lib/rich-text-parse.ts +740 -0
- package/src/theme/lib/rich-text.test.tsx +342 -0
- package/src/theme/lib/rich-text.tsx +86 -0
- package/src/theme/lib/seo-url.ts +28 -0
- package/src/theme/styles/overrides/fonts.css +7 -4
- package/src/theme/styles/overrides/home.css +11 -2
- package/src/theme/styles/overrides/rspress.css +10 -5
- package/src/theme/styles/overrides/tokens.css +41 -23
- package/src/theme/styles/rich-text.css +106 -0
- package/src/theme/styles/themes/amber.css +32 -2
- package/src/theme/styles/themes/arcade.css +16 -1
- package/src/theme/styles/themes/grannysmith.css +32 -2
- package/src/theme/styles/themes/honeycrisp.css +37 -7
- package/src/theme/styles/themes/midnight.css +16 -1
- package/src/theme/styles/themes/mulled.css +54 -9
- package/dist/theme/components/home/hero-demo-custom.tsx +0 -116
- package/dist/theme/components/home/split-visual-custom.tsx +0 -26
- package/src/theme/components/home/hero-demo-custom.tsx +0 -116
- package/src/theme/components/home/split-visual-custom.tsx +0 -26
|
@@ -9,6 +9,21 @@
|
|
|
9
9
|
padding: 0 32px;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
.cp-cta__eyebrow {
|
|
13
|
+
font-family: var(--cp-font-family-mono);
|
|
14
|
+
font-size: var(--cp-fs-eyebrow);
|
|
15
|
+
text-transform: uppercase;
|
|
16
|
+
letter-spacing: var(--cp-letter-eyebrow);
|
|
17
|
+
/* Same shade pair as `.cp-accent` — `--cp-c-brand-1` is a fill colour and
|
|
18
|
+
misses 4.5:1 at this size on several themes. See styles/rich-text.css. */
|
|
19
|
+
color: var(--cp-c-brand-2, var(--cp-c-brand-1));
|
|
20
|
+
margin-bottom: var(--cp-s-12);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
html[data-cp-variant='dark'] .cp-cta__eyebrow {
|
|
24
|
+
color: var(--cp-c-brand-light, var(--cp-c-brand-1));
|
|
25
|
+
}
|
|
26
|
+
|
|
12
27
|
.cp-cta__title {
|
|
13
28
|
font-size: clamp(32px, 5vw, 56px);
|
|
14
29
|
font-weight: 600;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { match } from 'massaman/match'
|
|
1
|
+
import { match, P } from 'massaman/match'
|
|
2
2
|
import type React from 'react'
|
|
3
3
|
|
|
4
|
+
import { renderRichText } from '../../lib/rich-text.tsx'
|
|
4
5
|
import { RouteLink } from '../../lib/route-link.tsx'
|
|
5
6
|
import { safeUrl } from '../../lib/safe-url.ts'
|
|
6
7
|
import type { HeroAction } from './hero'
|
|
@@ -8,6 +9,10 @@ import type { HeroAction } from './hero'
|
|
|
8
9
|
import './cta.css'
|
|
9
10
|
|
|
10
11
|
export interface CTAProps {
|
|
12
|
+
/**
|
|
13
|
+
* Small uppercase kicker rendered above the headline.
|
|
14
|
+
*/
|
|
15
|
+
readonly eyebrow?: string
|
|
11
16
|
/**
|
|
12
17
|
* Headline.
|
|
13
18
|
*/
|
|
@@ -30,12 +35,15 @@ export interface CTAProps {
|
|
|
30
35
|
* @returns React element.
|
|
31
36
|
*/
|
|
32
37
|
export function CTA(props: CTAProps): React.ReactElement {
|
|
33
|
-
const { title, subtitle, actions } = props
|
|
38
|
+
const { eyebrow, title, subtitle, actions } = props
|
|
34
39
|
const list = actions ?? []
|
|
35
40
|
|
|
36
41
|
return (
|
|
37
42
|
<section className="cp-cta">
|
|
38
43
|
<div className="cp-cta__inner">
|
|
44
|
+
{match(eyebrow)
|
|
45
|
+
.with(P.string, (e) => <div className="cp-cta__eyebrow">{renderRichText(e)}</div>)
|
|
46
|
+
.otherwise(() => null)}
|
|
39
47
|
<h2 className="cp-cta__title">{title}</h2>
|
|
40
48
|
{match(subtitle)
|
|
41
49
|
.with(undefined, () => null)
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
.cp-feature-card__title {
|
|
21
|
-
font-family:
|
|
21
|
+
font-family: var(--cp-ff-display);
|
|
22
22
|
font-size: var(--cp-fs-feature-title);
|
|
23
23
|
font-weight: var(--cp-fw-bold);
|
|
24
24
|
color: var(--rp-c-text-1, var(--cp-c-text-1));
|
|
@@ -2,6 +2,7 @@ import { match, P } from 'massaman/match'
|
|
|
2
2
|
import type React from 'react'
|
|
3
3
|
|
|
4
4
|
import './feature-card.css'
|
|
5
|
+
import { renderRichText } from '../../lib/rich-text.tsx'
|
|
5
6
|
import { Card } from '../shared/card'
|
|
6
7
|
import { CardIcon } from '../shared/card-icon'
|
|
7
8
|
import type { CardIconInput } from '../shared/resolve-card-icon'
|
|
@@ -64,14 +65,14 @@ export function FeatureCard({
|
|
|
64
65
|
className={clampClass('cp-feature-card__title', titleLines)}
|
|
65
66
|
style={clampStyle(titleLines)}
|
|
66
67
|
>
|
|
67
|
-
{title}
|
|
68
|
+
{renderRichText(title)}
|
|
68
69
|
</span>
|
|
69
70
|
</div>
|
|
70
71
|
<span
|
|
71
72
|
className={clampClass('cp-feature-card__desc', descriptionLines)}
|
|
72
73
|
style={clampStyle(descriptionLines)}
|
|
73
74
|
>
|
|
74
|
-
{description}
|
|
75
|
+
{renderRichText(description)}
|
|
75
76
|
</span>
|
|
76
77
|
{linkTail}
|
|
77
78
|
</Card>
|
|
@@ -1,16 +1,22 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import { useFrontmatter } from '@rspress/core/runtime'
|
|
1
|
+
import type { TruncateConfig } from '@ciderpress/config'
|
|
3
2
|
import { match, P } from 'massaman/match'
|
|
4
3
|
import type React from 'react'
|
|
5
4
|
|
|
6
|
-
import {
|
|
5
|
+
import { renderRichText } from '../../lib/rich-text.tsx'
|
|
7
6
|
import { FeatureCard } from './feature-card'
|
|
8
7
|
import type { FeatureItem } from './feature-card'
|
|
9
8
|
|
|
10
9
|
interface FrontmatterFeaturesHeading {
|
|
11
10
|
readonly label?: string
|
|
12
11
|
readonly title?: string
|
|
13
|
-
readonly
|
|
12
|
+
readonly body?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface HomeFeatureProps {
|
|
16
|
+
readonly items?: readonly FeatureItem[]
|
|
17
|
+
readonly heading?: FrontmatterFeaturesHeading
|
|
18
|
+
readonly columns?: 1 | 2 | 3 | 4
|
|
19
|
+
readonly truncate?: TruncateConfig
|
|
14
20
|
}
|
|
15
21
|
|
|
16
22
|
const DEFAULT_HEADING_EYEBROW = 'Features'
|
|
@@ -19,23 +25,15 @@ const DEFAULT_HEADING_SUBTITLE =
|
|
|
19
25
|
"Everything you need, nothing you don't. Configured in TypeScript, validated at boot."
|
|
20
26
|
|
|
21
27
|
/**
|
|
22
|
-
*
|
|
23
|
-
*
|
|
28
|
+
* Features grid block. Renders the supplied feature cards under a heading,
|
|
29
|
+
* or nothing when no cards are present.
|
|
24
30
|
*
|
|
25
|
-
* @
|
|
31
|
+
* @param props - Resolved feature cards, optional heading, and truncation limits.
|
|
32
|
+
* @returns React element with the feature grid, or null.
|
|
26
33
|
*/
|
|
27
|
-
export function HomeFeature(): React.ReactElement | null {
|
|
28
|
-
const {
|
|
29
|
-
|
|
30
|
-
const gridConfig = home && home.features
|
|
31
|
-
|
|
32
|
-
// Rspress types frontmatter as its own FrontMatterMeta shape which does not
|
|
33
|
-
// include ciderpress-specific `features`. The double cast is necessary because
|
|
34
|
-
// no shared Zod schema exists for frontmatter validation at runtime.
|
|
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: {}`
|
|
34
|
+
export function HomeFeature(props: HomeFeatureProps): React.ReactElement | null {
|
|
35
|
+
const { items, heading, columns, truncate } = props
|
|
36
|
+
// Frontmatter is unvalidated user content — a `heading.title: {}`
|
|
39
37
|
// would otherwise render as `[object Object]` in the H2. Treat any
|
|
40
38
|
// non-string value as missing and fall through to the framework default.
|
|
41
39
|
const headingEyebrow = match(heading && heading.label)
|
|
@@ -44,22 +42,27 @@ export function HomeFeature(): React.ReactElement | null {
|
|
|
44
42
|
const headingTitle = match(heading && heading.title)
|
|
45
43
|
.with(P.string, (s) => s)
|
|
46
44
|
.otherwise(() => DEFAULT_HEADING_TITLE)
|
|
47
|
-
const headingSubtitle = match(heading && heading.
|
|
45
|
+
const headingSubtitle = match(heading && heading.body)
|
|
48
46
|
.with(P.string, (s) => s)
|
|
49
47
|
.otherwise(() => DEFAULT_HEADING_SUBTITLE)
|
|
48
|
+
// Column count rides on a custom property so the grid keeps its CSS
|
|
49
|
+
// media queries — a narrow viewport still collapses to one column.
|
|
50
|
+
const gridStyle = match(columns)
|
|
51
|
+
.with(P.number, (c) => ({ '--cp-feature-cols': String(c) }) as React.CSSProperties)
|
|
52
|
+
.otherwise(() => undefined)
|
|
50
53
|
|
|
51
|
-
return match(
|
|
54
|
+
return match(items)
|
|
52
55
|
.with(
|
|
53
56
|
P.when((f): f is readonly FeatureItem[] => Array.isArray(f) && f.length > 0),
|
|
54
|
-
(
|
|
57
|
+
(list) => (
|
|
55
58
|
<div className="cp-feature-section">
|
|
56
59
|
<div className="cp-feature-section-head">
|
|
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>
|
|
60
|
+
<div className="cp-feature-section-head__eyebrow">{renderRichText(headingEyebrow)}</div>
|
|
61
|
+
<h2 className="cp-feature-section-head__title">{renderRichText(headingTitle)}</h2>
|
|
62
|
+
<p className="cp-feature-section-head__sub">{renderRichText(headingSubtitle)}</p>
|
|
60
63
|
</div>
|
|
61
|
-
<div className="cp-feature-grid">
|
|
62
|
-
{
|
|
64
|
+
<div className="cp-feature-grid" style={gridStyle}>
|
|
65
|
+
{list.map((f, i) => renderFeature(f, i, truncate))}
|
|
63
66
|
</div>
|
|
64
67
|
</div>
|
|
65
68
|
)
|
|
@@ -74,16 +77,16 @@ export function HomeFeature(): React.ReactElement | null {
|
|
|
74
77
|
* @private
|
|
75
78
|
* @param feature - Feature item data
|
|
76
79
|
* @param index - Array index for key generation
|
|
77
|
-
* @param
|
|
80
|
+
* @param truncate - Optional line-clamp limits for card text
|
|
78
81
|
* @returns Feature card element
|
|
79
82
|
*/
|
|
80
83
|
function renderFeature(
|
|
81
84
|
feature: FeatureItem,
|
|
82
85
|
index: number,
|
|
83
|
-
|
|
86
|
+
truncate: TruncateConfig | undefined
|
|
84
87
|
): React.ReactElement {
|
|
85
|
-
const titleLines =
|
|
86
|
-
const descLines =
|
|
88
|
+
const titleLines = truncate && truncate.title
|
|
89
|
+
const descLines = truncate && truncate.description
|
|
87
90
|
|
|
88
91
|
return (
|
|
89
92
|
<FeatureCard
|
|
@@ -72,6 +72,17 @@
|
|
|
72
72
|
padding: 0;
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
/* Code-form hero demo — the frame owns the chrome, so strip the native
|
|
76
|
+
Rspress code block's own margin and rounded corners. */
|
|
77
|
+
.cp-hero-demo--code .rp-codeblock {
|
|
78
|
+
margin: 0;
|
|
79
|
+
border-radius: 0;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.cp-hero-demo--code .rp-codeblock pre {
|
|
83
|
+
overflow-x: auto;
|
|
84
|
+
}
|
|
85
|
+
|
|
75
86
|
.cp-hero-demo__img {
|
|
76
87
|
display: block;
|
|
77
88
|
width: 100%;
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import type { HomeVisual, HomeVisualLine, HomeVisualTerminal } from '@ciderpress/config'
|
|
2
|
+
import { CodeBlockRuntime } from '@rspress/core/theme'
|
|
3
|
+
import { match, P } from 'massaman/match'
|
|
4
|
+
import type React from 'react'
|
|
5
|
+
|
|
6
|
+
import { withMountBase } from '../../lib/with-mount-base.ts'
|
|
7
|
+
|
|
8
|
+
import './hero-demo.css'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Surface a {@link HomeVisual} is rendered into.
|
|
12
|
+
*
|
|
13
|
+
* - `'hero'` — supplies its own framed `cp-hero-demo` container
|
|
14
|
+
* - `'split'` — the parent `cp-split__visual` frame owns the chrome
|
|
15
|
+
* - `'tabs'` — the parent `cp-tabs__visual` frame owns the chrome
|
|
16
|
+
*/
|
|
17
|
+
export type HomeVisualContext = 'hero' | 'split' | 'tabs'
|
|
18
|
+
|
|
19
|
+
interface HomeVisualViewProps {
|
|
20
|
+
readonly visual: HomeVisual
|
|
21
|
+
readonly context: HomeVisualContext
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Render a user-supplied {@link HomeVisual}. One union, three variants,
|
|
26
|
+
* shared by `home.hero.demo` and `home.blocks[].visual`:
|
|
27
|
+
*
|
|
28
|
+
* - **code** — rendered through Rspress's native `CodeBlockRuntime`, the
|
|
29
|
+
* same Shiki pipeline that highlights markdown code fences, so it
|
|
30
|
+
* themes identically to the rest of the site.
|
|
31
|
+
* - **image** — a screenshot or graphic, base-prefixed via
|
|
32
|
+
* {@link withMountBase} to survive a mounted `base`.
|
|
33
|
+
* - **terminal** — the framework's fake-macOS chrome painted with the
|
|
34
|
+
* supplied command and output lines.
|
|
35
|
+
*
|
|
36
|
+
* Terminates in `.otherwise`, not `.exhaustive`: on a hand-authored home
|
|
37
|
+
* page this receives raw frontmatter, where a blank `visual:` parses to
|
|
38
|
+
* null and `type:` may name a variant that does not exist. Throwing
|
|
39
|
+
* `NonExhaustiveError` from here failed the entire site build, so an
|
|
40
|
+
* unrenderable visual yields nothing instead.
|
|
41
|
+
*
|
|
42
|
+
* @param props - Validated visual config and the surface it renders into
|
|
43
|
+
* @returns The visual's React element, or null when it cannot be rendered
|
|
44
|
+
*/
|
|
45
|
+
export function HomeVisualView(props: HomeVisualViewProps): React.ReactElement | null {
|
|
46
|
+
const { visual, context } = props
|
|
47
|
+
return match(visual)
|
|
48
|
+
.with({ type: 'code' }, (v) => renderCode(v.code, v.language, context))
|
|
49
|
+
.with({ type: 'image' }, (v) => renderImage(v, context))
|
|
50
|
+
.with({ type: 'terminal' }, (v) => <VisualTerminal config={v} />)
|
|
51
|
+
.otherwise(() => null)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Render the code variant, framing it only in the hero context.
|
|
56
|
+
*
|
|
57
|
+
* @private
|
|
58
|
+
* @param code - Snippet source
|
|
59
|
+
* @param language - Shiki language identifier, defaulting to `ts`
|
|
60
|
+
* @param context - Surface being rendered into
|
|
61
|
+
* @returns Highlighted code block
|
|
62
|
+
*/
|
|
63
|
+
function renderCode(
|
|
64
|
+
code: string,
|
|
65
|
+
language: string | undefined,
|
|
66
|
+
context: HomeVisualContext
|
|
67
|
+
): React.ReactElement {
|
|
68
|
+
const block = <CodeBlockRuntime lang={language ?? 'ts'} code={code} />
|
|
69
|
+
return match(context)
|
|
70
|
+
.with('hero', () => <div className="cp-hero-demo cp-hero-demo--code">{block}</div>)
|
|
71
|
+
.otherwise(() => block)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Image variant fields shared by both surfaces.
|
|
76
|
+
*
|
|
77
|
+
* @private
|
|
78
|
+
*/
|
|
79
|
+
interface VisualImageFields {
|
|
80
|
+
readonly src: string
|
|
81
|
+
readonly alt?: string
|
|
82
|
+
readonly width?: number | string
|
|
83
|
+
readonly height?: number | string
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Render the image variant with the class names its surface expects.
|
|
88
|
+
*
|
|
89
|
+
* @private
|
|
90
|
+
* @param image - Validated image visual
|
|
91
|
+
* @param context - Surface being rendered into
|
|
92
|
+
* @returns Image element, framed in the hero context
|
|
93
|
+
*/
|
|
94
|
+
function renderImage(image: VisualImageFields, context: HomeVisualContext): React.ReactElement {
|
|
95
|
+
const { src, alt, width, height } = image
|
|
96
|
+
const framedClass = match(context)
|
|
97
|
+
.with('tabs', () => 'cp-tabs__img')
|
|
98
|
+
.otherwise(() => 'cp-split__img')
|
|
99
|
+
return match(context)
|
|
100
|
+
.with(P.union('split', 'tabs'), () => (
|
|
101
|
+
<img
|
|
102
|
+
src={withMountBase(src)}
|
|
103
|
+
alt={alt ?? ''}
|
|
104
|
+
width={width}
|
|
105
|
+
height={height}
|
|
106
|
+
className={framedClass}
|
|
107
|
+
/>
|
|
108
|
+
))
|
|
109
|
+
.with('hero', () => (
|
|
110
|
+
<div className="cp-hero-demo cp-hero-demo--image">
|
|
111
|
+
<img
|
|
112
|
+
src={withMountBase(src)}
|
|
113
|
+
alt={alt ?? ''}
|
|
114
|
+
width={width}
|
|
115
|
+
height={height}
|
|
116
|
+
className="cp-hero-demo__img"
|
|
117
|
+
/>
|
|
118
|
+
</div>
|
|
119
|
+
))
|
|
120
|
+
.exhaustive()
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
interface VisualTerminalProps {
|
|
124
|
+
readonly config: HomeVisualTerminal
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Render the terminal variant with the user-supplied command and lines.
|
|
129
|
+
* In the split context the parent frame flattens the chrome via CSS, so
|
|
130
|
+
* the markup is identical on both surfaces.
|
|
131
|
+
*
|
|
132
|
+
* @private
|
|
133
|
+
* @param props - Validated terminal config
|
|
134
|
+
* @returns Terminal-shell element
|
|
135
|
+
*/
|
|
136
|
+
function VisualTerminal(props: VisualTerminalProps): React.ReactElement {
|
|
137
|
+
const { windowTitle, command, lines } = props.config
|
|
138
|
+
// Hand-authored frontmatter bypasses the schema; a non-string here
|
|
139
|
+
// reaches React as a child and throws mid-render.
|
|
140
|
+
const commandText = match(command)
|
|
141
|
+
.with(P.string, (c) => c)
|
|
142
|
+
.otherwise(() => '')
|
|
143
|
+
const title = match(windowTitle)
|
|
144
|
+
.with(P.string, (w) => w)
|
|
145
|
+
.otherwise(() => undefined)
|
|
146
|
+
// `lines` is required by the type but frontmatter is unvalidated on
|
|
147
|
+
// hand-authored home pages — a missing key would otherwise throw here
|
|
148
|
+
// and blank the whole page.
|
|
149
|
+
const output = match(lines)
|
|
150
|
+
.with(
|
|
151
|
+
P.when((l): l is readonly HomeVisualLine[] => Array.isArray(l)),
|
|
152
|
+
(l) => l.filter(isTerminalLine)
|
|
153
|
+
)
|
|
154
|
+
.otherwise(() => [])
|
|
155
|
+
return (
|
|
156
|
+
<div className="cp-hero-demo">
|
|
157
|
+
<div className="cp-hero-demo__bar">
|
|
158
|
+
<span className="cp-hero-demo__dot" />
|
|
159
|
+
<span className="cp-hero-demo__dot" />
|
|
160
|
+
<span className="cp-hero-demo__dot" />
|
|
161
|
+
{match(title)
|
|
162
|
+
.with(P.string, (w) => <span className="cp-hero-demo__title">{w}</span>)
|
|
163
|
+
.otherwise(() => null)}
|
|
164
|
+
</div>
|
|
165
|
+
<pre className="cp-hero-demo__body">
|
|
166
|
+
<span className="cp-hero-demo__prompt">$ </span>
|
|
167
|
+
{commandText}
|
|
168
|
+
{'\n\n'}
|
|
169
|
+
{output.map((line, i) => (
|
|
170
|
+
<TerminalLine key={`${line.kind}-${i}`} line={line} />
|
|
171
|
+
))}
|
|
172
|
+
</pre>
|
|
173
|
+
</div>
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Whether a frontmatter value is a renderable terminal line. A YAML
|
|
179
|
+
* sequence with a blank entry yields `null`, which would throw on the
|
|
180
|
+
* first property read.
|
|
181
|
+
*
|
|
182
|
+
* @private
|
|
183
|
+
* @param value - Unvalidated line from frontmatter
|
|
184
|
+
* @returns True when the line can be rendered
|
|
185
|
+
*/
|
|
186
|
+
function isTerminalLine(value: unknown): value is HomeVisualLine {
|
|
187
|
+
if (typeof value !== 'object' || value === null) {
|
|
188
|
+
return false
|
|
189
|
+
}
|
|
190
|
+
const line = value as { text?: unknown }
|
|
191
|
+
return typeof line.text === 'string'
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
interface TerminalLineProps {
|
|
195
|
+
readonly line: HomeVisualLine
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Render a single terminal output line. Each kind gets a coloured prefix
|
|
200
|
+
* glyph mirroring the framework default.
|
|
201
|
+
*
|
|
202
|
+
* @private
|
|
203
|
+
* @param props - Single line entry
|
|
204
|
+
* @returns Glyph-prefixed line followed by a newline
|
|
205
|
+
*/
|
|
206
|
+
function TerminalLine(props: TerminalLineProps): React.ReactElement {
|
|
207
|
+
// `kind` is a union in the type but arrives unvalidated from
|
|
208
|
+
// hand-authored frontmatter — fall back to the neutral marker rather
|
|
209
|
+
// than throwing on an unknown value.
|
|
210
|
+
const className = match(props.line.kind)
|
|
211
|
+
.with('ok', () => 'cp-hero-demo__ok')
|
|
212
|
+
.with('cmt', () => 'cp-hero-demo__cmt')
|
|
213
|
+
.with('err', () => 'cp-hero-demo__err')
|
|
214
|
+
.otherwise(() => 'cp-hero-demo__info')
|
|
215
|
+
const glyph = match(props.line.kind)
|
|
216
|
+
.with('ok', () => ' ✓')
|
|
217
|
+
.with('cmt', () => ' ↻')
|
|
218
|
+
.with('err', () => ' ✗')
|
|
219
|
+
.otherwise(() => ' ▸')
|
|
220
|
+
return (
|
|
221
|
+
<>
|
|
222
|
+
<span className={className}>{glyph}</span> {props.line.text}
|
|
223
|
+
{'\n'}
|
|
224
|
+
</>
|
|
225
|
+
)
|
|
226
|
+
}
|