@takazudo/zudo-doc 5.9.0 → 5.10.0

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 (61) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +1 -1
  3. package/dist/chrome/derive.d.ts +1 -1
  4. package/dist/chrome/derive.js +24 -2
  5. package/dist/config-assertions/index.d.ts +30 -0
  6. package/dist/config-assertions/index.js +41 -1
  7. package/dist/doc-content-header/index.js +6 -0
  8. package/dist/doc-metainfo-area/index.js +1 -14
  9. package/dist/doc-page-renderer/index.d.ts +3 -3
  10. package/dist/doc-page-renderer/index.js +1 -1
  11. package/dist/doc-page-shell/index.d.ts +6 -0
  12. package/dist/doc-pager/index.d.ts +6 -0
  13. package/dist/doc-pager/index.js +5 -2
  14. package/dist/docs-schema/index.d.ts +11 -0
  15. package/dist/docs-schema/index.js +8 -1
  16. package/dist/format-date/index.d.ts +12 -0
  17. package/dist/format-date/index.js +64 -0
  18. package/dist/frontmatter-preview-defaults/index.js +6 -1
  19. package/dist/header/header.js +9 -1
  20. package/dist/home-page/index.js +3 -1
  21. package/dist/html-preview-wrapper/highlight-runtime.d.ts +7 -7
  22. package/dist/html-preview-wrapper/highlight-runtime.js +1 -1
  23. package/dist/integrations/changelog/emit.js +19 -2
  24. package/dist/mdx-components/index.d.ts +4 -1
  25. package/dist/mdx-components/index.js +3 -1
  26. package/dist/nav-indexing/index.d.ts +2 -0
  27. package/dist/nav-indexing/index.js +2 -0
  28. package/dist/nav-indexing/note-tray-index.d.ts +29 -0
  29. package/dist/nav-indexing/note-tray-index.js +120 -0
  30. package/dist/note-tray-index/index.d.ts +45 -0
  31. package/dist/note-tray-index/index.js +52 -0
  32. package/dist/note-tray-model/index.d.ts +31 -0
  33. package/dist/note-tray-model/index.js +74 -0
  34. package/dist/preset.d.ts +11 -0
  35. package/dist/preset.js +5 -1
  36. package/dist/safelist.css +1 -1
  37. package/dist/sidebar/types.d.ts +6 -0
  38. package/dist/sidebar-tree/build-tree.js +8 -0
  39. package/dist/sidebar-tree/types.d.ts +12 -0
  40. package/dist/sidebar-tree-island/index.js +175 -1
  41. package/dist/sidebar-utils/index.d.ts +6 -0
  42. package/dist/site-schema/doc-route-entries.js +2 -0
  43. package/dist/site-schema/index.d.ts +1 -0
  44. package/dist/site-schema/index.js +3 -1
  45. package/dist/site-schema/nav-tree.js +17 -2
  46. package/dist/site-schema/note-tray-validate.d.ts +7 -0
  47. package/dist/site-schema/note-tray-validate.js +94 -0
  48. package/dist/site-schema/types.d.ts +11 -0
  49. package/dist/site-tree-nav-island/index.d.ts +5 -1
  50. package/dist/site-tree-nav-island/index.js +119 -12
  51. package/dist/theme-packs/drift/pack.css +6 -1
  52. package/dist/theme-packs/hearth/pack.css +5 -0
  53. package/dist/theme-packs/sakura/pack.css +6 -0
  54. package/dist/theme-packs/scandi/pack.css +7 -7
  55. package/dist/theme-packs/timberline/pack.css +5 -0
  56. package/eject/doc-pager/index.tsx +9 -0
  57. package/eject/header/header.tsx +26 -1
  58. package/eject/sidebar/types.ts +6 -0
  59. package/eject/sidebar-tree-island/index.tsx +238 -6
  60. package/eject/site-tree-nav-island/index.tsx +166 -16
  61. package/package.json +20 -8
@@ -2,7 +2,7 @@
2
2
  Scandi — zudo-doc theme pack (ADR docs/adr/theme-packs.md)
3
3
 
4
4
  Hygge minimalism: an oat/ecru room with sage and clay accents,
5
- pill-shaped nav actives, an even 8px radius discipline, and a
5
+ pill-shaped nested nav actives, an even 8px radius discipline, and a
6
6
  linen-subtle weave on the page body (two cheap repeating
7
7
  gradients at ~2% ink — text backdrops stay effectively solid).
8
8
  Light = a Scandinavian interior at midday; dark = hygge night —
@@ -180,12 +180,12 @@ html[data-theme-pack="scandi"] aside[data-zd-mobile-sidebar] a:hover:not([data-n
180
180
  background: color-mix(in srgb, var(--zd-accent) 10%, transparent);
181
181
  color: var(--zd-accent-hover);
182
182
  }
183
- /* root-level actives carry aria-current="page" but not
184
- data-nav-active — round their default ink fill to the 8px
185
- discipline so no squared default remnant survives. */
186
- html[data-theme-pack="scandi"] #desktop-sidebar a[aria-current="page"],
187
- html[data-theme-pack="scandi"] aside[data-zd-mobile-sidebar] a[aria-current="page"] {
188
- border-radius: 8px;
183
+ /* Non-pill current rows keep the structural full-width band square in
184
+ both sidebar surfaces. The data-nav-active rule below owns the flat
185
+ nested pill. */
186
+ html[data-theme-pack="scandi"] #desktop-sidebar a[aria-current="page"]:not([data-nav-active]),
187
+ html[data-theme-pack="scandi"] aside[data-zd-mobile-sidebar] a[aria-current="page"]:not([data-nav-active]) {
188
+ border-radius: 0;
189
189
  }
190
190
  /* active leaf item: THE signature — a full pill in deep sage
191
191
  (candle amber at night), oat text, flat as painted wood. Fill
@@ -243,6 +243,11 @@ html[data-theme-pack="timberline"] #desktop-sidebar a {
243
243
  background-color 0.15s ease,
244
244
  color 0.15s ease;
245
245
  }
246
+ /* Keep the structural current-row plank square; data-nav-active is the
247
+ explicit hook for Timberline's nested chamfer. */
248
+ html[data-theme-pack="timberline"] #desktop-sidebar a[aria-current="page"]:not([data-nav-active]) {
249
+ border-radius: 0;
250
+ }
246
251
  html[data-theme-pack="timberline"] #desktop-sidebar a:hover:not([data-nav-active]):not([aria-current="page"]) {
247
252
  color: var(--zd-accent);
248
253
  text-decoration: none;
@@ -12,11 +12,18 @@ import { ChevronLeft, ChevronRight } from "../icons/index.js";
12
12
  import type { ChromeContext } from "../factory-context/index.js";
13
13
  import type { Settings } from "../settings.js";
14
14
  import { assertChromeContext } from "../chrome/assert-chrome-context.js";
15
+ import { formatDate } from "../format-date/index.js";
15
16
 
16
17
  // NavNode is a superset; we only need the fields the pager uses.
17
18
  interface PagerNode {
18
19
  href?: string;
19
20
  label: string;
21
+ shape?: "note-tray";
22
+ noteTrayDated?: boolean;
23
+ noteTraySidebar?: "index" | "year" | "month";
24
+ date?: string;
25
+ updated?: string;
26
+ rank?: number;
20
27
  }
21
28
 
22
29
  export interface DocPagerProps {
@@ -63,6 +70,7 @@ export function createDocPager<S extends Settings = Settings>(
63
70
  <p class="text-small font-semibold underline group-hover:text-accent">
64
71
  {prev.label}
65
72
  </p>
73
+ {prev.date && <p class="text-caption text-muted">{formatDate(prev.date, locale)}</p>}
66
74
  </a>
67
75
  ) : (
68
76
  <div />
@@ -79,6 +87,7 @@ export function createDocPager<S extends Settings = Settings>(
79
87
  <p class="text-small font-semibold underline group-hover:text-accent">
80
88
  {next.label}
81
89
  </p>
90
+ {next.date && <p class="text-caption text-muted">{formatDate(next.date, locale)}</p>}
82
91
  </a>
83
92
  ) : (
84
93
  <div />
@@ -282,7 +282,14 @@ export function Header(props: HeaderProps): JSX.Element {
282
282
 
283
283
  const isNonDefaultLocale = lang != null && lang !== i18n.defaultLocale;
284
284
  const pathWithoutBase = urlHelpers.stripBase(currentPath);
285
- const matchPath = pathForMatch(pathWithoutBase, lang, i18n.defaultLocale);
285
+ // `navHref` adds the active version prefix to rendered links, but the
286
+ // configured header paths are intentionally unversioned. Remove the
287
+ // version segment before locale normalization so SSR active state agrees
288
+ // with those configured paths on archived pages too. Keep this local to
289
+ // the renderer: `nav-active.ts` is embedded verbatim in the frozen
290
+ // overflow script and must remain unchanged.
291
+ const pathWithoutVersion = stripCurrentVersionPrefix(pathWithoutBase, currentVersion);
292
+ const matchPath = pathForMatch(pathWithoutVersion, lang, i18n.defaultLocale);
286
293
 
287
294
  const activeNavPath = computeActiveNavPath(headerNav, matchPath);
288
295
  const rightItemDispatch = createRightItemDispatch(headerRightComponents);
@@ -542,6 +549,24 @@ function renderNavItem(
542
549
  );
543
550
  }
544
551
 
552
+ /**
553
+ * Remove the active `/v/{version}` route prefix from a pathname.
554
+ *
555
+ * Version slugs are a path segment, so `/v/1.0` must not strip a path such as
556
+ * `/v/1.01/...`. The equality branch also keeps the route root normalized to
557
+ * `/` instead of returning an empty string. This helper deliberately lives in
558
+ * `header.tsx`; `nav-active.ts` is copied into the CSP-hash-pinned overflow
559
+ * script and is not part of the SSR-only version normalization seam.
560
+ */
561
+ function stripCurrentVersionPrefix(path: string, currentVersion?: string): string {
562
+ if (currentVersion == null || currentVersion === "") return path;
563
+
564
+ const prefix = `/v/${currentVersion}`;
565
+ if (path === prefix) return "/";
566
+ if (path.startsWith(`${prefix}/`)) return path.slice(prefix.length);
567
+ return path;
568
+ }
569
+
545
570
  type RightItemContext = Omit<HeaderRightComponentProps, "item" | "index">;
546
571
 
547
572
  /**
@@ -35,6 +35,12 @@ export interface SidebarNavNode {
35
35
  children: SidebarNavNode[];
36
36
  sortOrder?: "asc" | "desc";
37
37
  collapsed?: boolean;
38
+ shape?: "note-tray";
39
+ noteTrayDated?: boolean;
40
+ noteTraySidebar?: "index" | "year" | "month";
41
+ date?: string;
42
+ updated?: string;
43
+ rank?: number;
38
44
  }
39
45
 
40
46
  /**
@@ -20,6 +20,14 @@ import { filterTree } from "../sidebar-filter/index.js";
20
20
  import { findActiveSlug, normalizePath } from "../sidebar-active-slug/index.js";
21
21
  import { CURRENT_PATH_DATASET_KEY, readCurrentPath } from "../current-path/index.js";
22
22
  import { ensureSidebarScrollPreserve } from "./sidebar-scroll-preserve.js";
23
+ import {
24
+ formatYearMonthLabel,
25
+ getNoteTrayItems,
26
+ groupItems,
27
+ rankWidth,
28
+ type NoteTrayGroup,
29
+ } from "../note-tray-model/index.js";
30
+ import { formatMonthDay } from "../format-date/index.js";
23
31
 
24
32
  // The persisted aside can transiently tear down and re-mount its SidebarTree
25
33
  // effect during a body swap. Keep navigation snapshot ownership at browser
@@ -235,6 +243,12 @@ export function SidebarTree({ nodes, currentSlug, currentPath, rootMenuItems, ba
235
243
  [localeLinks, themeDefaultMode],
236
244
  );
237
245
 
246
+ const noteTrayRoot = nodes.length === 1 && nodes[0]?.shape === "note-tray" ? nodes[0] : undefined;
247
+ const filteredNoteTrayRoot = noteTrayRoot
248
+ ? filteredNodes.find((node) => node.slug === noteTrayRoot.slug)
249
+ : undefined;
250
+ const locale = localeLinks?.find((link) => link.active)?.code ?? "en";
251
+
238
252
  // Root menu view: show headerNav items as a simple list (Docusaurus-style)
239
253
  if (showingRootMenu && rootMenuItems) {
240
254
  return (
@@ -293,18 +307,236 @@ export function SidebarTree({ nodes, currentSlug, currentPath, rootMenuItems, ba
293
307
  />
294
308
  </div>
295
309
  </div>
296
- <NodeList
297
- nodes={filteredNodes}
298
- currentSlug={activeSlug}
299
- depth={0}
300
- forceOpen={!!query}
301
- />
310
+ {noteTrayRoot ? (
311
+ filteredNoteTrayRoot && (
312
+ <TrayList
313
+ tray={filteredNoteTrayRoot}
314
+ itemCount={getNoteTrayItems(noteTrayRoot).length}
315
+ currentSlug={activeSlug}
316
+ forceOpen={!!query}
317
+ locale={locale}
318
+ />
319
+ )
320
+ ) : (
321
+ <NodeList
322
+ nodes={filteredNodes}
323
+ currentSlug={activeSlug}
324
+ depth={0}
325
+ forceOpen={!!query}
326
+ />
327
+ )}
302
328
  {footer}
303
329
  </nav>
304
330
  );
305
331
  }
306
332
  SidebarTree.displayName = "SidebarTree";
307
333
 
334
+ function TrayList({
335
+ tray,
336
+ itemCount,
337
+ currentSlug,
338
+ forceOpen,
339
+ locale,
340
+ }: {
341
+ tray: SidebarNavNode;
342
+ itemCount: number;
343
+ currentSlug?: string;
344
+ forceOpen: boolean;
345
+ locale: string;
346
+ }) {
347
+ const items = getNoteTrayItems(tray);
348
+ const sidebarStyle = tray.noteTraySidebar ?? "index";
349
+ const width = rankWidth(itemCount);
350
+
351
+ return (
352
+ <>
353
+ <LeafNode node={tray} currentSlug={currentSlug} depth={0} isLast={items.length === 0} />
354
+ {sidebarStyle === "index" ? (
355
+ items.map((item, index) => (
356
+ <TrayItem
357
+ key={item.slug}
358
+ item={item}
359
+ currentSlug={currentSlug}
360
+ rankDigits={width}
361
+ isLast={index === items.length - 1}
362
+ />
363
+ ))
364
+ ) : (
365
+ groupItems(items, sidebarStyle, tray.sortOrder ?? "asc").map((group, index, groups) => (
366
+ <TrayGroupNode
367
+ key={group.key}
368
+ traySlug={tray.slug}
369
+ group={group}
370
+ grouping={sidebarStyle}
371
+ locale={locale}
372
+ currentSlug={currentSlug}
373
+ forceOpen={forceOpen}
374
+ isLast={index === groups.length - 1}
375
+ />
376
+ ))
377
+ )}
378
+ </>
379
+ );
380
+ }
381
+
382
+ function TrayItem({
383
+ item,
384
+ currentSlug,
385
+ rankDigits,
386
+ isLast,
387
+ showDate = false,
388
+ depth = 1,
389
+ }: {
390
+ item: SidebarNavNode;
391
+ currentSlug?: string;
392
+ rankDigits?: number;
393
+ isLast: boolean;
394
+ showDate?: boolean;
395
+ depth?: number;
396
+ }) {
397
+ if (!item.href) return null;
398
+ const isActive = item.slug === currentSlug;
399
+ const labelHtml = smartBreakToHtml(item.label);
400
+ const shortDate = item.date ? formatMonthDay(item.date) : undefined;
401
+
402
+ return (
403
+ <div className={isLast ? "pb-vsp-md" : ""}>
404
+ <div className="relative">
405
+ <ConnectorLines depth={depth} isLast={isLast} topPad="var(--spacing-vsp-2xs)" />
406
+ <a
407
+ href={item.href}
408
+ aria-current={isActive ? "page" : undefined}
409
+ data-nav-active={isActive ? "" : undefined}
410
+ className={`flex items-start gap-hsp-xs py-vsp-2xs pr-[4px] text-small break-words ${
411
+ isActive
412
+ ? "bg-fg font-medium text-bg"
413
+ : "text-muted hover:text-accent hover:underline focus:underline focus:text-accent"
414
+ }`}
415
+ style={{ paddingLeft: padLeft(depth, false) }}
416
+ >
417
+ {rankDigits !== undefined && (
418
+ <span className={`shrink-0 tabular-nums${isActive ? "" : " text-muted"}`}>
419
+ {item.rank === undefined ? "" : String(item.rank).padStart(rankDigits, "0")}
420
+ </span>
421
+ )}
422
+ <span className="min-w-0 flex-1" dangerouslySetInnerHTML={{ __html: labelHtml }} />
423
+ {showDate && shortDate && (
424
+ <span className={`shrink-0 tabular-nums${isActive ? "" : " text-muted"}`}>
425
+ {shortDate}
426
+ </span>
427
+ )}
428
+ </a>
429
+ </div>
430
+ </div>
431
+ );
432
+ }
433
+
434
+ function noteTrayGroupStorageKey(traySlug: string, groupKey: string): string {
435
+ return `${traySlug}#${groupKey}`;
436
+ }
437
+
438
+ function TrayGroupNode({
439
+ traySlug,
440
+ group,
441
+ grouping,
442
+ locale,
443
+ currentSlug,
444
+ forceOpen,
445
+ isLast,
446
+ }: {
447
+ traySlug: string;
448
+ group: NoteTrayGroup<SidebarNavNode>;
449
+ grouping: "year" | "month";
450
+ locale: string;
451
+ currentSlug?: string;
452
+ forceOpen: boolean;
453
+ isLast: boolean;
454
+ }) {
455
+ const containsCurrent = group.items.some((item) => item.slug === currentSlug);
456
+ const [open, setOpen] = useState(containsCurrent);
457
+ const storageKey = noteTrayGroupStorageKey(traySlug, group.key);
458
+ const label = grouping === "year" ? group.key : formatYearMonthLabel(group.key, locale);
459
+
460
+ useEffect(() => {
461
+ const stored = getOpenSet();
462
+ if (stored.has(storageKey) && !open) setOpen(true);
463
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
464
+
465
+ useEffect(() => {
466
+ if (containsCurrent && !open) {
467
+ setOpen(true);
468
+ const stored = getOpenSet();
469
+ stored.add(storageKey);
470
+ saveOpenSet(stored);
471
+ }
472
+ }, [containsCurrent]); // eslint-disable-line react-hooks/exhaustive-deps
473
+
474
+ useEffect(() => {
475
+ if (open) {
476
+ const stored = getOpenSet();
477
+ if (!stored.has(storageKey)) {
478
+ stored.add(storageKey);
479
+ saveOpenSet(stored);
480
+ }
481
+ }
482
+ }, [open, storageKey]);
483
+
484
+ const toggle = useCallback(() => {
485
+ setOpen((previous) => {
486
+ const next = !previous;
487
+ const stored = getOpenSet();
488
+ if (next) stored.add(storageKey);
489
+ else stored.delete(storageKey);
490
+ saveOpenSet(stored);
491
+ return next;
492
+ });
493
+ }, [storageKey]);
494
+
495
+ const isExpanded = forceOpen || open;
496
+
497
+ return (
498
+ <div className={!isLast && isExpanded ? "relative" : ""}>
499
+ {!isLast && isExpanded && (
500
+ <div
501
+ className="absolute border-l border-solid border-muted z-local-1"
502
+ style={{ left: connectorLeft(1), top: 0, bottom: 0 }}
503
+ />
504
+ )}
505
+ <div className="relative">
506
+ <ConnectorLines depth={1} isLast={isLast} topPad="var(--spacing-vsp-xs)" />
507
+ <button
508
+ type="button"
509
+ onClick={toggle}
510
+ className="flex w-full items-center gap-hsp-md py-vsp-xs text-left text-small font-semibold text-fg hover:text-accent hover:underline focus:underline focus:text-accent break-words"
511
+ style={{ paddingLeft: padLeft(1, true) }}
512
+ aria-expanded={isExpanded}
513
+ aria-label={isExpanded ? `Collapse ${label}` : `Expand ${label}`}
514
+ data-zd-sidebar-open-key={storageKey}
515
+ >
516
+ <span className="aspect-square flex items-center justify-center w-[1.5rem] shrink-0 border border-muted">
517
+ <ToggleChevron isExpanded={isExpanded} className="text-muted" />
518
+ </span>
519
+ <span>{label}</span>
520
+ </button>
521
+ </div>
522
+ {isExpanded && (
523
+ <div>
524
+ {group.items.map((item, index) => (
525
+ <TrayItem
526
+ key={item.slug}
527
+ item={item}
528
+ currentSlug={currentSlug}
529
+ isLast={index === group.items.length - 1}
530
+ showDate
531
+ depth={2}
532
+ />
533
+ ))}
534
+ </div>
535
+ )}
536
+ </div>
537
+ );
538
+ }
539
+
308
540
  // NodeList is memo-wrapped so that when only the filter query changes but
309
541
  // a subtree's nodes/currentSlug/depth/forceOpen are unchanged, Preact can
310
542
  // skip re-rendering the whole subtree.
@@ -6,8 +6,21 @@
6
6
  // lets us consume React-typed components in this Preact app.
7
7
  import { useState } from "preact/hooks";
8
8
  import type { SidebarNavNode } from "../sidebar/types.js";
9
- import { INDENT, connectorLeft, ConnectorLines, CategoryLinkIcon } from "../tree-nav-shared/index.js";
9
+ import {
10
+ INDENT,
11
+ connectorLeft,
12
+ ConnectorLines,
13
+ CategoryLinkIcon,
14
+ } from "../tree-nav-shared/index.js";
10
15
  import { ChevronRight } from "../icons/index.js";
16
+ import {
17
+ formatDate,
18
+ formatYearMonthLabel,
19
+ getNoteTrayItems,
20
+ groupItems,
21
+ rankWidth,
22
+ } from "../note-tray-model/index.js";
23
+ import { formatMonthDay } from "../format-date/index.js";
11
24
  import { initialCategoryOpenState, toggleCategoryOpenState } from "./state.js";
12
25
 
13
26
  // site-tree-nav uses wider padding than the narrow sidebar
@@ -42,6 +55,10 @@ export interface SiteTreeNavProps {
42
55
  categoryIgnore?: string[];
43
56
  /** Root-category slugs that should start collapsed. */
44
57
  initiallyCollapsedCategorySlugs?: string[];
58
+ /** Locale used by dated note-tray rows. */
59
+ locale?: string;
60
+ /** Localized label shown before an item's updated date. */
61
+ updatedLabel?: string;
45
62
  }
46
63
 
47
64
  export function SiteTreeNav({
@@ -50,6 +67,8 @@ export function SiteTreeNav({
50
67
  categoryOrder,
51
68
  categoryIgnore,
52
69
  initiallyCollapsedCategorySlugs,
70
+ locale = "en",
71
+ updatedLabel = "Updated",
53
72
  }: SiteTreeNavProps) {
54
73
  let processedTree = tree;
55
74
  if (categoryIgnore) {
@@ -69,20 +88,27 @@ export function SiteTreeNav({
69
88
  gridTemplateColumns: "repeat(auto-fill, minmax(min(18rem, 100%), 1fr))",
70
89
  }}
71
90
  >
72
- {processedTree.map((node) => (
73
- <div key={node.slug} className="min-w-0 border border-muted pl-hsp-sm py-vsp-2xs">
74
- {node.children.length > 0 ? (
75
- <CategoryNode
76
- node={node}
77
- depth={0}
78
- isLast={true}
79
- initiallyCollapsed={initiallyCollapsed.has(node.slug)}
80
- />
81
- ) : (
82
- <LeafNode node={node} depth={0} isLast={true} />
83
- )}
84
- </div>
85
- ))}
91
+ {processedTree.map((node) => {
92
+ if (node.shape === "note-tray" && getNoteTrayItems(node).length === 0) {
93
+ return null;
94
+ }
95
+ return (
96
+ <div key={node.slug} className="min-w-0 border border-muted pl-hsp-sm py-vsp-2xs">
97
+ {node.children.length > 0 ? (
98
+ <CategoryNode
99
+ node={node}
100
+ depth={0}
101
+ isLast={true}
102
+ initiallyCollapsed={initiallyCollapsed.has(node.slug)}
103
+ locale={locale}
104
+ updatedLabel={updatedLabel}
105
+ />
106
+ ) : (
107
+ <LeafNode node={node} depth={0} isLast={true} />
108
+ )}
109
+ </div>
110
+ );
111
+ })}
86
112
  </nav>
87
113
  );
88
114
  }
@@ -118,11 +144,15 @@ function CategoryNode({
118
144
  depth,
119
145
  isLast,
120
146
  initiallyCollapsed = false,
147
+ locale = "en",
148
+ updatedLabel = "Updated",
121
149
  }: {
122
150
  node: SidebarNavNode;
123
151
  depth: number;
124
152
  isLast: boolean;
125
153
  initiallyCollapsed?: boolean;
154
+ locale?: string;
155
+ updatedLabel?: string;
126
156
  }) {
127
157
  const [open, setOpen] = useState(() => initialCategoryOpenState(initiallyCollapsed));
128
158
  const toggle = () => setOpen(toggleCategoryOpenState);
@@ -185,13 +215,133 @@ function CategoryNode({
185
215
  </div>
186
216
  {open && (
187
217
  <div>
188
- <NodeList nodes={node.children} depth={depth + 1} />
218
+ {node.shape === "note-tray" && depth === 0 ? (
219
+ <NoteTrayNodeList node={node} locale={locale} updatedLabel={updatedLabel} />
220
+ ) : (
221
+ <NodeList nodes={node.children} depth={depth + 1} />
222
+ )}
189
223
  </div>
190
224
  )}
191
225
  </div>
192
226
  );
193
227
  }
194
228
 
229
+ function NoteTrayNodeList({
230
+ node,
231
+ locale,
232
+ updatedLabel,
233
+ }: {
234
+ node: SidebarNavNode;
235
+ locale: string;
236
+ updatedLabel: string;
237
+ }) {
238
+ const items = getNoteTrayItems(node);
239
+ const width = rankWidth(items);
240
+ const showDate = node.noteTrayDated === true;
241
+ const grouping =
242
+ showDate && node.noteTraySidebar !== "index"
243
+ ? node.noteTraySidebar
244
+ : undefined;
245
+
246
+ if (grouping === "year" || grouping === "month") {
247
+ return (
248
+ <div className="pl-hsp-md">
249
+ {groupItems(items, grouping, node.sortOrder ?? "asc").map((group) => (
250
+ <div key={group.key} data-note-tray-group={group.key}>
251
+ <div className="pt-vsp-sm pb-vsp-2xs text-micro tracking-wide uppercase text-muted">
252
+ {grouping === "year"
253
+ ? group.key
254
+ : formatYearMonthLabel(group.key, locale)}
255
+ </div>
256
+ {group.items.map((item) => (
257
+ <NoteTrayRow
258
+ key={item.slug}
259
+ item={item}
260
+ locale={locale}
261
+ updatedLabel={updatedLabel}
262
+ rankWidth={width}
263
+ showDate={showDate}
264
+ groupedDate={true}
265
+ />
266
+ ))}
267
+ </div>
268
+ ))}
269
+ </div>
270
+ );
271
+ }
272
+
273
+ return (
274
+ <div className="pl-hsp-md">
275
+ {items.map((item) => (
276
+ <NoteTrayRow
277
+ key={item.slug}
278
+ item={item}
279
+ locale={locale}
280
+ updatedLabel={updatedLabel}
281
+ rankWidth={width}
282
+ showDate={showDate}
283
+ groupedDate={false}
284
+ />
285
+ ))}
286
+ </div>
287
+ );
288
+ }
289
+
290
+ function NoteTrayRow({
291
+ item,
292
+ locale,
293
+ updatedLabel,
294
+ rankWidth: width,
295
+ showDate,
296
+ groupedDate,
297
+ }: {
298
+ item: SidebarNavNode;
299
+ locale: string;
300
+ updatedLabel: string;
301
+ rankWidth: number;
302
+ showDate: boolean;
303
+ groupedDate: boolean;
304
+ }) {
305
+ if (!item.href) return null;
306
+ const dateLabel = showDate && item.date
307
+ ? groupedDate
308
+ ? formatMonthDay(item.date)
309
+ : formatDate(item.date, locale)
310
+ : undefined;
311
+
312
+ return (
313
+ <a
314
+ href={item.href}
315
+ data-note-tray-row
316
+ className="flex items-start gap-hsp-sm py-vsp-2xs text-small text-fg hover:text-accent hover:underline focus:underline"
317
+ >
318
+ {dateLabel ? (
319
+ <time
320
+ dateTime={item.date}
321
+ className="shrink-0 font-mono tabular-nums text-caption text-muted"
322
+ >
323
+ {dateLabel}
324
+ </time>
325
+ ) : (
326
+ <span
327
+ className="shrink-0 font-mono tabular-nums text-caption text-muted"
328
+ style={{ width: `${width}ch` }}
329
+ >
330
+ {item.rank === undefined ? "" : String(item.rank).padStart(width, "0")}
331
+ </span>
332
+ )}
333
+ <span className="min-w-0">
334
+ <span>{item.label}</span>
335
+ {item.updated && (
336
+ <span className="block text-micro text-muted">
337
+ {updatedLabel} {formatDate(item.updated, locale)}
338
+ </span>
339
+ )}
340
+ </span>
341
+ </a>
342
+ );
343
+ }
344
+
195
345
  function LeafNode({
196
346
  node,
197
347
  depth,