@dogsbay/docs-layout 0.2.0-beta.9 → 0.2.0-beta.91

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dogsbay/docs-layout",
3
- "version": "0.2.0-beta.9",
3
+ "version": "0.2.0-beta.91",
4
4
  "description": "Standard documentation layout components for Dogsbay",
5
5
  "type": "module",
6
6
  "exports": {
@@ -29,14 +29,14 @@
29
29
  "./json-ld": "./src/json-ld.ts"
30
30
  },
31
31
  "dependencies": {
32
- "@dogsbay/ui": "0.2.0-beta.9",
33
- "@dogsbay/primitives": "0.2.0-beta.9"
32
+ "@dogsbay/ui": "0.2.0-beta.91",
33
+ "@dogsbay/primitives": "0.2.0-beta.91"
34
34
  },
35
35
  "devDependencies": {
36
36
  "vitest": "^3.0.0"
37
37
  },
38
38
  "peerDependencies": {
39
- "astro": "^5.0.0 || ^6.0.0"
39
+ "astro": "^5.0.0 || ^6.0.0 || ^7.0.0"
40
40
  },
41
41
  "files": [
42
42
  "src",
@@ -31,9 +31,11 @@ import SidebarMenuItem from "@dogsbay/ui/sidebar/SidebarMenuItem.astro";
31
31
  import SidebarMenuButton from "@dogsbay/ui/sidebar/SidebarMenuButton.astro";
32
32
  import SidebarSeparator from "@dogsbay/ui/sidebar/SidebarSeparator.astro";
33
33
  import SidebarNavTree from "@dogsbay/ui/sidebar/SidebarNavTree.astro";
34
+ import DocsNavClient from "./DocsNavClient.astro";
34
35
  import Separator from "@dogsbay/ui/separator/Separator.astro";
35
36
  import ThemeToggle from "@dogsbay/ui/theme-toggle/ThemeToggle.astro";
36
37
  import DocsToc from "./DocsToc.astro";
38
+ import { resolveTocPlacement, hasDisplayableToc, type TocMode } from "./toc-placement.js";
37
39
  import DocsFooter from "./DocsFooter.astro";
38
40
  import SearchDialog from "./SearchDialog.astro";
39
41
  import TagList from "./TagList.astro";
@@ -44,7 +46,7 @@ import VersionSwitcher from "./VersionSwitcher.astro";
44
46
  import LocaleSwitcher from "./LocaleSwitcher.astro";
45
47
  import { filterNavByAxis } from "./nav-filter.js";
46
48
  import type { LlmProviderName } from "./llm-actions.js";
47
- import { jsonLdTypeFor, normalizeCustomJsonLd } from "./json-ld.js";
49
+ import { jsonLdTypeFor, normalizeCustomJsonLd, buildArticleJsonLd } from "./json-ld.js";
48
50
  import { resolveTagKeywords } from "./tag-list-data.js";
49
51
 
50
52
  interface NavItem {
@@ -112,7 +114,13 @@ interface Props {
112
114
  siteDescription?: string;
113
115
  /** Copyright text (HTML allowed) */
114
116
  copyright?: string;
115
- /** Favicon path (default: "/favicon.ico"). Set to false to disable. */
117
+ /**
118
+ * Favicon path. No host-root default — that 404s on subpath
119
+ * deploys. The format-astro emitter passes a combined-prefix
120
+ * path (e.g. `/<repo>/favicon.ico`) computed from site.url +
121
+ * basePath. Pass an empty string or `false` to disable. When
122
+ * undefined, no `<link rel="icon">` is emitted.
123
+ */
116
124
  favicon?: string | false;
117
125
  /** Per-page OG image URL. Overrides defaultOgImage. */
118
126
  ogImage?: string;
@@ -127,16 +135,25 @@ interface Props {
127
135
  /** Theme color hint for browsers (hex string) */
128
136
  themeColor?: string;
129
137
  /**
130
- * Emit `<meta name="robots" content="noindex, nofollow">` when
138
+ * Emit `noindex` in the `<meta name="robots">` directive when
131
139
  * true. Tells external search engines (Google, Bing) to skip
132
- * this page. Has NO effect on in-site Pagefind search — for
133
- * that, use `excludeFromSearch`. The two are independent: a
134
- * page can be excluded from external SEs but still appear in
135
- * Pagefind (e.g. duplicate / old content readers might still
136
- * want to find when they're already on the site), or vice
137
- * versa.
140
+ * indexing this page. Independent of `nofollow` common pattern
141
+ * for tag / index pages is noindex + follow (don't list this
142
+ * page in results, but do crawl through to the real content).
143
+ *
144
+ * Has NO effect on in-site Pagefind search for that, use
145
+ * `excludeFromSearch`. The two are independent: a page can be
146
+ * excluded from external SEs but still appear in Pagefind (e.g.
147
+ * duplicate / old content readers might still want to find when
148
+ * they're already on the site), or vice versa.
138
149
  */
139
150
  noindex?: boolean;
151
+ /**
152
+ * Emit `nofollow` in the `<meta name="robots">` directive when
153
+ * true. Tells crawlers not to follow outbound links from this
154
+ * page. Independent of `noindex`. Default false.
155
+ */
156
+ nofollow?: boolean;
140
157
  /**
141
158
  * Exclude this page from in-site Pagefind search results.
142
159
  *
@@ -256,6 +273,23 @@ interface Props {
256
273
  * elements in `<body>`.
257
274
  */
258
275
  category?: string[];
276
+ /**
277
+ * Custom-taxonomy values from `meta.taxonomies` — anything declared
278
+ * in `taxonomies:` config that isn't one of the five hardcoded
279
+ * built-ins (`tags`, `category`, `audience`, `type`, `status`).
280
+ *
281
+ * Each entry becomes one `<div data-pagefind-filter="<name>:<value>">`
282
+ * inside the indexed body, so the search dialog grows a checkbox
283
+ * group for every custom taxonomy automatically. No extra config
284
+ * needed beyond declaring the taxonomy in `dogsbay.config.yml` —
285
+ * the search dialog discovers facets at index time and renders
286
+ * whatever Pagefind reports.
287
+ *
288
+ * Display labels for the checkboxes flow through
289
+ * `taxonomyDisplay[<name>]` (same prefix/label config that drives
290
+ * chip rendering elsewhere). When unset, raw slugs are shown.
291
+ */
292
+ taxonomies?: Record<string, string[]>;
259
293
  /**
260
294
  * Map of taxonomy name → index path for declared taxonomies.
261
295
  * Used to wire links from built-in field badges (TypeBadge,
@@ -324,6 +358,19 @@ interface Props {
324
358
  * (`<basePath>/<version>/`). Defaults to "/docs".
325
359
  */
326
360
  basePath?: string;
361
+ /**
362
+ * Sidebar navigation render mode.
363
+ *
364
+ * - `"client"` (default): emit the small `<DocsNavClient />` placeholder;
365
+ * the tree is hydrated from `/_dogsbay/nav.json` once per session.
366
+ * Page HTML shrinks dramatically at scale (~50 KB vs ~1.2 MB on a
367
+ * 2k-page site). No-JS users see a sitemap fallback link.
368
+ * - `"ssr-full"`: render the full nav tree into every page's HTML.
369
+ * Best for very small sites or strict no-JS / SEO contexts.
370
+ *
371
+ * See plans/client-rendered-nav.md.
372
+ */
373
+ navMode?: "client" | "ssr-full";
327
374
  /**
328
375
  * Per-page LLM action UI. When set and `enabled !== false`, renders
329
376
  * the PageActions cluster (Copy markdown + Open in Claude/ChatGPT/
@@ -358,6 +405,17 @@ interface Props {
358
405
  * in per-page.
359
406
  */
360
407
  wideLayout?: boolean;
408
+ /**
409
+ * Table-of-contents placement. Default `"top"`.
410
+ * - `"top"` — expandable "On this page" disclosure at the top of the
411
+ * article, identical on desktop and mobile; frees the right rail for the
412
+ * `right-rail` named slot (e.g. an Ask AI panel).
413
+ * - `"popover"` — an "On this page" dropdown in the header.
414
+ * - `"rail"` — the classic right-hand TOC sidebar (pre-`toc` behaviour).
415
+ * - `"off"` — no table of contents.
416
+ * See plans/ask-branch1-placement-toc.md.
417
+ */
418
+ toc?: TocMode;
361
419
  class?: string;
362
420
  }
363
421
 
@@ -368,6 +426,7 @@ const {
368
426
  nav,
369
427
  navGroups,
370
428
  headings = [],
429
+ toc = "top",
371
430
  prev,
372
431
  next,
373
432
  repoUrl,
@@ -376,7 +435,7 @@ const {
376
435
  editUrl,
377
436
  lastUpdated,
378
437
  copyright,
379
- favicon = "/favicon.ico",
438
+ favicon,
380
439
  ogImage,
381
440
  defaultOgImage,
382
441
  ogType = "article",
@@ -384,11 +443,18 @@ const {
384
443
  twitterHandle,
385
444
  themeColor,
386
445
  noindex,
446
+ nofollow,
387
447
  excludeFromSearch,
388
448
  plausibleDomain,
389
449
  plausibleScriptUrl,
390
450
  hideSearch = false,
391
- pagefindUrl = "/pagefind/",
451
+ // No default for pagefindUrl host-root absolute paths break on
452
+ // subpath-mounted deploys (GH Pages project pages, multi-mount
453
+ // Cloudflare). format-astro's emitter always passes the
454
+ // combined-prefix-aware URL; manual instantiation must too.
455
+ // Undefined here propagates to SearchDialog where the JS loader
456
+ // throws on first open instead of silently 404'ing the bundle.
457
+ pagefindUrl,
392
458
  mdMirror = false,
393
459
  tags,
394
460
  tagsIndexPath = "/tags",
@@ -398,6 +464,7 @@ const {
398
464
  pageType,
399
465
  audience,
400
466
  category,
467
+ taxonomies,
401
468
  taxonomyIndexPaths,
402
469
  taxonomyDisplay,
403
470
  autoH1,
@@ -407,6 +474,7 @@ const {
407
474
  multiSource,
408
475
  switcherMap,
409
476
  basePath,
477
+ navMode = "client",
410
478
  wideLayout = false,
411
479
  class: className,
412
480
  } = Astro.props;
@@ -428,6 +496,16 @@ const showLlmActionsInline =
428
496
  llmActionsEnabled
429
497
  && (llmActionsPlacement === "inline" || llmActionsPlacement === "both");
430
498
  const llmFooterLink = !!llmActions && llmActions.footerLink !== false;
499
+ // Per-mount llms.txt URL — Dogsbay emits `<basePath>/llms.txt`
500
+ // (sitemap-index pattern), so the footer link must be basePath-
501
+ // prefixed too. Falls back to `/llms.txt` when basePath is empty
502
+ // or unset, matching the platform's host-root single-site case.
503
+ const llmsLinkHrefResolved = (() => {
504
+ const bp = (basePath ?? "").replace(/\/+$/, "");
505
+ if (!bp) return "/llms.txt";
506
+ const prefix = bp.startsWith("/") ? bp : `/${bp}`;
507
+ return `${prefix}/llms.txt`;
508
+ })();
431
509
 
432
510
  // Compute href targets for the type / status badges. A field is
433
511
  // linkable only when (a) the user declared a `taxonomies.<field>`
@@ -447,15 +525,41 @@ const hasMetaStrip = (
447
525
  (typeof pageType === "string" && pageType.length > 0)
448
526
  );
449
527
 
528
+ // TOC placement: which container(s) + the right-rail plugin region render.
529
+ // Logic lives in toc-placement.ts so it's unit-tested; this file just consumes.
530
+ const tocPlacement = resolveTocPlacement(toc, {
531
+ // Only show a TOC when the page has 2+ displayable headings (depth 2–3);
532
+ // the H1 doesn't count and a one-item TOC is noise. Keeps "On this page"
533
+ // off landing/welcome pages that are just an H1 + prose.
534
+ hasHeadings: hasDisplayableToc(headings),
535
+ wideLayout: !!wideLayout,
536
+ });
537
+
450
538
  const currentPath = Astro.url.pathname.replace(/\/$/, "") || "/";
451
539
 
452
540
  // SEO computation
453
541
  const metaDescription = description ?? siteDescription;
454
542
  const metaOgImage = ogImage ?? defaultOgImage;
455
543
  const isAbsoluteSiteUrl = /^https?:\/\//.test(siteUrl);
544
+ // Compose canonical from ORIGIN + pathname. siteUrl may carry a
545
+ // path component (the urlBase that drives Astro's `base` — see
546
+ // plans/astro-base-from-site-url.md), and Astro.url.pathname
547
+ // already includes that prefix. Naively concatenating siteUrl +
548
+ // pathname double-counts the urlBase (e.g. .../repo/repo/page).
549
+ // Strip path off siteUrl by reparsing as a URL.
550
+ let canonicalOrigin: string | undefined;
551
+ if (isAbsoluteSiteUrl) {
552
+ try {
553
+ const u = new URL(siteUrl);
554
+ canonicalOrigin = `${u.protocol}//${u.host}`;
555
+ } catch {
556
+ // Malformed siteUrl — fall back to the original (no path) behavior.
557
+ canonicalOrigin = siteUrl.replace(/\/$/, "");
558
+ }
559
+ }
456
560
  const computedCanonical = canonicalUrl
457
- ?? (isAbsoluteSiteUrl
458
- ? siteUrl.replace(/\/$/, "") + Astro.url.pathname
561
+ ?? (canonicalOrigin
562
+ ? canonicalOrigin + Astro.url.pathname
459
563
  : undefined);
460
564
 
461
565
  // Markdown mirror — append `.md` to the current path for the alternate link
@@ -482,15 +586,16 @@ const tagKeywords = resolveTagKeywords(tags, tagLabels);
482
586
  // search engines key off for educational / tutorial / reference
483
587
  // SERP rendering. See `json-ld.ts` for the full mapping table.
484
588
  const articleJsonLd = (ogType === "article" && tagKeywords.length > 0)
485
- ? {
486
- "@context": "https://schema.org",
487
- "@type": jsonLdTypeFor(pageType),
488
- headline: title,
489
- keywords: tagKeywords.join(", "),
490
- ...(metaDescription ? { description: metaDescription } : {}),
491
- ...(metaOgImage ? { image: metaOgImage } : {}),
492
- ...(computedCanonical ? { url: computedCanonical } : {}),
493
- }
589
+ ? buildArticleJsonLd({
590
+ type: jsonLdTypeFor(pageType),
591
+ title,
592
+ siteName,
593
+ keywords: tagKeywords,
594
+ description: metaDescription,
595
+ image: metaOgImage,
596
+ url: computedCanonical,
597
+ headings,
598
+ })
494
599
  : undefined;
495
600
 
496
601
  // `customJsonLd` accepts either a single object or an array.
@@ -510,11 +615,22 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
510
615
  <title>{title} | {siteName}</title>
511
616
 
512
617
  {metaDescription && <meta name="description" content={metaDescription} />}
513
- {favicon !== false && <link rel="icon" href={favicon} />}
618
+ {favicon && <link rel="icon" href={favicon} />}
514
619
  {themeColor && <meta name="theme-color" content={themeColor} />}
515
620
  {/* External search engine directive — orthogonal to in-site
516
- Pagefind exclusion. */}
517
- {noindex && <meta name="robots" content="noindex, nofollow" />}
621
+ Pagefind exclusion. `noindex` + `nofollow` are independent
622
+ bits per the meta-robots spec; emit only the directives that
623
+ are set. Combining them when both are set keeps the tag
624
+ compact (`<meta name="robots" content="noindex, nofollow">`)
625
+ instead of emitting two tags. */}
626
+ {(noindex || nofollow) && (
627
+ <meta
628
+ name="robots"
629
+ content={[noindex && "noindex", nofollow && "nofollow"]
630
+ .filter(Boolean)
631
+ .join(", ")}
632
+ />
633
+ )}
518
634
 
519
635
  {/* In-site Pagefind exclusion is wired via two coordinated
520
636
  attributes on <body> and <main> below — see the prop docs
@@ -529,6 +645,12 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
529
645
 
530
646
  {computedCanonical && <link rel="canonical" href={computedCanonical} />}
531
647
  {mdMirrorHref && <link rel="alternate" type="text/markdown" href={mdMirrorHref} />}
648
+ {/* Programmatic llms.txt discovery — agents that follow head
649
+ link rels get the per-mount llms.txt without parsing HTML.
650
+ Mirrors the existing _headers Link rel="describedby" used by
651
+ Cloudflare Pages / Workers. */}
652
+ <link rel="alternate" type="text/plain" title="llms.txt" href={llmsLinkHrefResolved} />
653
+ <link rel="describedby" type="text/plain" href={llmsLinkHrefResolved} />
532
654
 
533
655
  {/* Open Graph */}
534
656
  <meta property="og:type" content={ogType} />
@@ -592,7 +714,7 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
592
714
  <SidebarHeader>
593
715
  <SidebarMenu>
594
716
  <SidebarMenuItem>
595
- <SidebarMenuButton size="lg" href={siteUrl} isActive={currentPath === siteUrl || currentPath === "/"}>
717
+ <SidebarMenuButton size="lg" href={basePath || "/"} isActive={currentPath === basePath || currentPath === "/"}>
596
718
  <div class="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
597
719
  <Fragment set:html={siteIcon} />
598
720
  </div>
@@ -608,7 +730,24 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
608
730
  <SidebarSeparator />
609
731
 
610
732
  <SidebarContent>
611
- {navGroups ? (
733
+ {navMode === "client" ? (
734
+ // Client-render mode: emit one DocsNavClient placeholder
735
+ // inside a single SidebarGroup, regardless of navGroups.
736
+ // The group-label shape doesn't apply when the tree is
737
+ // hydrated by JS — multi-group nav is reconstructed
738
+ // client-side from the same /_dogsbay/nav.json shape.
739
+ // See plans/client-rendered-nav.md.
740
+ <SidebarGroup>
741
+ <SidebarGroupContent>
742
+ <DocsNavClient
743
+ currentPath={currentPath}
744
+ basePath={basePath ?? ""}
745
+ version={multiSource?.version}
746
+ locale={multiSource?.locale}
747
+ />
748
+ </SidebarGroupContent>
749
+ </SidebarGroup>
750
+ ) : navGroups ? (
612
751
  navGroups.map(group => (
613
752
  <SidebarGroup>
614
753
  <SidebarGroupLabel>{group.label}</SidebarGroupLabel>
@@ -650,6 +789,16 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
650
789
  <span class="text-sm text-muted-foreground" data-page-title>{title}</span>
651
790
  <div class="ml-auto flex items-center gap-2">
652
791
  <slot name="header" />
792
+ {tocPlacement.popoverToc && (
793
+ <details class="dba-toc-popover relative" data-toc-popover>
794
+ <summary class="cursor-pointer list-none rounded-md px-2 py-1.5 text-sm text-muted-foreground hover:bg-accent hover:text-foreground">
795
+ On this page
796
+ </summary>
797
+ <div class="absolute right-0 z-50 mt-1 max-h-[70vh] w-64 overflow-y-auto rounded-md border bg-background p-3 shadow-md">
798
+ <DocsToc headings={headings} title="" />
799
+ </div>
800
+ </details>
801
+ )}
653
802
  {switcherMap && multiSource && (
654
803
  <LocaleSwitcher
655
804
  switcherMap={switcherMap}
@@ -676,6 +825,7 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
676
825
  {!hideSearch && (
677
826
  <SearchDialog
678
827
  pagefindUrl={pagefindUrl}
828
+ navUrl={basePath ? `${basePath}/_dogsbay/nav.json` : "/_dogsbay/nav.json"}
679
829
  taxonomyDisplay={taxonomyDisplay}
680
830
  />
681
831
  )}
@@ -709,9 +859,30 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
709
859
  the actual page content. Filter elements live in here
710
860
  now to stay inside that scope.
711
861
  */}
712
- {Array.isArray(tags) && tags.map((tag) => (
713
- <div hidden data-pagefind-filter={`tag:${tag}`}></div>
714
- ))}
862
+ {/*
863
+ Slash-nested tags whose prefix is declared in
864
+ `taxonomies.tags.prefixes` emit as per-prefix filter
865
+ divs so each prefix becomes its own Pagefind facet
866
+ column (Difficulty, Topic, Persona, …) instead of
867
+ pooling under a single "Tag" column.
868
+
869
+ Plain tags and tags whose prefix isn't declared fall
870
+ back to the pooled `tag:` filter — backward-compatible
871
+ for sites that haven't declared prefixes.
872
+
873
+ See plans/per-prefix-search-facets.md.
874
+ */}
875
+ {Array.isArray(tags) && tags.map((tag) => {
876
+ const slash = tag.indexOf("/");
877
+ if (slash > 0) {
878
+ const prefix = tag.slice(0, slash);
879
+ const leaf = tag.slice(slash + 1);
880
+ if (tagPrefixes && tagPrefixes[prefix]) {
881
+ return <div hidden data-pagefind-filter={`${prefix}:${leaf}`}></div>;
882
+ }
883
+ }
884
+ return <div hidden data-pagefind-filter={`tag:${tag}`}></div>;
885
+ })}
715
886
  {Array.isArray(audience) && audience.map((value) => (
716
887
  <div hidden data-pagefind-filter={`audience:${value}`}></div>
717
888
  ))}
@@ -720,6 +891,20 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
720
891
  ))}
721
892
  {status && <div hidden data-pagefind-filter={`status:${status}`}></div>}
722
893
  {pageType && <div hidden data-pagefind-filter={`type:${pageType}`}></div>}
894
+ {/*
895
+ Custom-taxonomy filters. Any taxonomy declared in
896
+ `dogsbay.config.yml` that isn't one of the five built-ins
897
+ flows through here, so `difficulty: intermediate` (etc.)
898
+ becomes a real Pagefind facet checkbox automatically.
899
+ See plans/beta-launch-followups.md for context.
900
+ */}
901
+ {taxonomies && Object.entries(taxonomies).flatMap(([name, values]) =>
902
+ Array.isArray(values)
903
+ ? values.map((value) => (
904
+ <div hidden data-pagefind-filter={`${name}:${value}`}></div>
905
+ ))
906
+ : []
907
+ )}
723
908
 
724
909
  <div class:list={["mx-auto", wideLayout ? "max-w-7xl" : "max-w-3xl"]}>
725
910
  {/*
@@ -793,6 +978,17 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
793
978
  </div>
794
979
  )}
795
980
 
981
+ {tocPlacement.topToc && (
982
+ <details class="dba-toc-top not-prose mb-6 rounded-md border" data-toc-top>
983
+ <summary class="cursor-pointer list-none px-3 py-2 text-sm font-medium text-muted-foreground">
984
+ On this page
985
+ </summary>
986
+ <div class="border-t px-3 py-2">
987
+ <DocsToc headings={headings} title="" />
988
+ </div>
989
+ </details>
990
+ )}
991
+
796
992
  <slot />
797
993
 
798
994
  <DocsFooter
@@ -802,21 +998,46 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
802
998
  next={next}
803
999
  copyright={copyright}
804
1000
  llmsLink={llmFooterLink}
1001
+ llmsLinkHref={llmsLinkHrefResolved}
805
1002
  />
806
1003
  </div>
807
1004
  </main>
808
1005
 
809
- {headings.length > 0 && !wideLayout && (
1006
+ {/* Classic right-hand TOC only in `toc: rail` mode. */}
1007
+ {tocPlacement.railToc && (
810
1008
  <aside class="sticky top-12 hidden h-[calc(100vh-3rem)] w-56 shrink-0 overflow-y-auto border-l p-4 lg:block">
811
1009
  <DocsToc headings={headings} />
812
1010
  </aside>
813
1011
  )}
1012
+ {/* Right-rail plugin region — host for the `right-rail` named slot
1013
+ (e.g. an Ask AI panel). Rendered in every non-rail mode and not
1014
+ gated on headings, so a plugin can dock on heading-less pages.
1015
+ Hidden when nothing fills it (see the empty-rail style below). */}
1016
+ {tocPlacement.regionRail && (
1017
+ <aside
1018
+ class="dba-right-rail sticky top-12 hidden h-[calc(100vh-3rem)] w-80 shrink-0 overflow-y-auto border-l p-4 lg:block xl:w-96"
1019
+ data-right-rail
1020
+ >
1021
+ <slot name="right-rail" />
1022
+ </aside>
1023
+ )}
814
1024
  </div>
815
1025
  </SidebarInset>
816
1026
  </SidebarProvider>
817
1027
  </body>
818
1028
  </html>
819
1029
 
1030
+ <style>
1031
+ /* The right-rail plugin region renders unconditionally in non-rail TOC
1032
+ modes so plugins (e.g. Ask AI) can dock into it. Until something fills
1033
+ the `right-rail` slot it has no element children — hide it so an empty
1034
+ bordered column doesn't show. (:has matches element children only; an
1035
+ unfilled Astro named slot renders none.) */
1036
+ aside[data-right-rail]:not(:has(*)) {
1037
+ display: none;
1038
+ }
1039
+ </style>
1040
+
820
1041
  <script>
821
1042
  import "@dogsbay/ui/sidebar/sidebar.ts";
822
1043
 
@@ -0,0 +1,89 @@
1
+ ---
2
+ /**
3
+ * Client-rendered drop-in replacement for SidebarNavTree.
4
+ *
5
+ * Emits an empty placeholder with the metadata the hydration script
6
+ * needs (nav-url to fetch, current-path to highlight, basePath for
7
+ * version/locale filtering). The actual tree DOM is rendered by
8
+ * `docs-nav-client.ts` after a single fetch of `nav.json` (cached
9
+ * once per session — subsequent navigations re-highlight without
10
+ * re-fetching, courtesy of view transitions).
11
+ *
12
+ * The placeholder shows a few skeleton rows so the layout doesn't
13
+ * shift when the real tree pops in. Skeleton uses the same width as
14
+ * the sidebar so visual jump is minimal even on a slow connection.
15
+ *
16
+ * Trade-offs vs SidebarNavTree:
17
+ * - HTML per page: ~200 bytes (this placeholder + a tiny script
18
+ * tag) vs ~600 KB+ for the SSR tree at scale.
19
+ * - No-JS users see only the skeleton + the `<noscript>` fallback
20
+ * link. A `sitemap.xml` link covers no-JS navigation.
21
+ * - First paint waits for the JS bundle + the JSON fetch. On a 4G
22
+ * connection that's typically <200 ms; the skeleton fills the
23
+ * space until then.
24
+ *
25
+ * See plans/client-rendered-nav.md.
26
+ */
27
+ interface Props {
28
+ /** Current page's URL pathname; the script uses it to mark the active item. */
29
+ currentPath: string;
30
+ /**
31
+ * URL prefix the host serves under (combined `urlBase` + `basePath`).
32
+ * The script joins this with `/_dogsbay/nav.json` to locate the
33
+ * fetchable nav tree; also threaded to the version/locale filter
34
+ * so multi-axis sites work the same as SSR.
35
+ */
36
+ basePath?: string;
37
+ /** Current source's version axis value, if multi-version site. */
38
+ version?: string;
39
+ /** Current source's locale axis value, if multi-locale site. */
40
+ locale?: string;
41
+ }
42
+
43
+ const { currentPath, basePath = "", version, locale } = Astro.props;
44
+ const navUrl = `${basePath}/_dogsbay/nav.json`;
45
+ ---
46
+
47
+ <div
48
+ id="docs-nav-root"
49
+ data-nav-url={navUrl}
50
+ data-current-path={currentPath}
51
+ data-base-path={basePath}
52
+ data-version={version ?? ""}
53
+ data-locale={locale ?? ""}
54
+ aria-busy="true"
55
+ aria-label="Documentation navigation"
56
+ >
57
+ <ul class="flex min-w-0 flex-col" data-sidebar="nav-tree" data-level="0">
58
+ {[0, 1, 2, 3, 4, 5].map((i) => (
59
+ <li>
60
+ <div
61
+ class="flex h-8 w-full items-center gap-2 rounded-md px-2"
62
+ aria-hidden="true"
63
+ >
64
+ <div class="h-2 w-2 rounded-full bg-sidebar-foreground/10" />
65
+ <div
66
+ class="h-2 rounded bg-sidebar-foreground/10"
67
+ style={`width: ${50 + ((i * 17) % 35)}%;`}
68
+ />
69
+ </div>
70
+ </li>
71
+ ))}
72
+ </ul>
73
+ <noscript>
74
+ <p class="px-2 py-1.5 text-sm text-sidebar-foreground/70">
75
+ JavaScript is required to render the sidebar. Use the
76
+ <a href={`${basePath}/sitemap.xml`} class="underline">sitemap</a>
77
+ to browse all pages.
78
+ </p>
79
+ </noscript>
80
+ </div>
81
+
82
+ <script>
83
+ // Single import — Astro/Vite bundles this into one shared chunk
84
+ // referenced from every page that uses DocsNavClient. Browsers
85
+ // cache the chunk, so the nav script ships once per session
86
+ // regardless of how many pages the user visits.
87
+ import { hydrateDocsNav } from "./docs-nav-client.ts";
88
+ hydrateDocsNav();
89
+ </script>
package/src/DocsToc.astro CHANGED
@@ -33,7 +33,7 @@ const filtered = headings.filter(h => h.depth >= minDepth && h.depth <= maxDepth
33
33
 
34
34
  {filtered.length > 0 && (
35
35
  <nav class:list={["text-sm", className]} aria-label="Table of contents">
36
- <div class="text-xs font-semibold uppercase text-muted-foreground">{title}</div>
36
+ {title && <div class="text-xs font-semibold uppercase text-muted-foreground">{title}</div>}
37
37
  <ul class="mt-2 space-y-1">
38
38
  {filtered.map(h => (
39
39
  <li>