@ciderpress/ui 1.0.0-rc.10 → 1.0.0-rc.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/dist/head/css/loader-apple.css +1 -1
  2. package/dist/head/css/loader-dots.css +1 -1
  3. package/dist/head/css/themes/amber.css +1 -1
  4. package/dist/head/css/themes/arcade.css +1 -1
  5. package/dist/head/css/themes/grannysmith.css +1 -1
  6. package/dist/head/css/themes/honeycrisp.css +1 -1
  7. package/dist/head/css/themes/midnight.css +1 -1
  8. package/dist/head/css/themes/mulled.css +1 -1
  9. package/dist/node.mjs +1110 -28
  10. package/dist/theme/components/home/cta.css +15 -0
  11. package/dist/theme/components/home/cta.tsx +10 -2
  12. package/dist/theme/components/home/feature-card.css +1 -1
  13. package/dist/theme/components/home/feature-card.tsx +3 -2
  14. package/dist/theme/components/home/feature.tsx +34 -31
  15. package/dist/theme/components/home/hero-demo.css +11 -0
  16. package/dist/theme/components/home/home-visual.tsx +226 -0
  17. package/dist/theme/components/home/layout.tsx +350 -171
  18. package/dist/theme/components/home/split.css +46 -19
  19. package/dist/theme/components/home/split.tsx +28 -8
  20. package/dist/theme/components/home/tabs.css +299 -0
  21. package/dist/theme/components/home/tabs.tsx +351 -0
  22. package/dist/theme/components/home/trust-strip.css +41 -0
  23. package/dist/theme/components/home/trust-strip.tsx +168 -11
  24. package/dist/theme/components/home/workspaces.tsx +171 -69
  25. package/dist/theme/components/seo-head-data.test.ts +196 -0
  26. package/dist/theme/components/seo-head-data.ts +490 -0
  27. package/dist/theme/components/seo-head.tsx +102 -0
  28. package/dist/theme/components/shared/IssueLinkIcon.tsx +42 -0
  29. package/dist/theme/components/shared/issue-link.css +74 -0
  30. package/dist/theme/components/workspaces/card.css +4 -0
  31. package/dist/theme/components/workspaces/card.tsx +3 -3
  32. package/dist/theme/components/workspaces/grid.tsx +18 -6
  33. package/dist/theme/index.tsx +6 -0
  34. package/dist/theme/lib/rich-text-parse.ts +740 -0
  35. package/dist/theme/lib/rich-text.test.tsx +342 -0
  36. package/dist/theme/lib/rich-text.tsx +86 -0
  37. package/dist/theme/lib/seo-url.ts +28 -0
  38. package/dist/theme/styles/overrides/fonts.css +7 -4
  39. package/dist/theme/styles/overrides/home.css +11 -2
  40. package/dist/theme/styles/overrides/rspress.css +10 -5
  41. package/dist/theme/styles/overrides/tokens.css +41 -23
  42. package/dist/theme/styles/rich-text.css +106 -0
  43. package/dist/theme/styles/themes/amber.css +32 -2
  44. package/dist/theme/styles/themes/arcade.css +16 -1
  45. package/dist/theme/styles/themes/grannysmith.css +32 -2
  46. package/dist/theme/styles/themes/honeycrisp.css +37 -7
  47. package/dist/theme/styles/themes/midnight.css +16 -1
  48. package/dist/theme/styles/themes/mulled.css +54 -9
  49. package/package.json +6 -5
  50. package/src/theme/components/home/cta.css +15 -0
  51. package/src/theme/components/home/cta.tsx +10 -2
  52. package/src/theme/components/home/feature-card.css +1 -1
  53. package/src/theme/components/home/feature-card.tsx +3 -2
  54. package/src/theme/components/home/feature.tsx +34 -31
  55. package/src/theme/components/home/hero-demo.css +11 -0
  56. package/src/theme/components/home/home-visual.tsx +226 -0
  57. package/src/theme/components/home/layout.tsx +350 -171
  58. package/src/theme/components/home/split.css +46 -19
  59. package/src/theme/components/home/split.tsx +28 -8
  60. package/src/theme/components/home/tabs.css +299 -0
  61. package/src/theme/components/home/tabs.tsx +351 -0
  62. package/src/theme/components/home/trust-strip.css +41 -0
  63. package/src/theme/components/home/trust-strip.tsx +168 -11
  64. package/src/theme/components/home/workspaces.tsx +171 -69
  65. package/src/theme/components/seo-head-data.test.ts +196 -0
  66. package/src/theme/components/seo-head-data.ts +490 -0
  67. package/src/theme/components/seo-head.tsx +102 -0
  68. package/src/theme/components/shared/IssueLinkIcon.tsx +42 -0
  69. package/src/theme/components/shared/issue-link.css +74 -0
  70. package/src/theme/components/workspaces/card.css +4 -0
  71. package/src/theme/components/workspaces/card.tsx +3 -3
  72. package/src/theme/components/workspaces/grid.tsx +18 -6
  73. package/src/theme/index.tsx +6 -0
  74. package/src/theme/lib/rich-text-parse.ts +740 -0
  75. package/src/theme/lib/rich-text.test.tsx +342 -0
  76. package/src/theme/lib/rich-text.tsx +86 -0
  77. package/src/theme/lib/seo-url.ts +28 -0
  78. package/src/theme/styles/overrides/fonts.css +7 -4
  79. package/src/theme/styles/overrides/home.css +11 -2
  80. package/src/theme/styles/overrides/rspress.css +10 -5
  81. package/src/theme/styles/overrides/tokens.css +41 -23
  82. package/src/theme/styles/rich-text.css +106 -0
  83. package/src/theme/styles/themes/amber.css +32 -2
  84. package/src/theme/styles/themes/arcade.css +16 -1
  85. package/src/theme/styles/themes/grannysmith.css +32 -2
  86. package/src/theme/styles/themes/honeycrisp.css +37 -7
  87. package/src/theme/styles/themes/midnight.css +16 -1
  88. package/src/theme/styles/themes/mulled.css +54 -9
  89. package/dist/theme/components/home/hero-demo-custom.tsx +0 -116
  90. package/dist/theme/components/home/split-visual-custom.tsx +0 -26
  91. package/src/theme/components/home/hero-demo-custom.tsx +0 -116
  92. package/src/theme/components/home/split-visual-custom.tsx +0 -26
@@ -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: 'Geist Pixel Square', var(--rp-font-family-base, sans-serif);
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 { HomeFeaturesConfig } from '@ciderpress/config'
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 { useCiderpress } from '../../hooks/use-ciderpress'
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 subtitle?: string
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
- * Custom HomeFeature override for ciderpress.
23
- * Uses useFrontmatter() hook to read features and renders with FeatureCard/FeatureGrid styling.
28
+ * Features grid block. Renders the supplied feature cards under a heading,
29
+ * or nothing when no cards are present.
24
30
  *
25
- * @returns React element with feature grid or null
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 { frontmatter } = useFrontmatter()
29
- const { home } = useCiderpress()
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.subtitle)
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(features)
54
+ return match(items)
52
55
  .with(
53
56
  P.when((f): f is readonly FeatureItem[] => Array.isArray(f) && f.length > 0),
54
- (items) => (
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
- {items.map((f, i) => renderFeature(f, i, gridConfig))}
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 gridConfig - Optional grid config for truncation
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
- gridConfig: HomeFeaturesConfig | undefined
86
+ truncate: TruncateConfig | undefined
84
87
  ): React.ReactElement {
85
- const titleLines = gridConfig && gridConfig.truncate && gridConfig.truncate.title
86
- const descLines = gridConfig && gridConfig.truncate && gridConfig.truncate.description
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
+ }