@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
@@ -1,4 +1,4 @@
1
- import { useLocation } from '@rspress/core/runtime'
1
+ import { removeBase, useLocation } from '@rspress/core/runtime'
2
2
  import { clsx } from 'clsx'
3
3
  import { match } from 'massaman/match'
4
4
  import { useEffect, useMemo, useRef, useState } from 'react'
@@ -22,13 +22,26 @@ const OVERFLOW_TOGGLE_WIDTH = 96
22
22
  */
23
23
  const DEFAULT_GAP_PX = 16
24
24
 
25
+ /**
26
+ * Grace period before a hover-opened dropdown closes on mouse-leave.
27
+ * Long enough to cross the gap into the popover without it snapping
28
+ * shut, short enough not to feel sticky.
29
+ */
30
+ const CLOSE_DELAY_MS = 220
31
+
25
32
  /**
26
33
  * Single primary-nav entry — matches the shape of `site.nav[*]` in
27
34
  * `ciderpress.config.ts`.
35
+ *
36
+ * An entry is either a leaf (has `link`, no `items`) or a dropdown
37
+ * parent (has `items`, `link` optional). When a parent has children,
38
+ * its own `link` is ignored — the label toggles the submenu rather
39
+ * than navigating.
28
40
  */
29
41
  export interface CiderpressNavMenuItem {
30
42
  readonly text: string
31
- readonly link: string
43
+ readonly link?: string
44
+ readonly items?: readonly CiderpressNavMenuItem[]
32
45
  }
33
46
 
34
47
  /**
@@ -151,24 +164,25 @@ export function CiderpressNavMenu(props: CiderpressNavMenuProps): React.ReactEle
151
164
  return (
152
165
  <>
153
166
  <div ref={measureRef} className="cp-nav-menu-measure" aria-hidden="true">
154
- {items.map((item) => (
155
- <span key={item.link} data-cp-menu-item className="cp-nav-menu__item">
167
+ {items.map((item, index) => (
168
+ <span
169
+ key={`${itemKey(item)}::${index}`}
170
+ data-cp-menu-item
171
+ className={clsx('cp-nav-menu__item', {
172
+ // Mirror the live dropdown toggle's label→chevron gap so the
173
+ // measured width matches what actually renders inline.
174
+ 'cp-nav-menu__item--measured-dropdown': hasChildren(item),
175
+ })}
176
+ >
156
177
  {item.text}
178
+ {hasChildren(item) && <Icon icon="pixelarticons:chevron-down" width={12} height={12} />}
157
179
  </span>
158
180
  ))}
159
181
  </div>
160
182
 
161
183
  <nav ref={containerRef} className="cp-nav-menu" aria-label="Primary">
162
- {visible.map((item) => (
163
- <RouteLink
164
- key={item.link}
165
- href={item.link}
166
- className={clsx('cp-nav-menu__item', {
167
- 'cp-nav-menu__item--active': isActive(pathname, item.link),
168
- })}
169
- >
170
- {item.text}
171
- </RouteLink>
184
+ {visible.map((item, index) => (
185
+ <NavMenuEntry key={`${itemKey(item)}::${index}`} item={item} pathname={pathname} />
172
186
  ))}
173
187
  {hasOverflow && (
174
188
  <div ref={overflowRef} className="cp-nav-menu__overflow">
@@ -184,19 +198,13 @@ export function CiderpressNavMenu(props: CiderpressNavMenuProps): React.ReactEle
184
198
  </button>
185
199
  {overflowOpen && (
186
200
  <ul className="cp-nav-menu__overflow-popover" role="menu">
187
- {overflow.map((item) => (
188
- <li key={item.link} role="none">
189
- <RouteLink
190
- href={item.link}
191
- role="menuitem"
192
- className={clsx('cp-nav-menu__overflow-item', {
193
- 'cp-nav-menu__overflow-item--active': isActive(pathname, item.link),
194
- })}
195
- onClick={() => setOverflowOpen(false)}
196
- >
197
- {item.text}
198
- </RouteLink>
199
- </li>
201
+ {overflow.map((item, index) => (
202
+ <OverflowEntry
203
+ key={`${itemKey(item)}::${index}`}
204
+ item={item}
205
+ pathname={pathname}
206
+ onNavigate={() => setOverflowOpen(false)}
207
+ />
200
208
  ))}
201
209
  </ul>
202
210
  )}
@@ -209,6 +217,286 @@ export function CiderpressNavMenu(props: CiderpressNavMenuProps): React.ReactEle
209
217
 
210
218
  export { CiderpressNavMenu as default }
211
219
 
220
+ /**
221
+ * Render a single inline nav entry — a plain link when the item is a
222
+ * leaf, or a hover/click dropdown when it carries child `items`.
223
+ *
224
+ * @private
225
+ * @param props - The nav item and the current pathname.
226
+ * @returns The entry element.
227
+ */
228
+ function NavMenuEntry(props: {
229
+ readonly item: CiderpressNavMenuItem
230
+ readonly pathname: string
231
+ }): React.ReactElement {
232
+ const { item, pathname } = props
233
+ return match(hasChildren(item))
234
+ .with(true, () => <NavMenuDropdown item={item} pathname={pathname} />)
235
+ .otherwise(() => (
236
+ <RouteLink
237
+ href={item.link ?? '#'}
238
+ className={clsx('cp-nav-menu__item', {
239
+ 'cp-nav-menu__item--active': isActiveLink(pathname, item.link),
240
+ })}
241
+ >
242
+ {item.text}
243
+ </RouteLink>
244
+ ))
245
+ }
246
+
247
+ /**
248
+ * A topbar dropdown: a toggle button plus a popover of child links.
249
+ * Opens on hover and on click, closes on outside click, on child
250
+ * navigation, or on `Escape`. The toggle is marked active when the
251
+ * current route matches any child link.
252
+ *
253
+ * @private
254
+ * @param props - The dropdown item and the current pathname.
255
+ * @returns The dropdown element.
256
+ */
257
+ function NavMenuDropdown(props: {
258
+ readonly item: CiderpressNavMenuItem
259
+ readonly pathname: string
260
+ }): React.ReactElement {
261
+ const { item, pathname } = props
262
+ // Two independent inputs: `hovering` (pointer preview) and `pinned`
263
+ // (an explicit click/tap/Enter latch). The menu is open when either is
264
+ // set. This keeps hover-to-preview and click-to-toggle from fighting —
265
+ // clicking an already-hover-open menu pins it instead of closing it.
266
+ const [hovering, setHovering] = useState(false)
267
+ const [pinned, setPinned] = useState(false)
268
+ const open = hovering || pinned
269
+ const ref = useRef<HTMLDivElement>(null)
270
+ const toggleRef = useRef<HTMLButtonElement>(null)
271
+ const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
272
+ const children = item.items ?? []
273
+
274
+ // Cancel any pending hover-close (mouse re-entered, or an explicit action).
275
+ function cancelClose(): void {
276
+ if (closeTimer.current !== null) {
277
+ clearTimeout(closeTimer.current)
278
+ closeTimer.current = null
279
+ }
280
+ }
281
+
282
+ // Fully close: drop both hover and pin. Used by Escape, outside-click,
283
+ // and child navigation.
284
+ function close(): void {
285
+ cancelClose()
286
+ setHovering(false)
287
+ setPinned(false)
288
+ }
289
+
290
+ // Drop the hover preview after a short grace period so brief excursions
291
+ // off the toggle (crossing into the popover, a jittery pointer) don't
292
+ // snap it shut. A pinned menu stays open regardless.
293
+ function scheduleClose(): void {
294
+ cancelClose()
295
+ closeTimer.current = setTimeout(() => setHovering(false), CLOSE_DELAY_MS)
296
+ }
297
+
298
+ useEffect(() => () => cancelClose(), [])
299
+
300
+ useEffect(() => {
301
+ if (!open) {
302
+ return
303
+ }
304
+ function onDocClick(event: MouseEvent): void {
305
+ const target = event.target as Node | null
306
+ if (target !== null && ref.current !== null && !ref.current.contains(target)) {
307
+ close()
308
+ }
309
+ }
310
+ function onKeyDown(event: KeyboardEvent): void {
311
+ if (event.key === 'Escape') {
312
+ close()
313
+ // Return focus to the toggle so a keyboard user isn't stranded
314
+ // on a link that just unmounted.
315
+ if (toggleRef.current !== null) {
316
+ toggleRef.current.focus()
317
+ }
318
+ }
319
+ }
320
+ document.addEventListener('mousedown', onDocClick)
321
+ document.addEventListener('keydown', onKeyDown)
322
+ return () => {
323
+ document.removeEventListener('mousedown', onDocClick)
324
+ document.removeEventListener('keydown', onKeyDown)
325
+ }
326
+ }, [open])
327
+
328
+ const active = children.some((child) => isActiveLink(pathname, child.link))
329
+
330
+ function handleMouseEnter(): void {
331
+ cancelClose()
332
+ setHovering(true)
333
+ }
334
+
335
+ // Close when focus leaves the dropdown entirely (Tab past the last
336
+ // link). Keep it open while focus moves between the toggle and items.
337
+ function handleBlur(event: React.FocusEvent<HTMLDivElement>): void {
338
+ const next = event.relatedTarget
339
+ if (ref.current !== null && next instanceof Node && ref.current.contains(next)) {
340
+ return
341
+ }
342
+ close()
343
+ }
344
+
345
+ // Click/tap/Enter is an explicit latch: pin it open, or unpin (and drop
346
+ // any lingering hover) to close. Works identically for mouse, touch,
347
+ // and keyboard.
348
+ function handleToggleClick(): void {
349
+ cancelClose()
350
+ if (pinned) {
351
+ setPinned(false)
352
+ setHovering(false)
353
+ return
354
+ }
355
+ setPinned(true)
356
+ }
357
+
358
+ return (
359
+ <div
360
+ ref={ref}
361
+ className="cp-nav-menu__dropdown"
362
+ onMouseEnter={handleMouseEnter}
363
+ onMouseLeave={scheduleClose}
364
+ onBlur={handleBlur}
365
+ >
366
+ <button
367
+ ref={toggleRef}
368
+ type="button"
369
+ className={clsx('cp-nav-menu__item', 'cp-nav-menu__dropdown-toggle', {
370
+ 'cp-nav-menu__item--active': active,
371
+ })}
372
+ onClick={handleToggleClick}
373
+ aria-haspopup="menu"
374
+ aria-expanded={open}
375
+ >
376
+ <span>{item.text}</span>
377
+ <Icon icon="pixelarticons:chevron-down" width={12} height={12} />
378
+ </button>
379
+ {open && (
380
+ <ul className="cp-nav-menu__dropdown-popover" role="menu" aria-label={item.text}>
381
+ {children.map((child, index) => (
382
+ <li key={`${itemKey(child)}::${index}`} role="none">
383
+ <RouteLink
384
+ href={child.link ?? '#'}
385
+ role="menuitem"
386
+ className={clsx('cp-nav-menu__overflow-item', {
387
+ 'cp-nav-menu__overflow-item--active': isActiveLink(pathname, child.link),
388
+ })}
389
+ onClick={close}
390
+ >
391
+ {child.text}
392
+ </RouteLink>
393
+ </li>
394
+ ))}
395
+ </ul>
396
+ )}
397
+ </div>
398
+ )
399
+ }
400
+
401
+ /**
402
+ * Render an entry inside the "More" overflow popover. Leaf items become
403
+ * a single menu link; dropdown parents become a labelled group with
404
+ * their children listed beneath.
405
+ *
406
+ * @private
407
+ * @param props - The item, current pathname, and a navigate callback.
408
+ * @returns The overflow entry list element.
409
+ */
410
+ function OverflowEntry(props: {
411
+ readonly item: CiderpressNavMenuItem
412
+ readonly pathname: string
413
+ readonly onNavigate: () => void
414
+ }): React.ReactElement {
415
+ const { item, pathname, onNavigate } = props
416
+ const children = item.items ?? []
417
+ return match(children.length > 0)
418
+ .with(true, () => (
419
+ <li className="cp-nav-menu__overflow-group" role="none">
420
+ <span className="cp-nav-menu__overflow-group-label" aria-hidden="true">
421
+ {item.text}
422
+ </span>
423
+ <ul className="cp-nav-menu__overflow-sublist" role="menu" aria-label={item.text}>
424
+ {children.map((child, index) => (
425
+ <li key={`${itemKey(child)}::${index}`} role="none">
426
+ <RouteLink
427
+ href={child.link ?? '#'}
428
+ role="menuitem"
429
+ className={clsx('cp-nav-menu__overflow-item', {
430
+ 'cp-nav-menu__overflow-item--active': isActiveLink(pathname, child.link),
431
+ })}
432
+ onClick={onNavigate}
433
+ >
434
+ {child.text}
435
+ </RouteLink>
436
+ </li>
437
+ ))}
438
+ </ul>
439
+ </li>
440
+ ))
441
+ .otherwise(() => (
442
+ <li role="none">
443
+ <RouteLink
444
+ href={item.link ?? '#'}
445
+ role="menuitem"
446
+ className={clsx('cp-nav-menu__overflow-item', {
447
+ 'cp-nav-menu__overflow-item--active': isActiveLink(pathname, item.link),
448
+ })}
449
+ onClick={onNavigate}
450
+ >
451
+ {item.text}
452
+ </RouteLink>
453
+ </li>
454
+ ))
455
+ }
456
+
457
+ /**
458
+ * Whether a nav item carries a non-empty `items` array (making it a
459
+ * dropdown parent rather than a leaf).
460
+ *
461
+ * @private
462
+ * @param item - Nav item to test.
463
+ * @returns True when the item has at least one child.
464
+ */
465
+ function hasChildren(item: CiderpressNavMenuItem): boolean {
466
+ return item.items !== undefined && item.items.length > 0
467
+ }
468
+
469
+ /**
470
+ * Stable React key for a nav item — its link when present, otherwise
471
+ * its label (dropdown parents may have no link of their own).
472
+ *
473
+ * @private
474
+ * @param item - Nav item to key.
475
+ * @returns Key string.
476
+ */
477
+ function itemKey(item: CiderpressNavMenuItem): string {
478
+ if (item.link !== undefined && item.link !== '') {
479
+ return item.link
480
+ }
481
+ return item.text
482
+ }
483
+
484
+ /**
485
+ * Active-route test that tolerates an absent link (dropdown parents),
486
+ * delegating to {@link isActive} only when a link is present.
487
+ *
488
+ * @private
489
+ * @param pathname - Current route pathname.
490
+ * @param link - Item link, possibly undefined.
491
+ * @returns True when the link is present and matches the route.
492
+ */
493
+ function isActiveLink(pathname: string, link: string | undefined): boolean {
494
+ if (link === undefined) {
495
+ return false
496
+ }
497
+ return isActive(pathname, link)
498
+ }
499
+
212
500
  /**
213
501
  * Walk the per-item widths left-to-right, accumulating until we'd
214
502
  * exceed the available width. When the accumulator already exceeds the
@@ -275,34 +563,105 @@ function gapAt(index: number, gap: number): number {
275
563
  }
276
564
 
277
565
  /**
278
- * Read every anchor under Rspress's hidden `.rp-nav-menu` and project
279
- * it into a `{ text, link }` item. Anchors with empty text or `href`
280
- * are dropped so we never surface a placeholder entry.
566
+ * Reconstruct the primary nav from Rspress's hidden `.rp-nav-menu`,
567
+ * preserving dropdowns. Each top-level `.rp-nav-menu__item` is either a
568
+ * leaf (its container is an anchor) or a dropdown parent (it wraps a
569
+ * `.rp-hover-group` of child links). Items with empty text, or dropdown
570
+ * parents with no usable children, are dropped.
281
571
  *
282
572
  * @private
283
573
  * @returns Nav items currently in the DOM (empty array when not mounted).
284
574
  */
285
575
  function scrapeNavItems(): readonly CiderpressNavMenuItem[] {
286
- const anchors = document.querySelectorAll<HTMLAnchorElement>('.rp-nav-menu .rp-nav-menu__item a')
576
+ return navMenuRoots()
577
+ .map(scrapeNavItem)
578
+ .filter((item): item is CiderpressNavMenuItem => item !== null)
579
+ }
580
+
581
+ /**
582
+ * Collect the top-level `.rp-nav-menu__item` `<li>`s to scrape. Rspress
583
+ * renders separate left and right nav `<ul>`s; the ciderpress topbar is
584
+ * right-aligned, so we read the right menu and only fall back to the
585
+ * unscoped selector when it isn't present.
586
+ *
587
+ * @private
588
+ * @returns Top-level nav item elements.
589
+ */
590
+ function navMenuRoots(): readonly HTMLElement[] {
591
+ const right = document.querySelectorAll<HTMLElement>('.rp-nav-menu--right > .rp-nav-menu__item')
592
+ if (right.length > 0) {
593
+ return [...right]
594
+ }
595
+ return [...document.querySelectorAll<HTMLElement>('.rp-nav-menu > .rp-nav-menu__item')]
596
+ }
597
+
598
+ /**
599
+ * Project a single top-level `.rp-nav-menu__item` element into a nav
600
+ * item, recursing one level into its `.rp-hover-group` dropdown when
601
+ * present.
602
+ *
603
+ * @private
604
+ * @param root - Top-level nav `<li>` element.
605
+ * @returns Parsed nav item, or `null` when unusable.
606
+ */
607
+ function scrapeNavItem(root: HTMLElement): CiderpressNavMenuItem | null {
608
+ const container = root.querySelector(':scope > .rp-nav-menu__item__container')
609
+ if (container === null) {
610
+ return null
611
+ }
612
+ const text = readElementText(container)
613
+ if (text === '') {
614
+ return null
615
+ }
616
+ const group = root.querySelector(':scope > .rp-hover-group')
617
+ if (group !== null) {
618
+ const items = scrapeGroupItems(group)
619
+ if (items.length === 0) {
620
+ return null
621
+ }
622
+ return { text, items }
623
+ }
624
+ const href = container.getAttribute('href')
625
+ if (href === null || href === '') {
626
+ return null
627
+ }
628
+ // Un-base the scraped href so `<Link>` re-applies the site `base` once
629
+ // rather than doubling the mount prefix on subpath deploys.
630
+ return { text, link: removeBase(href) }
631
+ }
632
+
633
+ /**
634
+ * Read the child links out of a Rspress `.rp-hover-group` dropdown.
635
+ *
636
+ * @private
637
+ * @param group - The `.rp-hover-group` element.
638
+ * @returns Child nav items with text + link (empties dropped).
639
+ */
640
+ function scrapeGroupItems(group: Element): readonly CiderpressNavMenuItem[] {
641
+ const anchors = group.querySelectorAll<HTMLAnchorElement>('.rp-hover-group__item__link')
287
642
  return [...anchors]
288
643
  .map((anchor) => ({
289
- text: readAnchorText(anchor),
290
- link: anchor.getAttribute('href') ?? '',
644
+ text: readElementText(anchor),
645
+ // Rspress's rendered `.rp-nav-menu` hrefs already carry the site `base`.
646
+ // Strip it here so `RouteLink` (→ Rspress `<Link>`) can re-apply it once
647
+ // rather than doubling the mount prefix on a subpath deploy (the
648
+ // `/examples/<slug>/examples/<slug>/…` 404 on mounted example sites).
649
+ link: removeBase(anchor.getAttribute('href') ?? ''),
291
650
  }))
292
651
  .filter((item) => item.text !== '' && item.link !== '')
293
652
  }
294
653
 
295
654
  /**
296
- * Pull the trimmed text content from an anchor. Returns an empty
655
+ * Pull the trimmed text content from an element. Returns an empty
297
656
  * string when `textContent` is missing — callers treat empty as "skip
298
- * this anchor".
657
+ * this element".
299
658
  *
300
659
  * @private
301
- * @param anchor - Anchor element to read.
660
+ * @param element - Element to read.
302
661
  * @returns Trimmed inner text, or empty string when absent.
303
662
  */
304
- function readAnchorText(anchor: HTMLAnchorElement): string {
305
- const text = anchor.textContent
663
+ function readElementText(element: Element): string {
664
+ const text = element.textContent
306
665
  if (text === null) {
307
666
  return ''
308
667
  }
@@ -6,13 +6,15 @@ import { Icon } from '../shared/icon.tsx'
6
6
  import './ciderpress-nav-social-links.css'
7
7
 
8
8
  /**
9
- * Single social-link entry — matches the shape of `socialLinks` in
10
- * `ciderpress.config.ts`.
9
+ * Single social-link entry — matches the serialised `socials` shape
10
+ * written into `themeConfig.socialLinks` by `packages/ui/src/config.ts`.
11
+ *
12
+ * The Rspress `mode` / `content` discriminator was killed in the
13
+ * `rc.4` API overhaul; every link is now a plain anchor.
11
14
  */
12
15
  export interface CiderpressSocialLink {
13
16
  readonly icon: string
14
- readonly mode: string
15
- readonly content: string
17
+ readonly url: string
16
18
  readonly label?: string
17
19
  }
18
20
 
@@ -21,25 +23,32 @@ export interface CiderpressNavSocialLinksProps {
21
23
  }
22
24
 
23
25
  /**
24
- * Maps Rspress's social-link `icon` slugs to pixelarticons icon ids.
25
- * Fallback for unmapped slugs is the generic `link` glyph.
26
+ * Maps Rspress's social-link `icon` slugs to `pixel` icon ids — one pixel-art
27
+ * glyph per {@link SocialLinkIcon} the config enum accepts, in the same
28
+ * aesthetic as the rest of the theme. Unmapped slugs fall back to the generic
29
+ * `pixel:link` chain glyph.
26
30
  */
27
31
  const ICON_MAP: Readonly<Record<string, string>> = Object.freeze({
28
- github: 'pixelarticons:github',
29
- npm: 'pixelarticons:package',
30
- twitter: 'pixelarticons:twitter',
31
- x: 'pixelarticons:twitter',
32
- discord: 'pixelarticons:chat',
33
- youtube: 'pixelarticons:play',
34
- bluesky: 'pixelarticons:bluesky',
35
- mastodon: 'pixelarticons:user',
32
+ github: 'pixel:github',
33
+ npm: 'pixel:npm',
34
+ twitter: 'pixel:twitter',
35
+ x: 'pixel:twitter',
36
+ discord: 'pixel:discord',
37
+ youtube: 'pixel:youtube',
38
+ bluesky: 'pixel:bluesky',
39
+ mastodon: 'pixel:mastodon',
40
+ slack: 'pixel:slack',
41
+ linkedin: 'pixel:linkedin',
42
+ gitlab: 'pixel:gitlab',
43
+ instagram: 'pixel:instagram',
44
+ facebook: 'pixel:facebook-round',
36
45
  })
37
46
 
38
47
  /**
39
48
  * Renders the configured social links as a cluster of icon buttons in
40
49
  * the topbar. Returns `null` when no links are configured.
41
50
  *
42
- * @param props - List of social links from `site.socialLinks`
51
+ * @param props - List of social links from `site.socials`
43
52
  * @returns Cluster of icon links, or `null`
44
53
  */
45
54
  export function CiderpressNavSocialLinks(
@@ -50,7 +59,7 @@ export function CiderpressNavSocialLinks(
50
59
  }
51
60
 
52
61
  const safeLinks = props.links
53
- .map((link) => ({ link, href: safeUrl(link.content) }))
62
+ .map((link) => ({ link, href: safeUrl(link.url) }))
54
63
  .filter((entry): entry is { readonly link: CiderpressSocialLink; readonly href: string } => {
55
64
  if (entry.href === null) {
56
65
  return false
@@ -66,18 +75,14 @@ export function CiderpressNavSocialLinks(
66
75
  <div className="cp-nav-social">
67
76
  {safeLinks.map(({ link, href }) => (
68
77
  <a
69
- key={link.content}
78
+ key={link.url}
70
79
  href={href}
71
80
  target="_blank"
72
81
  rel="noopener noreferrer"
73
82
  className="cp-nav-social__item"
74
83
  aria-label={link.label ?? link.icon}
75
84
  >
76
- <Icon
77
- icon={ICON_MAP[link.icon.toLowerCase()] ?? 'pixelarticons:link'}
78
- width={20}
79
- height={20}
80
- />
85
+ <Icon icon={ICON_MAP[link.icon.toLowerCase()] ?? 'pixel:link'} width={20} height={20} />
81
86
  </a>
82
87
  ))}
83
88
  </div>
@@ -1,7 +1,7 @@
1
- import type { SiteConfig } from '@ciderpress/config'
2
1
  import { match, P } from 'massaman/match'
3
2
  import type React from 'react'
4
3
 
4
+ import type { CiderpressSiteBlock } from '../../hooks/use-ciderpress'
5
5
  import { useCiderpress } from '../../hooks/use-ciderpress'
6
6
 
7
7
  import './floating-branch-indicator.css'
@@ -15,7 +15,7 @@ declare const __CIDERPRESS_GIT_BRANCH__: string | undefined
15
15
  * the branch name. Click anywhere on the pill opens the branch on GitHub.
16
16
  *
17
17
  * Hidden when on default branches (`main` / `master`), when the
18
- * build-time branch global is undefined, or when `site.edit.repo` is
18
+ * build-time branch global is undefined, or when `editLink.repo` is
19
19
  * not configured.
20
20
  *
21
21
  * @returns React element or null when hidden
@@ -69,7 +69,7 @@ function resolveBranch(): string {
69
69
  }
70
70
 
71
71
  /**
72
- * Build a GitHub `/tree/<branch>` URL from the configured `site.edit.repo`.
72
+ * Build a GitHub `/tree/<branch>` URL from the configured `editLink.repo`.
73
73
  *
74
74
  * Accepts either a full URL (used as-is) or an `org/repo` slug
75
75
  * (prefixed with `https://github.com/`). Returns `null` when `repo` is
@@ -91,14 +91,14 @@ function buildBranchHref(params: {
91
91
  }
92
92
 
93
93
  /**
94
- * Pull the configured edit-repo slug off the site config, expressed
95
- * with explicit null checks rather than optional chaining.
94
+ * Pull the configured edit-repo slug off the serialised site block,
95
+ * expressed with explicit null checks rather than optional chaining.
96
96
  *
97
97
  * @private
98
- * @param site - The ciderpress site config (may be undefined).
98
+ * @param site - The runtime site block (may be undefined).
99
99
  * @returns The configured `edit.repo` value or `undefined`.
100
100
  */
101
- function resolveRepo(site: SiteConfig | undefined): string | undefined {
101
+ function resolveRepo(site: CiderpressSiteBlock | undefined): string | undefined {
102
102
  if (site === undefined) {
103
103
  return undefined
104
104
  }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Small brand chip rendered immediately before `<HeaderLogo />` inside
3
+ * `cp-header-logo`. Sized to match `--cp-header-icon-size` (default
4
+ * 28px — same as the logo height token) so the two slots line up
5
+ * vertically by default. Themes can override either token to break
6
+ * the lockstep.
7
+ */
8
+
9
+ .cp-header-icon {
10
+ display: inline-flex;
11
+ align-items: center;
12
+ flex-shrink: 0;
13
+ height: var(--cp-header-icon-size, var(--cp-header-logo-height, 28px));
14
+ width: var(--cp-header-icon-size, var(--cp-header-logo-height, 28px));
15
+ margin-right: var(--cp-header-icon-gap, 8px);
16
+ color: var(--cp-header-icon-color, currentColor);
17
+ }
18
+
19
+ .cp-header-icon > svg,
20
+ .cp-header-icon__img {
21
+ height: 100%;
22
+ width: 100%;
23
+ display: block;
24
+ }