@takazudo/zudo-doc 5.9.0 → 5.11.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 (66) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +1 -1
  3. package/dist/chrome/derive.d.ts +1 -1
  4. package/dist/chrome/derive.js +25 -2
  5. package/dist/config-assertions/index.d.ts +30 -0
  6. package/dist/config-assertions/index.js +41 -1
  7. package/dist/config.d.ts +9 -0
  8. package/dist/config.js +1 -0
  9. package/dist/doc-content-header/index.js +6 -0
  10. package/dist/doc-metainfo-area/index.js +1 -14
  11. package/dist/doc-page-renderer/index.d.ts +3 -3
  12. package/dist/doc-page-renderer/index.js +1 -1
  13. package/dist/doc-page-shell/index.d.ts +6 -0
  14. package/dist/doc-pager/index.d.ts +6 -0
  15. package/dist/doc-pager/index.js +5 -2
  16. package/dist/docs-schema/index.d.ts +11 -0
  17. package/dist/docs-schema/index.js +11 -1
  18. package/dist/format-date/index.d.ts +12 -0
  19. package/dist/format-date/index.js +64 -0
  20. package/dist/frontmatter-preview-defaults/index.js +6 -1
  21. package/dist/header/header.js +9 -1
  22. package/dist/home-page/index.js +5 -2
  23. package/dist/html-preview-wrapper/highlight-runtime.d.ts +7 -7
  24. package/dist/html-preview-wrapper/highlight-runtime.js +1 -1
  25. package/dist/integrations/changelog/emit.js +19 -2
  26. package/dist/mdx-components/index.d.ts +4 -1
  27. package/dist/mdx-components/index.js +3 -1
  28. package/dist/nav-indexing/index.d.ts +2 -0
  29. package/dist/nav-indexing/index.js +2 -0
  30. package/dist/nav-indexing/note-tray-index.d.ts +29 -0
  31. package/dist/nav-indexing/note-tray-index.js +120 -0
  32. package/dist/note-tray-index/index.d.ts +45 -0
  33. package/dist/note-tray-index/index.js +52 -0
  34. package/dist/note-tray-model/index.d.ts +31 -0
  35. package/dist/note-tray-model/index.js +74 -0
  36. package/dist/preset.d.ts +11 -0
  37. package/dist/preset.js +5 -1
  38. package/dist/safelist.css +1 -1
  39. package/dist/settings.d.ts +2 -0
  40. package/dist/sidebar/types.d.ts +6 -0
  41. package/dist/sidebar-tree/build-tree.js +8 -0
  42. package/dist/sidebar-tree/types.d.ts +12 -0
  43. package/dist/sidebar-tree-island/index.js +176 -1
  44. package/dist/sidebar-utils/index.d.ts +6 -0
  45. package/dist/site-schema/doc-route-entries.js +2 -0
  46. package/dist/site-schema/index.d.ts +1 -0
  47. package/dist/site-schema/index.js +3 -1
  48. package/dist/site-schema/nav-tree.js +17 -2
  49. package/dist/site-schema/note-tray-validate.d.ts +7 -0
  50. package/dist/site-schema/note-tray-validate.js +94 -0
  51. package/dist/site-schema/types.d.ts +11 -0
  52. package/dist/site-tree-nav/index.d.ts +7 -0
  53. package/dist/site-tree-nav/index.js +3 -2
  54. package/dist/site-tree-nav-island/index.d.ts +5 -1
  55. package/dist/site-tree-nav-island/index.js +119 -12
  56. package/dist/theme-packs/drift/pack.css +6 -1
  57. package/dist/theme-packs/hearth/pack.css +5 -0
  58. package/dist/theme-packs/sakura/pack.css +6 -0
  59. package/dist/theme-packs/scandi/pack.css +7 -7
  60. package/dist/theme-packs/timberline/pack.css +5 -0
  61. package/eject/doc-pager/index.tsx +9 -0
  62. package/eject/header/header.tsx +26 -1
  63. package/eject/sidebar/types.ts +6 -0
  64. package/eject/sidebar-tree-island/index.tsx +241 -6
  65. package/eject/site-tree-nav-island/index.tsx +166 -16
  66. package/package.json +20 -8
@@ -250,6 +250,12 @@ html[data-theme-pack="sakura"] #desktop-sidebar a,
250
250
  html[data-theme-pack="sakura"] aside[data-zd-mobile-sidebar] a {
251
251
  border-radius: 9999px;
252
252
  }
253
+ /* Non-pill current rows stay square in both sidebar surfaces; the
254
+ data-nav-active rule below owns Sakura's nested petal shape. */
255
+ html[data-theme-pack="sakura"] #desktop-sidebar a[aria-current="page"]:not([data-nav-active]),
256
+ html[data-theme-pack="sakura"] aside[data-zd-mobile-sidebar] a[aria-current="page"]:not([data-nav-active]) {
257
+ border-radius: 0;
258
+ }
253
259
  html[data-theme-pack="sakura"] #desktop-sidebar a:not([data-nav-active]):hover,
254
260
  html[data-theme-pack="sakura"] aside[data-zd-mobile-sidebar] a:not([data-nav-active]):hover {
255
261
  text-decoration: none;
@@ -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
@@ -36,6 +44,7 @@ function ToggleChevron({ isExpanded, className }: { isExpanded: boolean; classNa
36
44
  }
37
45
 
38
46
  const STORAGE_KEY = "zd-sidebar-open";
47
+ const GROUPED_TRAY_ITEM_DEPTH = 3;
39
48
 
40
49
  function padLeft(depth: number, forCategory: boolean): string {
41
50
  if (depth === 0) return `calc(${BASE_PAD} + ${forCategory ? "0.15rem" : "0rem"})`;
@@ -235,6 +244,12 @@ export function SidebarTree({ nodes, currentSlug, currentPath, rootMenuItems, ba
235
244
  [localeLinks, themeDefaultMode],
236
245
  );
237
246
 
247
+ const noteTrayRoot = nodes.length === 1 && nodes[0]?.shape === "note-tray" ? nodes[0] : undefined;
248
+ const filteredNoteTrayRoot = noteTrayRoot
249
+ ? filteredNodes.find((node) => node.slug === noteTrayRoot.slug)
250
+ : undefined;
251
+ const locale = localeLinks?.find((link) => link.active)?.code ?? "en";
252
+
238
253
  // Root menu view: show headerNav items as a simple list (Docusaurus-style)
239
254
  if (showingRootMenu && rootMenuItems) {
240
255
  return (
@@ -293,18 +308,238 @@ export function SidebarTree({ nodes, currentSlug, currentPath, rootMenuItems, ba
293
308
  />
294
309
  </div>
295
310
  </div>
296
- <NodeList
297
- nodes={filteredNodes}
298
- currentSlug={activeSlug}
299
- depth={0}
300
- forceOpen={!!query}
301
- />
311
+ {noteTrayRoot ? (
312
+ filteredNoteTrayRoot && (
313
+ <TrayList
314
+ tray={filteredNoteTrayRoot}
315
+ itemCount={getNoteTrayItems(noteTrayRoot).length}
316
+ currentSlug={activeSlug}
317
+ forceOpen={!!query}
318
+ locale={locale}
319
+ />
320
+ )
321
+ ) : (
322
+ <NodeList
323
+ nodes={filteredNodes}
324
+ currentSlug={activeSlug}
325
+ depth={0}
326
+ forceOpen={!!query}
327
+ />
328
+ )}
302
329
  {footer}
303
330
  </nav>
304
331
  );
305
332
  }
306
333
  SidebarTree.displayName = "SidebarTree";
307
334
 
335
+ function TrayList({
336
+ tray,
337
+ itemCount,
338
+ currentSlug,
339
+ forceOpen,
340
+ locale,
341
+ }: {
342
+ tray: SidebarNavNode;
343
+ itemCount: number;
344
+ currentSlug?: string;
345
+ forceOpen: boolean;
346
+ locale: string;
347
+ }) {
348
+ const items = getNoteTrayItems(tray);
349
+ const sidebarStyle = tray.noteTraySidebar ?? "index";
350
+ const width = rankWidth(itemCount);
351
+
352
+ return (
353
+ <>
354
+ <LeafNode node={tray} currentSlug={currentSlug} depth={0} isLast={items.length === 0} />
355
+ {sidebarStyle === "index" ? (
356
+ items.map((item, index) => (
357
+ <TrayItem
358
+ key={item.slug}
359
+ item={item}
360
+ currentSlug={currentSlug}
361
+ rankDigits={width}
362
+ isLast={index === items.length - 1}
363
+ />
364
+ ))
365
+ ) : (
366
+ groupItems(items, sidebarStyle, tray.sortOrder ?? "asc").map((group, index, groups) => (
367
+ <TrayGroupNode
368
+ key={group.key}
369
+ traySlug={tray.slug}
370
+ group={group}
371
+ grouping={sidebarStyle}
372
+ locale={locale}
373
+ currentSlug={currentSlug}
374
+ forceOpen={forceOpen}
375
+ isLast={index === groups.length - 1}
376
+ />
377
+ ))
378
+ )}
379
+ </>
380
+ );
381
+ }
382
+
383
+ function TrayItem({
384
+ item,
385
+ currentSlug,
386
+ rankDigits,
387
+ isLast,
388
+ showDate = false,
389
+ depth = 1,
390
+ }: {
391
+ item: SidebarNavNode;
392
+ currentSlug?: string;
393
+ rankDigits?: number;
394
+ isLast: boolean;
395
+ showDate?: boolean;
396
+ depth?: number;
397
+ }) {
398
+ if (!item.href) return null;
399
+ const isActive = item.slug === currentSlug;
400
+ const labelHtml = smartBreakToHtml(item.label);
401
+ const shortDate = item.date ? formatMonthDay(item.date) : undefined;
402
+
403
+ return (
404
+ <div className={isLast ? "pb-vsp-md" : ""}>
405
+ <div className="relative">
406
+ <ConnectorLines depth={depth} isLast={isLast} topPad="var(--spacing-vsp-2xs)" />
407
+ <a
408
+ href={item.href}
409
+ aria-current={isActive ? "page" : undefined}
410
+ data-nav-active={isActive ? "" : undefined}
411
+ className={`flex items-start gap-hsp-xs py-vsp-2xs pr-[4px] text-small break-words ${
412
+ isActive
413
+ ? "bg-fg font-medium text-bg"
414
+ : "text-muted hover:text-accent hover:underline focus:underline focus:text-accent"
415
+ }`}
416
+ style={{ paddingLeft: padLeft(depth, false) }}
417
+ >
418
+ {rankDigits !== undefined && (
419
+ <span className={`shrink-0 tabular-nums${isActive ? "" : " text-muted"}`}>
420
+ {item.rank === undefined ? "" : String(item.rank).padStart(rankDigits, "0")}
421
+ </span>
422
+ )}
423
+ <span className="min-w-0 flex-1" dangerouslySetInnerHTML={{ __html: labelHtml }} />
424
+ {showDate && shortDate && (
425
+ <span className={`shrink-0 tabular-nums${isActive ? "" : " text-muted"}`}>
426
+ {shortDate}
427
+ </span>
428
+ )}
429
+ </a>
430
+ </div>
431
+ </div>
432
+ );
433
+ }
434
+
435
+ function noteTrayGroupStorageKey(traySlug: string, groupKey: string): string {
436
+ return `${traySlug}#${groupKey}`;
437
+ }
438
+
439
+ function TrayGroupNode({
440
+ traySlug,
441
+ group,
442
+ grouping,
443
+ locale,
444
+ currentSlug,
445
+ forceOpen,
446
+ isLast,
447
+ }: {
448
+ traySlug: string;
449
+ group: NoteTrayGroup<SidebarNavNode>;
450
+ grouping: "year" | "month";
451
+ locale: string;
452
+ currentSlug?: string;
453
+ forceOpen: boolean;
454
+ isLast: boolean;
455
+ }) {
456
+ const containsCurrent = group.items.some((item) => item.slug === currentSlug);
457
+ const [open, setOpen] = useState(containsCurrent);
458
+ const storageKey = noteTrayGroupStorageKey(traySlug, group.key);
459
+ const label = grouping === "year" ? group.key : formatYearMonthLabel(group.key, locale);
460
+
461
+ useEffect(() => {
462
+ const stored = getOpenSet();
463
+ if (stored.has(storageKey) && !open) setOpen(true);
464
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
465
+
466
+ useEffect(() => {
467
+ if (containsCurrent && !open) {
468
+ setOpen(true);
469
+ const stored = getOpenSet();
470
+ stored.add(storageKey);
471
+ saveOpenSet(stored);
472
+ }
473
+ }, [containsCurrent]); // eslint-disable-line react-hooks/exhaustive-deps
474
+
475
+ useEffect(() => {
476
+ if (open) {
477
+ const stored = getOpenSet();
478
+ if (!stored.has(storageKey)) {
479
+ stored.add(storageKey);
480
+ saveOpenSet(stored);
481
+ }
482
+ }
483
+ }, [open, storageKey]);
484
+
485
+ const toggle = useCallback(() => {
486
+ setOpen((previous) => {
487
+ const next = !previous;
488
+ const stored = getOpenSet();
489
+ if (next) stored.add(storageKey);
490
+ else stored.delete(storageKey);
491
+ saveOpenSet(stored);
492
+ return next;
493
+ });
494
+ }, [storageKey]);
495
+
496
+ const isExpanded = forceOpen || open;
497
+
498
+ return (
499
+ <div className={!isLast && isExpanded ? "relative" : ""}>
500
+ {!isLast && isExpanded && (
501
+ <div
502
+ className="absolute border-l border-solid border-muted z-local-1"
503
+ style={{ left: connectorLeft(1), top: 0, bottom: 0 }}
504
+ />
505
+ )}
506
+ <div className="relative">
507
+ <ConnectorLines depth={1} isLast={isLast} topPad="var(--spacing-vsp-xs)" />
508
+ <button
509
+ type="button"
510
+ onClick={toggle}
511
+ 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"
512
+ style={{ paddingLeft: padLeft(1, true) }}
513
+ aria-expanded={isExpanded}
514
+ aria-label={isExpanded ? `Collapse ${label}` : `Expand ${label}`}
515
+ data-zd-sidebar-open-key={storageKey}
516
+ >
517
+ <span className="aspect-square flex items-center justify-center w-[1.5rem] shrink-0 border border-muted">
518
+ <ToggleChevron isExpanded={isExpanded} className="text-muted" />
519
+ </span>
520
+ <span>{label}</span>
521
+ </button>
522
+ </div>
523
+ {isExpanded && (
524
+ <div>
525
+ {group.items.map((item, index) => (
526
+ <TrayItem
527
+ key={item.slug}
528
+ item={item}
529
+ currentSlug={currentSlug}
530
+ isLast={index === group.items.length - 1}
531
+ showDate
532
+ // Leave one visual indentation step between the group branch
533
+ // and its dated child branch so the hierarchy reads clearly.
534
+ depth={GROUPED_TRAY_ITEM_DEPTH}
535
+ />
536
+ ))}
537
+ </div>
538
+ )}
539
+ </div>
540
+ );
541
+ }
542
+
308
543
  // NodeList is memo-wrapped so that when only the filter query changes but
309
544
  // a subtree's nodes/currentSlug/depth/forceOpen are unchanged, Preact can
310
545
  // 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,