@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.
- package/dist/node.mjs +20 -2
- package/dist/plugins/mermaid/MermaidRenderer.tsx +24 -6
- package/dist/theme/components/nav/ciderpress-docs-bar.css +13 -1
- package/dist/theme/components/nav/ciderpress-docs-bar.tsx +33 -2
- package/dist/theme/components/nav/ciderpress-nav-hamburger.css +50 -0
- package/dist/theme/components/nav/ciderpress-nav-hamburger.tsx +95 -9
- package/dist/theme/components/nav/ciderpress-nav-menu.css +88 -0
- package/dist/theme/components/nav/ciderpress-nav-menu.tsx +397 -38
- package/dist/theme/components/nav/ciderpress-nav-social-links.tsx +18 -15
- package/dist/theme/components/nav/layout.tsx +72 -9
- package/dist/theme/components/shared/icon.tsx +2 -1
- package/dist/theme/components/shared/section-card.tsx +28 -0
- package/dist/theme/components/sidebar/sidebar-badge.css +50 -0
- package/dist/theme/components/sidebar/sidebar-badge.tsx +153 -0
- package/dist/theme/components/sidebar/sidebar-scope.tsx +24 -1
- package/dist/theme/hooks/use-ciderpress.ts +6 -1
- package/dist/theme/hooks/use-nav-items.ts +82 -11
- package/dist/theme/index.tsx +3 -0
- package/dist/theme/styles/overrides/rail.css +27 -10
- package/dist/theme/styles/overrides/section-card.css +9 -0
- package/dist/theme/styles/overrides/sidebar.css +22 -0
- package/package.json +13 -12
- package/src/theme/components/nav/ciderpress-docs-bar.css +13 -1
- package/src/theme/components/nav/ciderpress-docs-bar.tsx +33 -2
- package/src/theme/components/nav/ciderpress-nav-hamburger.css +50 -0
- package/src/theme/components/nav/ciderpress-nav-hamburger.tsx +95 -9
- package/src/theme/components/nav/ciderpress-nav-menu.css +88 -0
- package/src/theme/components/nav/ciderpress-nav-menu.tsx +397 -38
- package/src/theme/components/nav/ciderpress-nav-social-links.tsx +18 -15
- package/src/theme/components/nav/layout.tsx +72 -9
- package/src/theme/components/shared/icon.tsx +2 -1
- package/src/theme/components/shared/section-card.tsx +28 -0
- package/src/theme/components/sidebar/sidebar-badge.css +50 -0
- package/src/theme/components/sidebar/sidebar-badge.tsx +153 -0
- package/src/theme/components/sidebar/sidebar-scope.tsx +24 -1
- package/src/theme/hooks/use-ciderpress.ts +6 -1
- package/src/theme/hooks/use-nav-items.ts +82 -11
- package/src/theme/index.tsx +3 -0
- package/src/theme/styles/overrides/rail.css +27 -10
- package/src/theme/styles/overrides/section-card.css +9 -0
- 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
|
|
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
|
|
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
|
-
<
|
|
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
|
-
<
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
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
|
-
|
|
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:
|
|
290
|
-
|
|
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
|
|
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
|
|
657
|
+
* this element".
|
|
299
658
|
*
|
|
300
659
|
* @private
|
|
301
|
-
* @param
|
|
660
|
+
* @param element - Element to read.
|
|
302
661
|
* @returns Trimmed inner text, or empty string when absent.
|
|
303
662
|
*/
|
|
304
|
-
function
|
|
305
|
-
const text =
|
|
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
|
|
27
|
-
*
|
|
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: '
|
|
31
|
-
npm: '
|
|
32
|
-
twitter: '
|
|
33
|
-
x: '
|
|
34
|
-
discord: '
|
|
35
|
-
youtube: '
|
|
36
|
-
bluesky: '
|
|
37
|
-
mastodon: '
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
|
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'),
|