@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.
Files changed (67) hide show
  1. package/dist/index.d.ts +1 -1
  2. package/dist/index.mjs +4 -3
  3. package/dist/node.d.ts +1 -1
  4. package/dist/node.mjs +316 -50
  5. package/dist/theme/components/footer/site-footer.tsx +68 -9
  6. package/dist/theme/components/home/feature-card.tsx +5 -8
  7. package/dist/theme/components/home/feature.tsx +31 -11
  8. package/dist/theme/components/home/hero-demo-custom.tsx +116 -0
  9. package/dist/theme/components/home/hero-demo.css +27 -0
  10. package/dist/theme/components/home/layout.tsx +149 -26
  11. package/dist/theme/components/home/split-visual-custom.tsx +26 -0
  12. package/dist/theme/components/home/workspaces.tsx +39 -3
  13. package/dist/theme/components/nav/branch-tag.tsx +7 -7
  14. package/dist/theme/components/nav/ciderpress-header.css +2 -1
  15. package/dist/theme/components/nav/ciderpress-header.tsx +26 -3
  16. package/dist/theme/components/nav/ciderpress-nav-social-links.tsx +9 -7
  17. package/dist/theme/components/nav/floating-branch-indicator.tsx +7 -7
  18. package/dist/theme/components/nav/header-icon.css +24 -0
  19. package/dist/theme/components/nav/header-icon.tsx +119 -0
  20. package/dist/theme/components/nav/header-logo.css +16 -0
  21. package/dist/theme/components/nav/header-logo.tsx +200 -0
  22. package/dist/theme/components/nav/layout.tsx +45 -7
  23. package/dist/theme/components/nav/nav-logo.tsx +5 -1
  24. package/dist/theme/components/shared/card-icon.tsx +37 -0
  25. package/dist/theme/components/shared/ciderpress-logo.tsx +5 -1
  26. package/dist/theme/components/shared/icon.tsx +119 -35
  27. package/dist/theme/components/shared/resolve-card-icon.ts +53 -12
  28. package/dist/theme/components/shared/section-card.tsx +9 -6
  29. package/dist/theme/components/sidebar/sidebar-links.tsx +6 -2
  30. package/dist/theme/components/theme-provider.tsx +27 -11
  31. package/dist/theme/components/workspaces/card.tsx +7 -9
  32. package/dist/theme/hooks/use-ciderpress.ts +60 -5
  33. package/dist/theme/lib/read-social-links.ts +4 -3
  34. package/dist/theme/lib/theme-favicon.ts +1 -1
  35. package/dist/theme/lib/with-mount-base.ts +31 -0
  36. package/package.json +13 -13
  37. package/src/theme/components/footer/site-footer.tsx +68 -9
  38. package/src/theme/components/home/feature-card.tsx +5 -8
  39. package/src/theme/components/home/feature.tsx +31 -11
  40. package/src/theme/components/home/hero-demo-custom.tsx +116 -0
  41. package/src/theme/components/home/hero-demo.css +27 -0
  42. package/src/theme/components/home/layout.tsx +149 -26
  43. package/src/theme/components/home/split-visual-custom.tsx +26 -0
  44. package/src/theme/components/home/workspaces.tsx +39 -3
  45. package/src/theme/components/nav/branch-tag.tsx +7 -7
  46. package/src/theme/components/nav/ciderpress-header.css +2 -1
  47. package/src/theme/components/nav/ciderpress-header.tsx +26 -3
  48. package/src/theme/components/nav/ciderpress-nav-social-links.tsx +9 -7
  49. package/src/theme/components/nav/floating-branch-indicator.tsx +7 -7
  50. package/src/theme/components/nav/header-icon.css +24 -0
  51. package/src/theme/components/nav/header-icon.tsx +119 -0
  52. package/src/theme/components/nav/header-logo.css +16 -0
  53. package/src/theme/components/nav/header-logo.tsx +200 -0
  54. package/src/theme/components/nav/layout.tsx +45 -7
  55. package/src/theme/components/nav/nav-logo.tsx +5 -1
  56. package/src/theme/components/shared/card-icon.tsx +37 -0
  57. package/src/theme/components/shared/ciderpress-logo.tsx +5 -1
  58. package/src/theme/components/shared/icon.tsx +119 -35
  59. package/src/theme/components/shared/resolve-card-icon.ts +53 -12
  60. package/src/theme/components/shared/section-card.tsx +9 -6
  61. package/src/theme/components/sidebar/sidebar-links.tsx +6 -2
  62. package/src/theme/components/theme-provider.tsx +27 -11
  63. package/src/theme/components/workspaces/card.tsx +7 -9
  64. package/src/theme/hooks/use-ciderpress.ts +60 -5
  65. package/src/theme/lib/read-social-links.ts +4 -3
  66. package/src/theme/lib/theme-favicon.ts +1 -1
  67. package/src/theme/lib/with-mount-base.ts +31 -0
@@ -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'
@@ -120,6 +120,30 @@ export function Layout(): React.ReactElement {
120
120
  </ContentFooterPortal>
121
121
  )
122
122
 
123
+ // Rspress's SSG-MD pipeline renders the React tree through
124
+ // `react-render-to-markdown` to produce the `.md` files served to
125
+ // `<LlmsCopyButton />`. If we render our chrome (header, docs bar,
126
+ // footer slot wrappers) during that pass, the topbar logo, search,
127
+ // and nav items all get serialized as markdown and end up at the
128
+ // top of the copied content. Short-circuit to just the article body
129
+ // via `<OriginalLayout />` (Rspress's stock layout already returns a
130
+ // Fragment-only output for SSG-MD). The visible HTML SSG path
131
+ // (`SSG_MD` undefined) still gets the full chrome.
132
+ if (import.meta.env.SSG_MD) {
133
+ return (
134
+ <OriginalLayout
135
+ top={null}
136
+ beforeNavMenu={null}
137
+ afterNavMenu={null}
138
+ beforeSidebar={null}
139
+ afterSidebar={null}
140
+ beforeDoc={null}
141
+ afterDoc={null}
142
+ bottom={null}
143
+ />
144
+ )
145
+ }
146
+
123
147
  return (
124
148
  <>
125
149
  <CiderpressHeader
@@ -169,16 +193,17 @@ function readNavItems(site: unknown): readonly CiderpressNavMenuItem[] {
169
193
 
170
194
  /**
171
195
  * 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.
196
+ * derived from the serialised `site.edit` / `site.report` blocks
197
+ * (originally configured via top-level `editLink` / `reportLink`).
198
+ * Returns an empty array when neither is configured.
174
199
  *
175
200
  * @private
176
201
  * @param params - Site edit/report config plus current page path
177
202
  * @returns Ordered list of meta actions
178
203
  */
179
204
  function collectMetaActions(params: {
180
- readonly edit: SiteEditConfig | undefined
181
- readonly report: SiteReportConfig | undefined
205
+ readonly edit: CiderpressSiteBlock['edit']
206
+ readonly report: CiderpressSiteBlock['report']
182
207
  readonly pagePath: string
183
208
  }): readonly MetaAction[] {
184
209
  const { edit, report, pagePath } = params
@@ -204,9 +229,17 @@ function collectMetaActions(params: {
204
229
  }
205
230
 
206
231
  /**
232
+ * Construct the per-page "edit this page" URL from a serialised
233
+ * `editLink` block. The `buildSiteBlock` helper guarantees `repo` is
234
+ * present whenever the block itself is — callers should already have
235
+ * skipped the action when the block was `undefined`.
236
+ *
207
237
  * @private
238
+ * @param edit - Site edit block (non-undefined when called)
239
+ * @param pagePath - Current page's source path (with leading slash)
240
+ * @returns Fully-qualified URL string
208
241
  */
209
- function buildEditUrl(edit: SiteEditConfig, pagePath: string): string {
242
+ function buildEditUrl(edit: NonNullable<CiderpressSiteBlock['edit']>, pagePath: string): string {
210
243
  if (edit.repo.startsWith('http')) {
211
244
  return edit.repo
212
245
  }
@@ -219,9 +252,14 @@ function buildEditUrl(edit: SiteEditConfig, pagePath: string): string {
219
252
  }
220
253
 
221
254
  /**
255
+ * Construct the "report an issue" URL from a serialised `reportLink`
256
+ * block. Same `repo`-presence contract as {@link buildEditUrl}.
257
+ *
222
258
  * @private
259
+ * @param report - Site report block (non-undefined when called)
260
+ * @returns Fully-qualified URL string
223
261
  */
224
- function buildReportUrl(report: SiteReportConfig): string {
262
+ function buildReportUrl(report: NonNullable<CiderpressSiteBlock['report']>): string {
225
263
  if (report.repo.startsWith('http')) {
226
264
  return report.repo
227
265
  }
@@ -153,7 +153,11 @@ function readLogoConfig(mod: unknown): string | LogoFn | undefined {
153
153
  asRecord.default !== null && asRecord.default !== undefined
154
154
  ? (asRecord.default as Partial<CiderpressConfig>)
155
155
  : (mod as Partial<CiderpressConfig>)
156
- const { logo } = candidate
156
+ const brand = candidate.brand
157
+ if (brand === undefined) {
158
+ return undefined
159
+ }
160
+ const { logo } = brand
157
161
  if (typeof logo === 'string') {
158
162
  return logo
159
163
  }
@@ -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
  /**
@@ -1,41 +1,125 @@
1
- import catppuccin from '@iconify-json/catppuccin/icons.json' with { type: 'json' }
2
- import devicon from '@iconify-json/devicon/icons.json' with { type: 'json' }
3
- import logos from '@iconify-json/logos/icons.json' with { type: 'json' }
4
- import materialIconTheme from '@iconify-json/material-icon-theme/icons.json' with { type: 'json' }
5
- import mdi from '@iconify-json/mdi/icons.json' with { type: 'json' }
6
- import pixelarticons from '@iconify-json/pixelarticons/icons.json' with { type: 'json' }
7
- import simpleIcons from '@iconify-json/simple-icons/icons.json' with { type: 'json' }
8
- import skillIcons from '@iconify-json/skill-icons/icons.json' with { type: 'json' }
9
- import vscodeIcons from '@iconify-json/vscode-icons/icons.json' with { type: 'json' }
10
- import { addCollection, Icon } from '@iconify/react'
11
-
12
- // Register all icon collections for offline Iconify resolution.
13
- // `addCollection` is called purely for its side effect of mutating
14
- // Iconify's internal registry. Holding the return values in a
15
- // throwaway `const` keeps the call list a single expression statement
16
- // rather than nine misleading named exports.
17
- // oxlint-disable-next-line no-unused-vars
18
- const _iconCollectionsLoaded = [
19
- addCollection(cast(pixelarticons)),
20
- addCollection(cast(devicon)),
21
- addCollection(cast(mdi)),
22
- addCollection(cast(simpleIcons)),
23
- addCollection(cast(skillIcons)),
24
- addCollection(cast(catppuccin)),
25
- addCollection(cast(logos)),
26
- addCollection(cast(vscodeIcons)),
27
- addCollection(cast(materialIconTheme)),
28
- ] as const
29
-
30
- export { Icon }
1
+ import { addCollection, Icon as IconifyIcon } from '@iconify/react'
2
+ import type { IconProps } from '@iconify/react'
3
+ import type React from 'react'
4
+ import { useEffect, useState } from 'react'
31
5
 
32
6
  /**
33
- * Cast an icon JSON import to the type expected by `addCollection`.
7
+ * Per-collection lazy loaders keyed by Iconify prefix.
8
+ *
9
+ * Each entry is a bare dynamic `import()` so the consuming site's Rsbuild
10
+ * build emits **one async chunk per collection** instead of folding all nine
11
+ * `icons.json` files into a single eager ~30MB chunk pulled on every route.
12
+ * Two consequences fall out of that:
13
+ *
14
+ * - **Deployability** — the largest collection (`logos`, ~8MB) stays well
15
+ * under per-file host caps (Cloudflare Pages rejects files >25MB), where
16
+ * the combined blob failed outright.
17
+ * - **Performance** — a page only downloads the collections it actually
18
+ * references, not the full set on first paint.
19
+ *
20
+ * The specifiers are string literals (not computed) so the bundler can
21
+ * statically resolve every chunk at build time.
22
+ *
23
+ * @private
24
+ */
25
+ const COLLECTION_LOADERS: Record<string, () => Promise<{ readonly default: unknown }>> = {
26
+ catppuccin: () => import('@iconify-json/catppuccin/icons.json'),
27
+ devicon: () => import('@iconify-json/devicon/icons.json'),
28
+ logos: () => import('@iconify-json/logos/icons.json'),
29
+ 'material-icon-theme': () => import('@iconify-json/material-icon-theme/icons.json'),
30
+ mdi: () => import('@iconify-json/mdi/icons.json'),
31
+ pixelarticons: () => import('@iconify-json/pixelarticons/icons.json'),
32
+ 'simple-icons': () => import('@iconify-json/simple-icons/icons.json'),
33
+ 'skill-icons': () => import('@iconify-json/skill-icons/icons.json'),
34
+ 'vscode-icons': () => import('@iconify-json/vscode-icons/icons.json'),
35
+ }
36
+
37
+ /**
38
+ * Cache of in-flight / settled collection registrations keyed by prefix.
39
+ * Guarantees each collection's chunk is fetched and merged into Iconify's
40
+ * registry exactly once, regardless of how many `<Icon>` instances on a
41
+ * page reference it.
42
+ *
43
+ * @private
44
+ */
45
+ const collectionCache = new Map<string, Promise<void>>()
46
+
47
+ /**
48
+ * Offline-registered Iconify icon.
49
+ *
50
+ * Renders `@iconify/react`'s `Icon` unchanged, but registers the icon's
51
+ * collection on demand: the first time a prefix is seen the matching
52
+ * `@iconify-json` chunk is dynamically imported and merged into Iconify's
53
+ * registry, then a re-render paints the resolved SVG. Because `IconifyIcon`
54
+ * reads the live registry on every render, an icon appears as soon as its
55
+ * collection chunk resolves.
56
+ *
57
+ * @param props - Standard `@iconify/react` icon props; `icon` is the
58
+ * `prefix:name` identifier (e.g. `devicon:typescript`)
59
+ * @returns The Iconify icon element
60
+ */
61
+ export function Icon(props: IconProps): React.ReactElement {
62
+ const prefix = resolvePrefix(props.icon)
63
+ const [, markRegistered] = useState(false)
64
+
65
+ useEffect(() => {
66
+ ensureCollection(prefix).then(() => markRegistered(true))
67
+ }, [prefix])
68
+
69
+ return <IconifyIcon {...props} />
70
+ }
71
+
72
+ /**
73
+ * Dynamically import and register the collection for a prefix, once.
74
+ *
75
+ * Returns the cached registration promise on repeat calls so the chunk is
76
+ * fetched a single time. Unknown prefixes (no bundled collection) resolve
77
+ * immediately — `IconifyIcon` falls back to its own resolution for those.
78
+ *
79
+ * @private
80
+ * @param prefix - Iconify collection prefix (e.g. `logos`)
81
+ * @returns Promise that settles once the collection is registered
82
+ */
83
+ function ensureCollection(prefix: string): Promise<void> {
84
+ const cached = collectionCache.get(prefix)
85
+ if (cached !== undefined) {
86
+ return cached
87
+ }
88
+ const loader = COLLECTION_LOADERS[prefix]
89
+ if (loader === undefined) {
90
+ return Promise.resolve()
91
+ }
92
+ const registration = loader().then(registerModule)
93
+ collectionCache.set(prefix, registration)
94
+ return registration
95
+ }
96
+
97
+ /**
98
+ * Merge a dynamically imported `icons.json` module into Iconify's registry.
99
+ *
100
+ * @private
101
+ * @param mod - Module namespace whose `default` export is the collection JSON
102
+ */
103
+ function registerModule(mod: { readonly default: unknown }): void {
104
+ addCollection(mod.default as Parameters<typeof addCollection>[0])
105
+ }
106
+
107
+ /**
108
+ * Extract the collection prefix from an Iconify identifier. Non-string icon
109
+ * inputs and identifiers without a `prefix:name` shape yield an empty string,
110
+ * which `ensureCollection` treats as "nothing to load".
34
111
  *
35
112
  * @private
36
- * @param v - Raw icon JSON import
37
- * @returns Value cast to the addCollection parameter type
113
+ * @param icon - The `icon` prop passed to `<Icon>`
114
+ * @returns The collection prefix, or `''` when none can be determined
38
115
  */
39
- function cast(v: unknown): Parameters<typeof addCollection>[0] {
40
- return v as Parameters<typeof addCollection>[0]
116
+ function resolvePrefix(icon: IconProps['icon']): string {
117
+ if (typeof icon !== 'string') {
118
+ return ''
119
+ }
120
+ const parts = icon.split(':')
121
+ if (parts.length < 2) {
122
+ return ''
123
+ }
124
+ return parts[0]
41
125
  }