@ciderpress/ui 1.0.0-rc.6 → 1.0.0-rc.7

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 (41) hide show
  1. package/dist/node.mjs +20 -2
  2. package/dist/plugins/mermaid/MermaidRenderer.tsx +24 -6
  3. package/dist/theme/components/nav/ciderpress-docs-bar.css +13 -1
  4. package/dist/theme/components/nav/ciderpress-docs-bar.tsx +33 -2
  5. package/dist/theme/components/nav/ciderpress-nav-hamburger.css +50 -0
  6. package/dist/theme/components/nav/ciderpress-nav-hamburger.tsx +95 -9
  7. package/dist/theme/components/nav/ciderpress-nav-menu.css +88 -0
  8. package/dist/theme/components/nav/ciderpress-nav-menu.tsx +397 -38
  9. package/dist/theme/components/nav/ciderpress-nav-social-links.tsx +18 -15
  10. package/dist/theme/components/nav/layout.tsx +72 -9
  11. package/dist/theme/components/shared/icon.tsx +2 -1
  12. package/dist/theme/components/shared/section-card.tsx +28 -0
  13. package/dist/theme/components/sidebar/sidebar-badge.css +50 -0
  14. package/dist/theme/components/sidebar/sidebar-badge.tsx +153 -0
  15. package/dist/theme/components/sidebar/sidebar-scope.tsx +24 -1
  16. package/dist/theme/hooks/use-ciderpress.ts +6 -1
  17. package/dist/theme/hooks/use-nav-items.ts +82 -11
  18. package/dist/theme/index.tsx +3 -0
  19. package/dist/theme/styles/overrides/rail.css +27 -10
  20. package/dist/theme/styles/overrides/section-card.css +9 -0
  21. package/dist/theme/styles/overrides/sidebar.css +22 -0
  22. package/package.json +13 -12
  23. package/src/theme/components/nav/ciderpress-docs-bar.css +13 -1
  24. package/src/theme/components/nav/ciderpress-docs-bar.tsx +33 -2
  25. package/src/theme/components/nav/ciderpress-nav-hamburger.css +50 -0
  26. package/src/theme/components/nav/ciderpress-nav-hamburger.tsx +95 -9
  27. package/src/theme/components/nav/ciderpress-nav-menu.css +88 -0
  28. package/src/theme/components/nav/ciderpress-nav-menu.tsx +397 -38
  29. package/src/theme/components/nav/ciderpress-nav-social-links.tsx +18 -15
  30. package/src/theme/components/nav/layout.tsx +72 -9
  31. package/src/theme/components/shared/icon.tsx +2 -1
  32. package/src/theme/components/shared/section-card.tsx +28 -0
  33. package/src/theme/components/sidebar/sidebar-badge.css +50 -0
  34. package/src/theme/components/sidebar/sidebar-badge.tsx +153 -0
  35. package/src/theme/components/sidebar/sidebar-scope.tsx +24 -1
  36. package/src/theme/hooks/use-ciderpress.ts +6 -1
  37. package/src/theme/hooks/use-nav-items.ts +82 -11
  38. package/src/theme/index.tsx +3 -0
  39. package/src/theme/styles/overrides/rail.css +27 -10
  40. package/src/theme/styles/overrides/section-card.css +9 -0
  41. package/src/theme/styles/overrides/sidebar.css +22 -0
@@ -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
  }
@@ -23,18 +23,25 @@ export interface CiderpressNavSocialLinksProps {
23
23
  }
24
24
 
25
25
  /**
26
- * Maps Rspress's social-link `icon` slugs to pixelarticons icon ids.
27
- * 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.
28
30
  */
29
31
  const ICON_MAP: Readonly<Record<string, string>> = Object.freeze({
30
- github: 'pixelarticons:github',
31
- npm: 'pixelarticons:package',
32
- twitter: 'pixelarticons:twitter',
33
- x: 'pixelarticons:twitter',
34
- discord: 'pixelarticons:chat',
35
- youtube: 'pixelarticons:play',
36
- bluesky: 'pixelarticons:bluesky',
37
- 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',
38
45
  })
39
46
 
40
47
  /**
@@ -75,11 +82,7 @@ export function CiderpressNavSocialLinks(
75
82
  className="cp-nav-social__item"
76
83
  aria-label={link.label ?? link.icon}
77
84
  >
78
- <Icon
79
- icon={ICON_MAP[link.icon.toLowerCase()] ?? 'pixelarticons:link'}
80
- width={20}
81
- height={20}
82
- />
85
+ <Icon icon={ICON_MAP[link.icon.toLowerCase()] ?? 'pixel:link'} width={20} height={20} />
83
86
  </a>
84
87
  ))}
85
88
  </div>
@@ -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,9 +120,13 @@ 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
  )
@@ -182,13 +193,65 @@ function readNavItems(site: unknown): readonly CiderpressNavMenuItem[] {
182
193
  if (!Array.isArray(candidate)) {
183
194
  return []
184
195
  }
185
- return candidate.filter(
186
- (item): item is CiderpressNavMenuItem =>
187
- typeof item === 'object' &&
188
- item !== null &&
189
- typeof (item as { text?: unknown }).text === 'string' &&
190
- typeof (item as { link?: unknown }).link === 'string'
191
- )
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)
192
255
  }
193
256
 
194
257
  /**
@@ -7,7 +7,7 @@ import { useEffect, useState } from 'react'
7
7
  * Per-collection lazy loaders keyed by Iconify prefix.
8
8
  *
9
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
10
+ * build emits **one async chunk per collection** instead of folding all ten
11
11
  * `icons.json` files into a single eager ~30MB chunk pulled on every route.
12
12
  * Two consequences fall out of that:
13
13
  *
@@ -28,6 +28,7 @@ const COLLECTION_LOADERS: Record<string, () => Promise<{ readonly default: unkno
28
28
  logos: () => import('@iconify-json/logos/icons.json'),
29
29
  'material-icon-theme': () => import('@iconify-json/material-icon-theme/icons.json'),
30
30
  mdi: () => import('@iconify-json/mdi/icons.json'),
31
+ pixel: () => import('@iconify-json/pixel/icons.json'),
31
32
  pixelarticons: () => import('@iconify-json/pixelarticons/icons.json'),
32
33
  'simple-icons': () => import('@iconify-json/simple-icons/icons.json'),
33
34
  'skill-icons': () => import('@iconify-json/skill-icons/icons.json'),