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

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 (106) 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 +335 -51
  5. package/dist/plugins/mermaid/MermaidRenderer.tsx +49 -6
  6. package/dist/theme/components/footer/site-footer.tsx +68 -9
  7. package/dist/theme/components/home/feature-card.tsx +5 -8
  8. package/dist/theme/components/home/feature.tsx +31 -11
  9. package/dist/theme/components/home/hero-demo-custom.tsx +116 -0
  10. package/dist/theme/components/home/hero-demo.css +27 -0
  11. package/dist/theme/components/home/layout.tsx +149 -26
  12. package/dist/theme/components/home/split-visual-custom.tsx +26 -0
  13. package/dist/theme/components/home/workspaces.tsx +39 -3
  14. package/dist/theme/components/nav/branch-tag.tsx +7 -7
  15. package/dist/theme/components/nav/ciderpress-docs-bar.css +13 -1
  16. package/dist/theme/components/nav/ciderpress-docs-bar.tsx +33 -2
  17. package/dist/theme/components/nav/ciderpress-header.css +2 -1
  18. package/dist/theme/components/nav/ciderpress-header.tsx +26 -3
  19. package/dist/theme/components/nav/ciderpress-nav-hamburger.css +50 -0
  20. package/dist/theme/components/nav/ciderpress-nav-hamburger.tsx +95 -9
  21. package/dist/theme/components/nav/ciderpress-nav-menu.css +88 -0
  22. package/dist/theme/components/nav/ciderpress-nav-menu.tsx +397 -38
  23. package/dist/theme/components/nav/ciderpress-nav-social-links.tsx +27 -22
  24. package/dist/theme/components/nav/floating-branch-indicator.tsx +7 -7
  25. package/dist/theme/components/nav/header-icon.css +24 -0
  26. package/dist/theme/components/nav/header-icon.tsx +119 -0
  27. package/dist/theme/components/nav/header-logo.css +16 -0
  28. package/dist/theme/components/nav/header-logo.tsx +200 -0
  29. package/dist/theme/components/nav/layout.tsx +117 -16
  30. package/dist/theme/components/nav/nav-logo.tsx +16 -19
  31. package/dist/theme/components/openapi/index.ts +0 -2
  32. package/dist/theme/components/openapi/openapi.css +0 -14
  33. package/dist/theme/components/shared/card-icon.tsx +37 -0
  34. package/dist/theme/components/shared/ciderpress-logo.tsx +5 -1
  35. package/dist/theme/components/shared/icon.tsx +120 -35
  36. package/dist/theme/components/shared/resolve-card-icon.ts +53 -12
  37. package/dist/theme/components/shared/section-card.tsx +37 -6
  38. package/dist/theme/components/sidebar/sidebar-badge.css +50 -0
  39. package/dist/theme/components/sidebar/sidebar-badge.tsx +153 -0
  40. package/dist/theme/components/sidebar/sidebar-links.tsx +6 -2
  41. package/dist/theme/components/sidebar/sidebar-scope.tsx +24 -1
  42. package/dist/theme/components/theme-provider.tsx +27 -11
  43. package/dist/theme/components/workspaces/card.tsx +7 -9
  44. package/dist/theme/hooks/use-ciderpress.ts +65 -5
  45. package/dist/theme/hooks/use-nav-items.ts +82 -11
  46. package/dist/theme/index.tsx +5 -3
  47. package/dist/theme/lib/read-social-links.ts +4 -3
  48. package/dist/theme/lib/theme-favicon.ts +1 -1
  49. package/dist/theme/lib/with-mount-base.ts +31 -0
  50. package/dist/theme/styles/overrides/footnotes.css +54 -0
  51. package/dist/theme/styles/overrides/lists.css +83 -0
  52. package/dist/theme/styles/overrides/rail.css +27 -10
  53. package/dist/theme/styles/overrides/section-card.css +9 -0
  54. package/dist/theme/styles/overrides/sidebar.css +22 -0
  55. package/package.json +18 -16
  56. package/src/theme/components/footer/site-footer.tsx +68 -9
  57. package/src/theme/components/home/feature-card.tsx +5 -8
  58. package/src/theme/components/home/feature.tsx +31 -11
  59. package/src/theme/components/home/hero-demo-custom.tsx +116 -0
  60. package/src/theme/components/home/hero-demo.css +27 -0
  61. package/src/theme/components/home/layout.tsx +149 -26
  62. package/src/theme/components/home/split-visual-custom.tsx +26 -0
  63. package/src/theme/components/home/workspaces.tsx +39 -3
  64. package/src/theme/components/nav/branch-tag.tsx +7 -7
  65. package/src/theme/components/nav/ciderpress-docs-bar.css +13 -1
  66. package/src/theme/components/nav/ciderpress-docs-bar.tsx +33 -2
  67. package/src/theme/components/nav/ciderpress-header.css +2 -1
  68. package/src/theme/components/nav/ciderpress-header.tsx +26 -3
  69. package/src/theme/components/nav/ciderpress-nav-hamburger.css +50 -0
  70. package/src/theme/components/nav/ciderpress-nav-hamburger.tsx +95 -9
  71. package/src/theme/components/nav/ciderpress-nav-menu.css +88 -0
  72. package/src/theme/components/nav/ciderpress-nav-menu.tsx +397 -38
  73. package/src/theme/components/nav/ciderpress-nav-social-links.tsx +27 -22
  74. package/src/theme/components/nav/floating-branch-indicator.tsx +7 -7
  75. package/src/theme/components/nav/header-icon.css +24 -0
  76. package/src/theme/components/nav/header-icon.tsx +119 -0
  77. package/src/theme/components/nav/header-logo.css +16 -0
  78. package/src/theme/components/nav/header-logo.tsx +200 -0
  79. package/src/theme/components/nav/layout.tsx +117 -16
  80. package/src/theme/components/nav/nav-logo.tsx +16 -19
  81. package/src/theme/components/openapi/index.ts +0 -2
  82. package/src/theme/components/openapi/openapi.css +0 -14
  83. package/src/theme/components/shared/card-icon.tsx +37 -0
  84. package/src/theme/components/shared/ciderpress-logo.tsx +5 -1
  85. package/src/theme/components/shared/icon.tsx +120 -35
  86. package/src/theme/components/shared/resolve-card-icon.ts +53 -12
  87. package/src/theme/components/shared/section-card.tsx +37 -6
  88. package/src/theme/components/sidebar/sidebar-badge.css +50 -0
  89. package/src/theme/components/sidebar/sidebar-badge.tsx +153 -0
  90. package/src/theme/components/sidebar/sidebar-links.tsx +6 -2
  91. package/src/theme/components/sidebar/sidebar-scope.tsx +24 -1
  92. package/src/theme/components/theme-provider.tsx +27 -11
  93. package/src/theme/components/workspaces/card.tsx +7 -9
  94. package/src/theme/hooks/use-ciderpress.ts +65 -5
  95. package/src/theme/hooks/use-nav-items.ts +82 -11
  96. package/src/theme/index.tsx +5 -3
  97. package/src/theme/lib/read-social-links.ts +4 -3
  98. package/src/theme/lib/theme-favicon.ts +1 -1
  99. package/src/theme/lib/with-mount-base.ts +31 -0
  100. package/src/theme/styles/overrides/footnotes.css +54 -0
  101. package/src/theme/styles/overrides/lists.css +83 -0
  102. package/src/theme/styles/overrides/rail.css +27 -10
  103. package/src/theme/styles/overrides/section-card.css +9 -0
  104. package/src/theme/styles/overrides/sidebar.css +22 -0
  105. package/dist/theme/components/openapi/copy-markdown-button.tsx +0 -41
  106. package/src/theme/components/openapi/copy-markdown-button.tsx +0 -41
@@ -0,0 +1,119 @@
1
+ /* oxlint-disable no-ternary -- raw-copied file; relaxed rules per packages/ui/CLAUDE.md */
2
+ import type { CiderpressConfig, IconConfig, IconImage } from '@ciderpress/config'
3
+ // oxlint-disable-next-line import/no-unresolved -- alias provided by createRspressConfig's resolve.alias
4
+ import userConfigModule from '@ciderpress/internal/user-config'
5
+ import { match } from 'massaman/match'
6
+ import type React from 'react'
7
+
8
+ import { Icon } from '../shared/icon'
9
+
10
+ import './header-icon.css'
11
+
12
+ /**
13
+ * Small brand chip rendered before `<HeaderLogo />` inside `cp-header-logo`.
14
+ *
15
+ * Reads `userConfig.brand.icon` from the bundled user config and routes:
16
+ * - Iconify id (`"devicon:react"`) → `<Icon>` component.
17
+ * - `{ id, color }` → `<Icon>` with inline color.
18
+ * - `{ src, alt }` → `<img>` with the user's asset.
19
+ * - missing → null (slot collapses, `<HeaderLogo />` stretches).
20
+ *
21
+ * Pairs with `<HeaderLogo />` for the canonical two-slot brand identity
22
+ * (icon chip + wordmark logo). Sites with only one of the two get the
23
+ * lone element rendered.
24
+ *
25
+ * @returns Branded icon chip ready to drop inside `cp-header-logo`, or null
26
+ */
27
+ export function HeaderIcon(): React.ReactElement | null {
28
+ const icon = readIconConfig(userConfigModule)
29
+ if (icon === undefined) {
30
+ return null
31
+ }
32
+ return match(resolveTopbar(icon))
33
+ .with({ kind: 'iconify' }, (r) => (
34
+ <span className="cp-header-icon">
35
+ <Icon icon={r.id} style={inlineColor(r.color)} />
36
+ </span>
37
+ ))
38
+ .with({ kind: 'image' }, (r) => (
39
+ <span className="cp-header-icon">
40
+ <img src={r.src} alt={r.alt} className="cp-header-icon__img" />
41
+ </span>
42
+ ))
43
+ .exhaustive()
44
+ }
45
+
46
+ export { HeaderIcon as default }
47
+
48
+ type TopbarIcon =
49
+ | { readonly kind: 'iconify'; readonly id: string; readonly color: string | undefined }
50
+ | { readonly kind: 'image'; readonly src: string; readonly alt: string }
51
+
52
+ /**
53
+ * Extract the `brand.icon` field from the bundled user config module.
54
+ *
55
+ * @private
56
+ * @param mod - Module imported from `@ciderpress/internal/user-config`
57
+ * @returns The `brand.icon` value or `undefined` when none is configured
58
+ */
59
+ function readIconConfig(mod: unknown): IconConfig | undefined {
60
+ if (mod === null || mod === undefined) {
61
+ return undefined
62
+ }
63
+ const asRecord = mod as Record<string, unknown>
64
+ const candidate =
65
+ asRecord.default !== null && asRecord.default !== undefined
66
+ ? (asRecord.default as Partial<CiderpressConfig>)
67
+ : (mod as Partial<CiderpressConfig>)
68
+ const brand = candidate.brand
69
+ if (brand === undefined) {
70
+ return undefined
71
+ }
72
+ return brand.icon
73
+ }
74
+
75
+ /**
76
+ * Discriminate `IconConfig` into a render-ready shape. Iconify strings get
77
+ * `color: undefined` (driven by ambient CSS); image objects supply alt
78
+ * defaulted to empty string per the underlying type.
79
+ *
80
+ * @private
81
+ * @param icon - Validated `IconConfig` value
82
+ * @returns Discriminated render shape
83
+ */
84
+ function resolveTopbar(icon: IconConfig): TopbarIcon {
85
+ if (typeof icon === 'string') {
86
+ return { kind: 'iconify', id: icon, color: undefined }
87
+ }
88
+ if (isImageIcon(icon)) {
89
+ return { kind: 'image', src: icon.src, alt: icon.alt ?? '' }
90
+ }
91
+ return { kind: 'iconify', id: icon.id, color: icon.color }
92
+ }
93
+
94
+ /**
95
+ * Build an inline-style object that sets the chip's `color` when the user
96
+ * supplied an explicit palette colour. Iconify renders fills via
97
+ * `currentColor` for monochrome icons, so this is enough to retint them.
98
+ *
99
+ * @private
100
+ * @param color - Optional palette colour token
101
+ * @returns Inline style object or undefined
102
+ */
103
+ function inlineColor(color: string | undefined): React.CSSProperties | undefined {
104
+ if (color === undefined) {
105
+ return undefined
106
+ }
107
+ return { color }
108
+ }
109
+
110
+ /**
111
+ * Type guard for the image-form `IconConfig` object.
112
+ *
113
+ * @private
114
+ * @param icon - Non-string icon config value
115
+ * @returns True when the object carries `src` (image form)
116
+ */
117
+ function isImageIcon(icon: Exclude<IconConfig, string>): icon is IconImage {
118
+ return 'src' in icon && typeof icon.src === 'string'
119
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Sizing for the brand mark rendered inside `cp-header-logo` by
3
+ * `<HeaderLogo />`.
4
+ *
5
+ * The parent `.cp-header-logo` already sets `--cp-header-logo-height`
6
+ * (default 28px) and `width: auto` for inline SVGs. Apply matching
7
+ * rules to image marks so a user `logo: '/logo.svg'` lines up exactly
8
+ * with the SVG wordmark height — no extra config needed.
9
+ */
10
+
11
+ .cp-header-logo__img,
12
+ .cp-header-logo__mark {
13
+ height: var(--cp-header-logo-height, 28px);
14
+ width: auto;
15
+ display: block;
16
+ }
@@ -0,0 +1,200 @@
1
+ /* oxlint-disable no-ternary -- raw-copied file; relaxed rules per packages/ui/CLAUDE.md */
2
+ import type { CiderpressConfig, LogoFn, LogoImage } from '@ciderpress/config'
3
+ // oxlint-disable-next-line import/no-unresolved -- alias provided by createRspressConfig's resolve.alias
4
+ import userConfigModule from '@ciderpress/internal/user-config'
5
+ import { BRAND_COLORS, DEFAULT_THEME_NAME } from '@ciderpress/theme'
6
+ import React, { useEffect, useState } from 'react'
7
+
8
+ import { withMountBase } from '../../lib/with-mount-base.ts'
9
+
10
+ import './header-logo.css'
11
+
12
+ interface ThemeContext {
13
+ readonly name: string
14
+ readonly variant: 'light' | 'dark'
15
+ readonly isDark: boolean
16
+ readonly colors: {
17
+ readonly brand: string
18
+ readonly brandHover: string
19
+ readonly brandSoft: string
20
+ readonly bg: string
21
+ readonly text: string
22
+ }
23
+ }
24
+
25
+ const COLOR_VARS = Object.freeze({
26
+ brand: '--rp-c-brand',
27
+ brandHover: '--rp-c-brand-dark',
28
+ brandSoft: '--rp-c-brand-tint',
29
+ bg: '--rp-c-bg',
30
+ text: '--rp-c-text-1',
31
+ })
32
+
33
+ const DEFAULT_BRAND = BRAND_COLORS[DEFAULT_THEME_NAME]
34
+ const FALLBACK_COLORS = Object.freeze({
35
+ brand: DEFAULT_BRAND.primary,
36
+ brandHover: DEFAULT_BRAND.hover,
37
+ brandSoft: DEFAULT_BRAND.soft,
38
+ bg: '#ffffff',
39
+ text: '#1f2937',
40
+ })
41
+
42
+ /**
43
+ * Brand mark rendered inside `<RouteLink className="cp-header-logo">`
44
+ * by `<CiderpressHeader />`.
45
+ *
46
+ * Reads `userConfig.brand.logo` from the bundled user config (same
47
+ * `@ciderpress/internal/user-config` alias `NavLogo` uses) and renders:
48
+ *
49
+ * - `string` → `<img src={...}>` pointed at the user's asset path.
50
+ * - `LogoFn` → call with the live theme context. `LogoImage` returns
51
+ * render as `<img>`; React node returns render as-is. Re-renders
52
+ * when `data-cp-theme` / `data-cp-variant` flips so theme-aware
53
+ * logos retint without a reload.
54
+ * - missing → the framework's themed `<CiderpressLogo />` wordmark.
55
+ *
56
+ * Replaces the prior approach where the header was hardcoded to
57
+ * `<CiderpressLogo />` and the user's logo was forwarded only to
58
+ * Rspress's nav slot — which renders at 1×1 on landing pages.
59
+ *
60
+ * @returns Branded logo element ready to drop inside `cp-header-logo`
61
+ */
62
+ export function HeaderLogo(): React.ReactElement | null {
63
+ const logoConfig = readLogoConfig(userConfigModule)
64
+ const [themeContext, setThemeContext] = useState<ThemeContext | null>(null)
65
+
66
+ useEffect(() => {
67
+ if (typeof logoConfig !== 'function') {
68
+ return
69
+ }
70
+ const html = globalThis.document.documentElement
71
+ setThemeContext(readThemeContext(html))
72
+
73
+ const observer = new MutationObserver(() => {
74
+ setThemeContext(readThemeContext(html))
75
+ })
76
+ observer.observe(html, {
77
+ attributes: true,
78
+ attributeFilter: ['data-cp-theme', 'data-cp-variant', 'class', 'style'],
79
+ })
80
+ return () => observer.disconnect()
81
+ }, [logoConfig])
82
+
83
+ if (logoConfig === undefined) {
84
+ // Default: the auto-generated `/logo.svg` written to the public dir
85
+ // by the banner module at sync time (derived from `config.title`).
86
+ // Sites that committed their own `public/logo.svg` already win here.
87
+ // The `<CiderpressLogo />` framework wordmark is opt-in via
88
+ // `logo: ({ theme }) => <CiderpressLogo />`.
89
+ return <img src={withMountBase('/logo.svg')} alt="" className="cp-header-logo__img" />
90
+ }
91
+
92
+ if (typeof logoConfig === 'string') {
93
+ return <img src={withMountBase(logoConfig)} alt="" className="cp-header-logo__img" />
94
+ }
95
+
96
+ if (themeContext === null) {
97
+ // Function-form logo: render nothing for a frame instead of flashing
98
+ // the ciderpress wordmark while themeContext resolves. The user's
99
+ // logo appears as soon as the layout effect runs.
100
+ return null
101
+ }
102
+
103
+ const result = logoConfig({ theme: themeContext })
104
+ if (isLogoImage(result)) {
105
+ return (
106
+ <img
107
+ src={withMountBase(result.src)}
108
+ alt={result.alt ?? ''}
109
+ width={result.width}
110
+ height={result.height}
111
+ className="cp-header-logo__img"
112
+ />
113
+ )
114
+ }
115
+ return <>{result as React.ReactNode}</>
116
+ }
117
+
118
+ export { HeaderLogo as default }
119
+
120
+ /**
121
+ * Extract the `logo` field from the bundled user config module.
122
+ *
123
+ * @private
124
+ * @param mod - Module imported from `@ciderpress/internal/user-config`
125
+ * @returns The `logo` value or `undefined` when none is configured
126
+ */
127
+ function readLogoConfig(mod: unknown): string | LogoFn | undefined {
128
+ if (mod === null || mod === undefined) {
129
+ return undefined
130
+ }
131
+ const asRecord = mod as Record<string, unknown>
132
+ const candidate =
133
+ asRecord.default !== null && asRecord.default !== undefined
134
+ ? (asRecord.default as Partial<CiderpressConfig>)
135
+ : (mod as Partial<CiderpressConfig>)
136
+ const brand = candidate.brand
137
+ if (brand === undefined) {
138
+ return undefined
139
+ }
140
+ const { logo } = brand
141
+ if (typeof logo === 'string') {
142
+ return logo
143
+ }
144
+ if (typeof logo === 'function') {
145
+ return logo
146
+ }
147
+ return undefined
148
+ }
149
+
150
+ /**
151
+ * Type guard distinguishing a `LogoImage` object from a React element.
152
+ *
153
+ * @private
154
+ * @param value - Return value from a `LogoFn`
155
+ * @returns True when `value` should be spread onto an `<img>` element
156
+ */
157
+ function isLogoImage(value: unknown): value is LogoImage {
158
+ if (value === null || typeof value !== 'object') {
159
+ return false
160
+ }
161
+ const obj = value as Record<string, unknown>
162
+ if ('$$typeof' in obj) {
163
+ return false
164
+ }
165
+ return typeof obj.src === 'string'
166
+ }
167
+
168
+ /**
169
+ * Build the live `ThemeContext` passed to a function-form `logo`.
170
+ *
171
+ * @private
172
+ * @param html - Document root element
173
+ * @returns Live theme context snapshot
174
+ */
175
+ function readThemeContext(html: HTMLElement): ThemeContext {
176
+ const variant: 'light' | 'dark' = html.dataset.cpVariant === 'light' ? 'light' : 'dark'
177
+ const name = typeof html.dataset.cpTheme === 'string' ? html.dataset.cpTheme : DEFAULT_THEME_NAME
178
+
179
+ const styles = globalThis.window.getComputedStyle(html)
180
+ function read(cssVar: string, fallback: string): string {
181
+ const raw = styles.getPropertyValue(cssVar).trim()
182
+ if (raw.length === 0) {
183
+ return fallback
184
+ }
185
+ return raw
186
+ }
187
+
188
+ return {
189
+ name,
190
+ variant,
191
+ isDark: variant === 'dark',
192
+ colors: {
193
+ brand: read(COLOR_VARS.brand, FALLBACK_COLORS.brand),
194
+ brandHover: read(COLOR_VARS.brandHover, FALLBACK_COLORS.brandHover),
195
+ brandSoft: read(COLOR_VARS.brandSoft, FALLBACK_COLORS.brandSoft),
196
+ bg: read(COLOR_VARS.bg, FALLBACK_COLORS.bg),
197
+ text: read(COLOR_VARS.text, FALLBACK_COLORS.text),
198
+ },
199
+ }
200
+ }
@@ -1,10 +1,10 @@
1
- import type { SiteEditConfig, SiteReportConfig } from '@ciderpress/config'
2
1
  import { useFrontmatter, useSite } from '@rspress/core/runtime'
3
2
  import { Layout as OriginalLayout } from '@rspress/core/theme-original'
4
3
  import { match, P } from 'massaman/match'
5
4
  import type React from 'react'
6
5
  import { useEffect } from 'react'
7
6
 
7
+ import type { CiderpressSiteBlock } from '../../hooks/use-ciderpress'
8
8
  import { useCiderpress } from '../../hooks/use-ciderpress'
9
9
  import { useNavItems } from '../../hooks/use-nav-items'
10
10
  import { readSocialLinks } from '../../lib/read-social-links'
@@ -59,7 +59,14 @@ export function Layout(): React.ReactElement {
59
59
  .with(true, () => configNavItems)
60
60
  .otherwise(() => scrapedNavItems)
61
61
  const socialLinks = readSocialLinks(rspressSite)
62
- const { announcement, topbarCta, sidebarPromo: sidebarPromoConfig, edit, report } = site ?? {}
62
+ const {
63
+ announcement,
64
+ topbarCta,
65
+ sidebarPromo: sidebarPromoConfig,
66
+ edit,
67
+ report,
68
+ feedback,
69
+ } = site ?? {}
63
70
  const { frontmatter } = useFrontmatter()
64
71
  const fmRecord = frontmatter as Record<string, unknown>
65
72
  const isHome = fmRecord.pageType === 'home'
@@ -113,13 +120,41 @@ export function Layout(): React.ReactElement {
113
120
 
114
121
  const metaActions = collectMetaActions({ edit, report, pagePath })
115
122
 
123
+ const feedbackSlot = match(feedback)
124
+ .with({ enabled: true }, (f) => <Feedback question={f.question} />)
125
+ .otherwise(() => null)
126
+
116
127
  const afterDocSlot = (
117
128
  <ContentFooterPortal>
118
- <Feedback />
129
+ {feedbackSlot}
119
130
  <MetaActions actions={metaActions} />
120
131
  </ContentFooterPortal>
121
132
  )
122
133
 
134
+ // Rspress's SSG-MD pipeline renders the React tree through
135
+ // `react-render-to-markdown` to produce the `.md` files served to
136
+ // `<LlmsCopyButton />`. If we render our chrome (header, docs bar,
137
+ // footer slot wrappers) during that pass, the topbar logo, search,
138
+ // and nav items all get serialized as markdown and end up at the
139
+ // top of the copied content. Short-circuit to just the article body
140
+ // via `<OriginalLayout />` (Rspress's stock layout already returns a
141
+ // Fragment-only output for SSG-MD). The visible HTML SSG path
142
+ // (`SSG_MD` undefined) still gets the full chrome.
143
+ if (import.meta.env.SSG_MD) {
144
+ return (
145
+ <OriginalLayout
146
+ top={null}
147
+ beforeNavMenu={null}
148
+ afterNavMenu={null}
149
+ beforeSidebar={null}
150
+ afterSidebar={null}
151
+ beforeDoc={null}
152
+ afterDoc={null}
153
+ bottom={null}
154
+ />
155
+ )
156
+ }
157
+
123
158
  return (
124
159
  <>
125
160
  <CiderpressHeader
@@ -158,27 +193,80 @@ function readNavItems(site: unknown): readonly CiderpressNavMenuItem[] {
158
193
  if (!Array.isArray(candidate)) {
159
194
  return []
160
195
  }
161
- return candidate.filter(
162
- (item): item is CiderpressNavMenuItem =>
163
- typeof item === 'object' &&
164
- item !== null &&
165
- typeof (item as { text?: unknown }).text === 'string' &&
166
- typeof (item as { link?: unknown }).link === 'string'
167
- )
196
+ return candidate.map(toNavItem).filter((item): item is CiderpressNavMenuItem => item !== null)
197
+ }
198
+
199
+ /**
200
+ * Coerce one raw Rspress nav entry into a `CiderpressNavMenuItem`,
201
+ * recursing into `items` for dropdown parents. Entries missing a
202
+ * string `text`, or that are neither a link nor a non-empty dropdown,
203
+ * are dropped (returned as `null`).
204
+ *
205
+ * @private
206
+ * @param raw - Untyped nav entry read off `site.nav`
207
+ * @returns Parsed nav item, or `null` when the entry is unusable
208
+ */
209
+ function toNavItem(raw: unknown): CiderpressNavMenuItem | null {
210
+ if (typeof raw !== 'object' || raw === null) {
211
+ return null
212
+ }
213
+ const record = raw as {
214
+ readonly text?: unknown
215
+ readonly link?: unknown
216
+ readonly items?: unknown
217
+ }
218
+ if (typeof record.text !== 'string') {
219
+ return null
220
+ }
221
+ const link = match(record.link)
222
+ .with(P.string, (value) => value)
223
+ .otherwise(() => undefined)
224
+ const items = match(Array.isArray(record.items))
225
+ .with(true, () =>
226
+ (record.items as readonly unknown[])
227
+ .map(toNavItem)
228
+ .filter((child): child is CiderpressNavMenuItem => child !== null)
229
+ )
230
+ .otherwise(() => [])
231
+ return buildNavItem({ text: record.text, link, items })
232
+ }
233
+
234
+ /**
235
+ * Assemble a `CiderpressNavMenuItem` from its parsed parts, keeping
236
+ * only the properties that are actually present so the result matches
237
+ * the optional-field contract. Returns `null` for a dead entry (no
238
+ * link and no children).
239
+ *
240
+ * @private
241
+ * @param params - The parsed text, optional link, and child items
242
+ * @returns A nav item, or `null` when there is nothing to render
243
+ */
244
+ function buildNavItem(params: {
245
+ readonly text: string
246
+ readonly link: string | undefined
247
+ readonly items: readonly CiderpressNavMenuItem[]
248
+ }): CiderpressNavMenuItem | null {
249
+ const { text, link, items } = params
250
+ return match({ hasLink: link !== undefined, hasItems: items.length > 0 })
251
+ .with({ hasLink: true, hasItems: true }, () => ({ text, link, items }))
252
+ .with({ hasLink: true, hasItems: false }, () => ({ text, link }))
253
+ .with({ hasLink: false, hasItems: true }, () => ({ text, items }))
254
+ .otherwise(() => null)
168
255
  }
169
256
 
170
257
  /**
171
258
  * Build the list of `MetaAction`s to render under each doc page,
172
- * derived from `site.edit` and `site.report`. Returns an empty array
173
- * when neither is configured.
259
+ * derived from the serialised `site.edit` / `site.report` blocks
260
+ * (originally configured via top-level `editLink` / `reportLink`).
261
+ * Returns an empty array when neither is configured.
174
262
  *
175
263
  * @private
176
264
  * @param params - Site edit/report config plus current page path
177
265
  * @returns Ordered list of meta actions
178
266
  */
179
267
  function collectMetaActions(params: {
180
- readonly edit: SiteEditConfig | undefined
181
- readonly report: SiteReportConfig | undefined
268
+ readonly edit: CiderpressSiteBlock['edit']
269
+ readonly report: CiderpressSiteBlock['report']
182
270
  readonly pagePath: string
183
271
  }): readonly MetaAction[] {
184
272
  const { edit, report, pagePath } = params
@@ -204,9 +292,17 @@ function collectMetaActions(params: {
204
292
  }
205
293
 
206
294
  /**
295
+ * Construct the per-page "edit this page" URL from a serialised
296
+ * `editLink` block. The `buildSiteBlock` helper guarantees `repo` is
297
+ * present whenever the block itself is — callers should already have
298
+ * skipped the action when the block was `undefined`.
299
+ *
207
300
  * @private
301
+ * @param edit - Site edit block (non-undefined when called)
302
+ * @param pagePath - Current page's source path (with leading slash)
303
+ * @returns Fully-qualified URL string
208
304
  */
209
- function buildEditUrl(edit: SiteEditConfig, pagePath: string): string {
305
+ function buildEditUrl(edit: NonNullable<CiderpressSiteBlock['edit']>, pagePath: string): string {
210
306
  if (edit.repo.startsWith('http')) {
211
307
  return edit.repo
212
308
  }
@@ -219,9 +315,14 @@ function buildEditUrl(edit: SiteEditConfig, pagePath: string): string {
219
315
  }
220
316
 
221
317
  /**
318
+ * Construct the "report an issue" URL from a serialised `reportLink`
319
+ * block. Same `repo`-presence contract as {@link buildEditUrl}.
320
+ *
222
321
  * @private
322
+ * @param report - Site report block (non-undefined when called)
323
+ * @returns Fully-qualified URL string
223
324
  */
224
- function buildReportUrl(report: SiteReportConfig): string {
325
+ function buildReportUrl(report: NonNullable<CiderpressSiteBlock['report']>): string {
225
326
  if (report.repo.startsWith('http')) {
226
327
  return report.repo
227
328
  }
@@ -6,8 +6,6 @@ import { BRAND_COLORS, DEFAULT_THEME_NAME } from '@ciderpress/theme'
6
6
  import React, { useEffect, useState } from 'react'
7
7
  import { createPortal } from 'react-dom'
8
8
 
9
- import { CiderpressLogo } from '../shared/ciderpress-logo'
10
-
11
9
  import './nav-logo.css'
12
10
 
13
11
  /**
@@ -37,7 +35,7 @@ const FALLBACK_COLORS = Object.freeze({
37
35
  })
38
36
 
39
37
  /**
40
- * `NavLogo` — Rspress-aware logo slot that handles all three logo configs.
38
+ * `NavLogo` — Rspress-aware logo slot for function-form `logo` configs.
41
39
  *
42
40
  * - Reads `userConfig.logo` from the bundled user config via the
43
41
  * `@ciderpress/internal/user-config` alias.
@@ -52,7 +50,10 @@ const FALLBACK_COLORS = Object.freeze({
52
50
  * - `logo` is a function → call with the live `LogoContext`. If the return
53
51
  * value is a `LogoImage`-shaped object, spread onto `<img>`. Otherwise
54
52
  * render as a React node.
55
- * - `logo` is missing → render the default themed `<CiderpressLogo />`.
53
+ * - `logo` is missing → render `null`; Rspress paints the auto-generated
54
+ * `/logo.svg` (written to the public dir by the banner module, derived
55
+ * from the project title). Users who want the framework's themed wordmark
56
+ * opt in via `logo: ({ theme }) => <CiderpressLogo />`.
56
57
  *
57
58
  * @returns Portaled logo element or null
58
59
  */
@@ -116,13 +117,11 @@ export function NavLogo(): React.ReactElement | null {
116
117
 
117
118
  const logoConfig = readLogoConfig(userConfigModule)
118
119
 
119
- // String-form user logos are handled by Rspress's native <img>. NavLogo
120
- // takes over for both the default case (theme-aware CiderpressLogo SVG) and
121
- // function-form logos (user-defined render). The static `/logo.svg` from
122
- // Rspress's <img> renders immediately on first paint; once NavLogo's
123
- // portal mounts, CSS hides the static img so only the theme-aware version
124
- // shows. Theme color flips via `currentColor` reading from `--rp-c-brand`.
125
- if (typeof logoConfig === 'string') {
120
+ // String-form user logos and the missing-logo default both ride Rspress's
121
+ // native <img>. resolveLogo() in @ciderpress/ui/config maps the missing
122
+ // case to the auto-generated /logo.svg path. Only function-form logos
123
+ // need this portal — they re-render when the theme/variant changes.
124
+ if (typeof logoConfig !== 'function') {
126
125
  return null
127
126
  }
128
127
 
@@ -130,13 +129,7 @@ export function NavLogo(): React.ReactElement | null {
130
129
  return null
131
130
  }
132
131
 
133
- // Raw-copied file — plain conditional, no ts-pattern (see packages/ui/CLAUDE.md).
134
- const rendered =
135
- typeof logoConfig === 'function' ? (
136
- renderLogoFn({ fn: logoConfig, theme: themeContext })
137
- ) : (
138
- <CiderpressLogo />
139
- )
132
+ const rendered = renderLogoFn({ fn: logoConfig, theme: themeContext })
140
133
 
141
134
  return createPortal(<span className="cp-nav-logo">{rendered}</span>, target)
142
135
  }
@@ -160,7 +153,11 @@ function readLogoConfig(mod: unknown): string | LogoFn | undefined {
160
153
  asRecord.default !== null && asRecord.default !== undefined
161
154
  ? (asRecord.default as Partial<CiderpressConfig>)
162
155
  : (mod as Partial<CiderpressConfig>)
163
- const { logo } = candidate
156
+ const brand = candidate.brand
157
+ if (brand === undefined) {
158
+ return undefined
159
+ }
160
+ const { logo } = brand
164
161
  if (typeof logo === 'string') {
165
162
  return logo
166
163
  }
@@ -1,5 +1,3 @@
1
- export { CopyMarkdownButton } from './copy-markdown-button'
2
- export type { CopyMarkdownButtonProps } from './copy-markdown-button'
3
1
  export { OpenAPIOperation } from './operation'
4
2
  export type { OpenAPIOperationProps } from './operation'
5
3
  export { OpenAPIOverview } from './overview'
@@ -5,20 +5,6 @@
5
5
  * Uses BEM naming: block__element--modifier.
6
6
  */
7
7
 
8
- /* ── Hide Rspress default LLMs copy button on OpenAPI pages ── */
9
- /* OpenAPI pages use a custom CopyMarkdownButton with build-time rendered markdown */
10
- :has(.cp-oas-operation) > .rp-llms-container,
11
- :has(.cp-oas-overview) > .rp-llms-container {
12
- display: none;
13
- }
14
-
15
- /* ── Copy markdown button ─────────────────────────────────── */
16
- .cp-oas-copy-markdown {
17
- float: right;
18
- margin-top: 8px;
19
- margin-bottom: 0;
20
- }
21
-
22
8
  /* ── Two-column operation layout ───────────────────────────── */
23
9
  .cp-oas-operation {
24
10
  display: grid;
@@ -0,0 +1,37 @@
1
+ import { match } from 'massaman/match'
2
+ import type React from 'react'
3
+
4
+ import { Icon } from './icon'
5
+ import type { ResolvedCardIcon } from './resolve-card-icon'
6
+
7
+ interface CardIconProps {
8
+ readonly resolved: ResolvedCardIcon
9
+ readonly className?: string
10
+ }
11
+
12
+ /**
13
+ * Card-surface icon renderer. Branches on the discriminated
14
+ * `ResolvedCardIcon`:
15
+ *
16
+ * - `kind: 'iconify'` → `<Icon icon={id} />` wrapped in a colour swatch.
17
+ * - `kind: 'image'` → `<img src alt>` painted at the same chip size
18
+ * without colour tinting (the image carries its own art direction).
19
+ *
20
+ * @param props - Resolved icon + optional className applied to the swatch wrapper
21
+ * @returns Branded icon element
22
+ */
23
+ export function CardIcon(props: CardIconProps): React.ReactElement {
24
+ const wrapperClass = props.className ?? 'cp-card__icon'
25
+ return match(props.resolved)
26
+ .with({ kind: 'iconify' }, (r) => (
27
+ <span className={`${wrapperClass} ${wrapperClass}--${r.color}`}>
28
+ <Icon icon={r.id} />
29
+ </span>
30
+ ))
31
+ .with({ kind: 'image' }, (r) => (
32
+ <span className={`${wrapperClass} ${wrapperClass}--image`}>
33
+ <img src={r.src} alt={r.alt} className={`${wrapperClass}-img`} />
34
+ </span>
35
+ ))
36
+ .exhaustive()
37
+ }
@@ -1,5 +1,9 @@
1
1
  import { BRAND_COLORS, DEFAULT_THEME_NAME } from '@ciderpress/theme'
2
- import type React from 'react'
2
+ // Value import (not `import type`) — Rslib emits the classic JSX runtime
3
+ // (`React.createElement(...)`), so the `React` binding must survive
4
+ // compilation. With a type-only import, the bundle throws `React is not
5
+ // defined` at runtime.
6
+ import React from 'react'
3
7
 
4
8
  const FONT_STACK = "'SF Mono', 'Fira Code', 'JetBrains Mono', Consolas, monospace"
5
9
  /**