@dogsbay/docs-layout 0.2.0-beta.13 → 0.2.0-beta.130

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.
@@ -31,9 +31,12 @@ 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";
39
+ import { linkIconAttrs } from "./link-icons.js";
37
40
  import DocsFooter from "./DocsFooter.astro";
38
41
  import SearchDialog from "./SearchDialog.astro";
39
42
  import TagList from "./TagList.astro";
@@ -42,9 +45,9 @@ import TypeBadge from "./TypeBadge.astro";
42
45
  import PageActions from "./PageActions.astro";
43
46
  import VersionSwitcher from "./VersionSwitcher.astro";
44
47
  import LocaleSwitcher from "./LocaleSwitcher.astro";
45
- import { filterNavByAxis } from "./nav-filter.js";
48
+ import { filterNavByAxis, rootVersionFilter } from "./nav-filter.js";
46
49
  import type { LlmProviderName } from "./llm-actions.js";
47
- import { jsonLdTypeFor, normalizeCustomJsonLd } from "./json-ld.js";
50
+ import { jsonLdTypeFor, normalizeCustomJsonLd, buildArticleJsonLd, buildWebSiteJsonLd } from "./json-ld.js";
48
51
  import { resolveTagKeywords } from "./tag-list-data.js";
49
52
 
50
53
  interface NavItem {
@@ -86,6 +89,8 @@ type TagPaletteName =
86
89
  interface Props {
87
90
  /** Site name shown in header and sidebar logo */
88
91
  siteName: string;
92
+ /** The line under the site name in the sidebar header (`site.tagline`). Default "Documentation". */
93
+ siteTagline?: string;
89
94
  /** Page title for <title> tag */
90
95
  title: string;
91
96
  /** Home URL — also used as the base for the canonical link if absolute */
@@ -112,7 +117,13 @@ interface Props {
112
117
  siteDescription?: string;
113
118
  /** Copyright text (HTML allowed) */
114
119
  copyright?: string;
115
- /** Favicon path (default: "/favicon.ico"). Set to false to disable. */
120
+ /**
121
+ * Favicon path. No host-root default — that 404s on subpath
122
+ * deploys. The format-astro emitter passes a combined-prefix
123
+ * path (e.g. `/<repo>/favicon.ico`) computed from site.url +
124
+ * basePath. Pass an empty string or `false` to disable. When
125
+ * undefined, no `<link rel="icon">` is emitted.
126
+ */
116
127
  favicon?: string | false;
117
128
  /** Per-page OG image URL. Overrides defaultOgImage. */
118
129
  ogImage?: string;
@@ -122,21 +133,34 @@ interface Props {
122
133
  ogType?: "article" | "website";
123
134
  /** Canonical URL for this page. Auto-computed from siteUrl + pathname when absolute siteUrl is provided. */
124
135
  canonicalUrl?: string;
136
+ /** `hreflang` alternates for this page's copies in other languages (absolute URLs), written after the canonical link. */
137
+ alternates?: { hreflang: string; href: string }[];
138
+ /** JSON-LD `BreadcrumbList` for this page, written after its own JSON-LD block. */
139
+ breadcrumbJsonLd?: Record<string, unknown>;
125
140
  /** Twitter / X handle including the leading "@" */
126
141
  twitterHandle?: string;
127
142
  /** Theme color hint for browsers (hex string) */
128
143
  themeColor?: string;
129
144
  /**
130
- * Emit `<meta name="robots" content="noindex, nofollow">` when
145
+ * Emit `noindex` in the `<meta name="robots">` directive when
131
146
  * 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.
147
+ * indexing this page. Independent of `nofollow` — common pattern
148
+ * for tag / index pages is noindex + follow (don't list this
149
+ * page in results, but do crawl through to the real content).
150
+ *
151
+ * Has NO effect on in-site Pagefind search — for that, use
152
+ * `excludeFromSearch`. The two are independent: a page can be
153
+ * excluded from external SEs but still appear in Pagefind (e.g.
154
+ * duplicate / old content readers might still want to find when
155
+ * they're already on the site), or vice versa.
138
156
  */
139
157
  noindex?: boolean;
158
+ /**
159
+ * Emit `nofollow` in the `<meta name="robots">` directive when
160
+ * true. Tells crawlers not to follow outbound links from this
161
+ * page. Independent of `noindex`. Default false.
162
+ */
163
+ nofollow?: boolean;
140
164
  /**
141
165
  * Exclude this page from in-site Pagefind search results.
142
166
  *
@@ -179,6 +203,12 @@ interface Props {
179
203
  * a non-root base path (e.g. "/docs/pagefind/").
180
204
  */
181
205
  pagefindUrl?: string;
206
+ /**
207
+ * Base Pagefind prefixes result URLs with. Forwarded to SearchDialog;
208
+ * format-astro passes the right value for the deploy layout. See
209
+ * `pagefind-base-url.ts` for why no constant works.
210
+ */
211
+ pagefindBaseUrl?: string;
182
212
  /**
183
213
  * Whether the site emits per-page `.md` mirror endpoints. When true,
184
214
  * the page advertises `<link rel="alternate" type="text/markdown">`
@@ -213,6 +243,12 @@ interface Props {
213
243
  * `taxonomies.tags.labels`.
214
244
  */
215
245
  tagLabels?: Record<string, string>;
246
+ /**
247
+ * Color chips whose prefix has no color automatically (a stable
248
+ * palette color per first segment). Sourced from
249
+ * `taxonomies.tags.autoColor`.
250
+ */
251
+ tagAutoColor?: boolean;
216
252
  /**
217
253
  * Page lifecycle status. Renders a StatusBadge for non-stable
218
254
  * states. Drafts/deprecated get strong colors.
@@ -333,7 +369,7 @@ interface Props {
333
369
  * `@/data/switcherMap.json`.
334
370
  */
335
371
  switcherMap?: {
336
- versions: Array<{ id: string; label?: string; eol?: boolean; default?: boolean }>;
372
+ versions: Array<{ id: string; label?: string; eol?: boolean; default?: boolean; root?: boolean }>;
337
373
  byLogicalKey: Record<string, Record<string, string>>;
338
374
  };
339
375
  /**
@@ -341,6 +377,19 @@ interface Props {
341
377
  * (`<basePath>/<version>/`). Defaults to "/docs".
342
378
  */
343
379
  basePath?: string;
380
+ /**
381
+ * Sidebar navigation render mode.
382
+ *
383
+ * - `"client"` (default): emit the small `<DocsNavClient />` placeholder;
384
+ * the tree is hydrated from `/_dogsbay/nav.json` once per session.
385
+ * Page HTML shrinks dramatically at scale (~50 KB vs ~1.2 MB on a
386
+ * 2k-page site). No-JS users see a sitemap fallback link.
387
+ * - `"ssr-full"`: render the full nav tree into every page's HTML.
388
+ * Best for very small sites or strict no-JS / SEO contexts.
389
+ *
390
+ * See plans/client-rendered-nav.md.
391
+ */
392
+ navMode?: "client" | "ssr-full";
344
393
  /**
345
394
  * Per-page LLM action UI. When set and `enabled !== false`, renders
346
395
  * the PageActions cluster (Copy markdown + Open in Claude/ChatGPT/
@@ -375,16 +424,74 @@ interface Props {
375
424
  * in per-page.
376
425
  */
377
426
  wideLayout?: boolean;
427
+
428
+ /**
429
+ * Which chrome this page wears.
430
+ *
431
+ * `"docs"` (default) is the full documentation shell. `"blog"` drops
432
+ * the sidebar and its nav fetch, and turns on the byline row — the
433
+ * blog is the same SITE, not a second design system, so the header,
434
+ * footer, search, page actions, TOC and every theme token stay
435
+ * identical. See plans/blog-capability.md Phase 3.
436
+ */
437
+ chrome?: "docs" | "blog";
438
+
439
+ /** Post authors. No byline is rendered when absent — never a placeholder. */
440
+ author?: string[];
441
+ /** ISO publish date for the byline. */
442
+ publishedDate?: string;
443
+ /** ISO updated date; shown only when it differs from publishedDate. */
444
+ updatedDate?: string;
445
+ /**
446
+ * Reading estimate in whole minutes. Computed at EMIT time from the
447
+ * TreeNode word count — a browser-side count would re-walk the DOM on
448
+ * every load for a number that cannot change.
449
+ */
450
+ readingMinutes?: number;
451
+ /** Optional hero image URL, rendered above the prose. */
452
+ heroImage?: string;
453
+ /**
454
+ * Table-of-contents placement. Default `"top"`.
455
+ * - `"top"` — expandable "On this page" disclosure at the top of the
456
+ * article, identical on desktop and mobile; frees the right rail for the
457
+ * `right-rail` named slot (e.g. an Ask AI panel).
458
+ * - `"popover"` — an "On this page" dropdown in the header.
459
+ * - `"rail"` — the classic right-hand TOC sidebar (pre-`toc` behaviour).
460
+ * - `"off"` — no table of contents.
461
+ * See plans/ask-branch1-placement-toc.md.
462
+ */
463
+ toc?: TocMode;
464
+ /**
465
+ * Internal/external link-icon glyphs. When set, a small trailing icon
466
+ * marks links in the prose — external (absolute / protocol-relative
467
+ * href) vs internal (root / relative) — classified purely by href
468
+ * shape in CSS. Either side may be absent to mark only that link kind.
469
+ * Sourced from `content.linkIcons` in `dogsbay.config.yml`.
470
+ * See plans/link-resolution-and-icons.md.
471
+ */
472
+ linkIcons?: {
473
+ external?: string;
474
+ internal?: string;
475
+ };
476
+ /**
477
+ * "Powered by DogsBay" footer attribution. Generated sites pass
478
+ * `siteConfig.poweredBy`, which defaults to true and is turned off with
479
+ * `poweredBy: false` in dogsbay.config.yml. See docs/powered-by.md.
480
+ */
481
+ poweredBy?: boolean;
378
482
  class?: string;
379
483
  }
380
484
 
381
485
  const {
486
+ poweredBy = false,
382
487
  siteName,
488
+ siteTagline,
383
489
  title,
384
490
  siteUrl = "/",
385
491
  nav,
386
492
  navGroups,
387
493
  headings = [],
494
+ toc = "top",
388
495
  prev,
389
496
  next,
390
497
  repoUrl,
@@ -393,24 +500,35 @@ const {
393
500
  editUrl,
394
501
  lastUpdated,
395
502
  copyright,
396
- favicon = "/favicon.ico",
503
+ favicon,
397
504
  ogImage,
398
505
  defaultOgImage,
399
506
  ogType = "article",
400
507
  canonicalUrl,
508
+ alternates = [],
509
+ breadcrumbJsonLd,
401
510
  twitterHandle,
402
511
  themeColor,
403
512
  noindex,
513
+ nofollow,
404
514
  excludeFromSearch,
405
515
  plausibleDomain,
406
516
  plausibleScriptUrl,
407
517
  hideSearch = false,
408
- pagefindUrl = "/pagefind/",
518
+ // No default for pagefindUrl — host-root absolute paths break on
519
+ // subpath-mounted deploys (GH Pages project pages, multi-mount
520
+ // Cloudflare). format-astro's emitter always passes the
521
+ // combined-prefix-aware URL; manual instantiation must too.
522
+ // Undefined here propagates to SearchDialog where the JS loader
523
+ // throws on first open instead of silently 404'ing the bundle.
524
+ pagefindUrl,
525
+ pagefindBaseUrl,
409
526
  mdMirror = false,
410
527
  tags,
411
528
  tagsIndexPath = "/tags",
412
529
  tagPrefixes,
413
530
  tagLabels,
531
+ tagAutoColor = false,
414
532
  status,
415
533
  pageType,
416
534
  audience,
@@ -425,10 +543,28 @@ const {
425
543
  multiSource,
426
544
  switcherMap,
427
545
  basePath,
546
+ navMode = "client",
428
547
  wideLayout = false,
548
+ chrome = "docs",
549
+ author,
550
+ publishedDate,
551
+ updatedDate,
552
+ readingMinutes,
553
+ heroImage,
554
+ linkIcons,
429
555
  class: className,
430
556
  } = Astro.props;
431
557
 
558
+ // The version served at the site root (`path: ""`), if any: the sidebar
559
+ // keeps its pages by leaving out the other versions' folders.
560
+ const navRootVersion = rootVersionFilter(switcherMap?.versions);
561
+
562
+ // Link icons — classified in CSS by href shape. `data-link-icons`
563
+ // lists which kinds are active; the glyphs ride in CSS custom props
564
+ // (single-quoted so they're valid `content:` string values).
565
+ const { tokens: _linkIconTokens, style: _linkIconStyle } =
566
+ linkIconAttrs(linkIcons);
567
+
432
568
  // Resolve LLM action visibility + placement once. The component
433
569
  // guards against missing markdownBody / mdUrl internally, but we
434
570
  // also gate at the layout level so the slots stay empty when
@@ -446,6 +582,16 @@ const showLlmActionsInline =
446
582
  llmActionsEnabled
447
583
  && (llmActionsPlacement === "inline" || llmActionsPlacement === "both");
448
584
  const llmFooterLink = !!llmActions && llmActions.footerLink !== false;
585
+ // Per-mount llms.txt URL — Dogsbay emits `<basePath>/llms.txt`
586
+ // (sitemap-index pattern), so the footer link must be basePath-
587
+ // prefixed too. Falls back to `/llms.txt` when basePath is empty
588
+ // or unset, matching the platform's host-root single-site case.
589
+ const llmsLinkHrefResolved = (() => {
590
+ const bp = (basePath ?? "").replace(/\/+$/, "");
591
+ if (!bp) return "/llms.txt";
592
+ const prefix = bp.startsWith("/") ? bp : `/${bp}`;
593
+ return `${prefix}/llms.txt`;
594
+ })();
449
595
 
450
596
  // Compute href targets for the type / status badges. A field is
451
597
  // linkable only when (a) the user declared a `taxonomies.<field>`
@@ -465,20 +611,108 @@ const hasMetaStrip = (
465
611
  (typeof pageType === "string" && pageType.length > 0)
466
612
  );
467
613
 
614
+ // TOC placement: which container(s) + the right-rail plugin region render.
615
+ // Logic lives in toc-placement.ts so it's unit-tested; this file just consumes.
616
+ const tocPlacement = resolveTocPlacement(toc, {
617
+ // Only show a TOC when the page has 2+ displayable headings (depth 2–3);
618
+ // the H1 doesn't count and a one-item TOC is noise. Keeps "On this page"
619
+ // off landing/welcome pages that are just an H1 + prose.
620
+ hasHeadings: hasDisplayableToc(headings),
621
+ wideLayout: !!wideLayout,
622
+ });
623
+
468
624
  const currentPath = Astro.url.pathname.replace(/\/$/, "") || "/";
469
625
 
470
626
  // SEO computation
471
627
  const metaDescription = description ?? siteDescription;
472
628
  const metaOgImage = ogImage ?? defaultOgImage;
473
629
  const isAbsoluteSiteUrl = /^https?:\/\//.test(siteUrl);
630
+ // Compose canonical from ORIGIN + pathname. siteUrl may carry a
631
+ // path component (the urlBase that drives Astro's `base` — see
632
+ // plans/astro-base-from-site-url.md), and Astro.url.pathname
633
+ // already includes that prefix. Naively concatenating siteUrl +
634
+ // pathname double-counts the urlBase (e.g. .../repo/repo/page).
635
+ // Strip path off siteUrl by reparsing as a URL.
636
+ let canonicalOrigin: string | undefined;
637
+ if (isAbsoluteSiteUrl) {
638
+ try {
639
+ const u = new URL(siteUrl);
640
+ canonicalOrigin = `${u.protocol}//${u.host}`;
641
+ } catch {
642
+ // Malformed siteUrl — fall back to the original (no path) behavior.
643
+ canonicalOrigin = siteUrl.replace(/\/$/, "");
644
+ }
645
+ }
474
646
  const computedCanonical = canonicalUrl
475
- ?? (isAbsoluteSiteUrl
476
- ? siteUrl.replace(/\/$/, "") + Astro.url.pathname
647
+ ?? (canonicalOrigin
648
+ ? canonicalOrigin + Astro.url.pathname
477
649
  : undefined);
478
650
 
479
- // Markdown mirror — append `.md` to the current path for the alternate link
651
+ /**
652
+ * Format an ISO date for a byline.
653
+ *
654
+ * Runs at BUILD time, so the output is baked into the HTML and every
655
+ * reader sees the same string — no hydration flash, and no dependence on
656
+ * the visitor's locale for a date the author wrote. Falls back to the
657
+ * raw value if it will not parse, so a typo shows up as itself rather
658
+ * than as "Invalid Date".
659
+ */
660
+ /**
661
+ * A value as a machine-readable ISO 8601 datetime, or "" when it is
662
+ * not a date at all.
663
+ *
664
+ * Unlike `_formatPostDate`, an unparseable value must NOT pass
665
+ * through: this feeds `article:published_time` / `modified_time`,
666
+ * where a malformed datetime is worse than an absent one — a consumer
667
+ * either ignores the whole article object or shows garbage.
668
+ * `lastUpdated` is documented as also accepting a version/tag string
669
+ * ("v2.1"), which must never reach these tags.
670
+ */
671
+ function isoDate(value: string | undefined): string {
672
+ if (!value) return "";
673
+ const d = new Date(value);
674
+ return Number.isNaN(d.getTime()) ? "" : d.toISOString();
675
+ }
676
+
677
+ function _formatPostDate(iso: string): string {
678
+ const d = new Date(iso);
679
+ if (Number.isNaN(d.getTime())) return iso;
680
+ // timeZone: "UTC" is load-bearing. parseMeta normalizes `created:
681
+ // 2026-01-01` to "2026-01-01T00:00:00.000Z", so a build host in any
682
+ // negative-offset zone (the default on many CI runners) would render
683
+ // "31 December 2025". DocsFooter already carries this fix; these
684
+ // copies dropped it.
685
+ return d.toLocaleDateString("en-GB", {
686
+ year: "numeric",
687
+ month: "long",
688
+ day: "numeric",
689
+ timeZone: "UTC",
690
+ });
691
+ }
692
+
693
+ // Markdown mirror for the alternate link.
694
+ //
695
+ // Leaf pages append `.md` to the trimmed path (`/getting-started/` →
696
+ // `/getting-started.md`), matching the emitted `<slug>.md.ts` endpoint.
697
+ // The SITE INDEX is the exception: it is emitted as `index.md.ts`, so
698
+ // its mirror is `<base>/index.md`. Appending `.md` there produced
699
+ // `/.md` (root-served) or `/blog.md` (mounted) — neither exists, and on
700
+ // a mounted site `/blog.md` also falls outside the `/blog/*` route.
701
+ // Both leaf and index URLs carry a trailing slash under Astro's
702
+ // directory build format, so only "is this the site root" distinguishes
703
+ // them. Kept in step with `shouldRewriteToMarkdown`, which is handed the
704
+ // same combined prefix.
705
+ // Use the `basePath` PROP, not import.meta.env.BASE_URL. Astro's `base`
706
+ // carries only site.url's path (urlBase); the prop carries the COMBINED
707
+ // urlBase + site.basePath prefix — the same value the middleware gets —
708
+ // so on a `site.basePath: /docs` deploy the two would otherwise disagree
709
+ // about which page is the index.
710
+ const mdMirrorBase = (basePath ?? "").replace(/\/+$/, "");
711
+ const mdMirrorPath = Astro.url.pathname.replace(/\/$/, "");
480
712
  const mdMirrorHref = mdMirror
481
- ? (Astro.url.pathname.replace(/\/$/, "") || "") + ".md"
713
+ ? mdMirrorPath === mdMirrorBase
714
+ ? `${mdMirrorBase}/index.md`
715
+ : `${mdMirrorPath}.md`
482
716
  : undefined;
483
717
 
484
718
  // Tag keywords for HTML meta + JSON-LD. Slug-based identifiers
@@ -499,17 +733,30 @@ const tagKeywords = resolveTagKeywords(tags, tagLabels);
499
733
  // Course; everything else → Article. Matches the conventions
500
734
  // search engines key off for educational / tutorial / reference
501
735
  // SERP rendering. See `json-ld.ts` for the full mapping table.
502
- const articleJsonLd = (ogType === "article" && tagKeywords.length > 0)
503
- ? {
504
- "@context": "https://schema.org",
505
- "@type": jsonLdTypeFor(pageType),
506
- headline: title,
507
- keywords: tagKeywords.join(", "),
508
- ...(metaDescription ? { description: metaDescription } : {}),
509
- ...(metaOgImage ? { image: metaOgImage } : {}),
510
- ...(computedCanonical ? { url: computedCanonical } : {}),
511
- }
512
- : undefined;
736
+ // Structured data is emitted for EVERY page, not only tagged articles.
737
+ // This used to also require `tagKeywords.length > 0`, so an untagged
738
+ // page — a homepage, a landing page, a section index — emitted none at
739
+ // all. An external agent audit reported "No JSON-LD structured data
740
+ // found on homepage" and it was right: the page most likely to be an
741
+ // agent's entry point was the one describing itself the least.
742
+ // Keywords are enrichment, not a precondition for identity.
743
+ const articleJsonLd = ogType === "article"
744
+ ? buildArticleJsonLd({
745
+ type: jsonLdTypeFor(pageType),
746
+ title,
747
+ siteName,
748
+ keywords: tagKeywords,
749
+ description: metaDescription,
750
+ image: metaOgImage,
751
+ url: computedCanonical,
752
+ headings,
753
+ })
754
+ : buildWebSiteJsonLd({
755
+ siteName,
756
+ title,
757
+ description: metaDescription,
758
+ url: computedCanonical,
759
+ });
513
760
 
514
761
  // `customJsonLd` accepts either a single object or an array.
515
762
  // Normalize to an array so we can iterate uniformly. Empty array
@@ -527,12 +774,126 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
527
774
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
528
775
  <title>{title} | {siteName}</title>
529
776
 
777
+ <style is:global>
778
+ /*
779
+ The sticky-chrome contract. This header is exactly this tall, and
780
+ anything else that sticks (the release-comparison toolbar) offsets
781
+ by the same variable — so they cannot drift apart.
782
+ */
783
+ :root {
784
+ --db-header-height: 3rem;
785
+ }
786
+
787
+ /*
788
+ Diagrams drawn by Pele (`content.diagrams: pele`, plans/pele-diagrams.md).
789
+ Pele colours its SVGs from these variables, so a diagram takes the theme
790
+ and .dark switches them. Here rather than in Diagram.astro or global.css:
791
+ those are copied into a site once, this package is re-pinned on every
792
+ build. Borders are thin, so the theme's --border alone is too faint for
793
+ node outlines. The base mapping is the same as dogsbay-ssg's ssg.css.
794
+ */
795
+ .pele {
796
+ --pele-bg: var(--background);
797
+ --pele-fg: var(--foreground);
798
+ --pele-muted: var(--muted-foreground);
799
+ --pele-line: var(--foreground);
800
+ --pele-surface: var(--muted);
801
+ --pele-surface-alt: color-mix(in oklab, var(--muted), var(--foreground) 7%);
802
+ --pele-border: color-mix(in oklab, var(--border), var(--muted-foreground) 45%);
803
+ --pele-accent: var(--primary);
804
+ --pele-font: var(--font-sans);
805
+ --pele-font-mono: var(--font-code, monospace);
806
+ --pele-radius: calc(var(--radius) - 4px);
807
+ /*
808
+ Series colours (pie, xy, git, timeline, treemap, …; gantt's critical
809
+ tasks). Pele writes labels in --pele-bg on these fills, so they are
810
+ darker in light mode and lighter in dark mode. Its own palette is
811
+ fixed, and put white text on yellow (1.6:1). Same hues, in Pele's
812
+ order. dogsbay-ssg's ssg.css does not map these yet.
813
+ */
814
+ --pele-series-1: oklch(0.5 0.13 250);
815
+ --pele-series-2: oklch(0.5 0.15 55);
816
+ --pele-series-3: oklch(0.5 0.13 145);
817
+ --pele-series-4: oklch(0.5 0.16 25);
818
+ --pele-series-5: oklch(0.5 0.09 195);
819
+ --pele-series-6: oklch(0.5 0.12 95);
820
+ --pele-series-7: oklch(0.5 0.12 320);
821
+ --pele-series-8: oklch(0.5 0.06 60);
822
+ --pele-critical: var(--pele-series-4);
823
+ --pele-error: var(--pele-series-4);
824
+ }
825
+ .dark .pele {
826
+ --pele-series-1: oklch(0.78 0.13 250);
827
+ --pele-series-2: oklch(0.78 0.15 55);
828
+ --pele-series-3: oklch(0.78 0.13 145);
829
+ --pele-series-4: oklch(0.78 0.16 25);
830
+ --pele-series-5: oklch(0.78 0.09 195);
831
+ --pele-series-6: oklch(0.78 0.12 95);
832
+ --pele-series-7: oklch(0.78 0.12 320);
833
+ --pele-series-8: oklch(0.78 0.06 60);
834
+ }
835
+ /*
836
+ Diagram.astro inverts diagrams in dark mode, for Mermaid's fixed colours;
837
+ Pele's are already dark. Unlayered, so this wins over the dark:invert and
838
+ dark:hue-rotate utilities.
839
+ */
840
+ .diagram-content:has(> svg.pele),
841
+ .diagram-content > svg.pele {
842
+ filter: none;
843
+ }
844
+
845
+ /*
846
+ Internal/external link icons. Opt-in via `content.linkIcons`,
847
+ which sets `data-link-icons` (which kinds are active) and the
848
+ `--db-link-icon-*` custom props (the glyphs) on <body>. Links are
849
+ classified purely by href shape, so it covers both structured
850
+ links and raw-HTML anchors from imported content. Scoped to
851
+ `.docs-prose` so nav/header/footer links are untouched; buttons
852
+ (`<a data-variant>`, from Button.astro) carry no icon.
853
+ */
854
+ body[data-link-icons~="external"] .docs-prose a[href^="http://" i]:not([data-variant])::after,
855
+ body[data-link-icons~="external"] .docs-prose a[href^="https://" i]:not([data-variant])::after,
856
+ body[data-link-icons~="external"] .docs-prose a[href^="//"]:not([data-variant])::after {
857
+ content: var(--db-link-icon-external, "");
858
+ display: inline-block;
859
+ margin-inline-start: 0.15em;
860
+ font-size: 0.85em;
861
+ line-height: 1;
862
+ vertical-align: baseline;
863
+ opacity: 0.7;
864
+ }
865
+ body[data-link-icons~="internal"]
866
+ .docs-prose
867
+ a[href^="/"]:not([href^="//"]):not([data-variant])::after,
868
+ body[data-link-icons~="internal"] .docs-prose a[href^="./"]:not([data-variant])::after,
869
+ body[data-link-icons~="internal"] .docs-prose a[href^="../"]:not([data-variant])::after {
870
+ content: var(--db-link-icon-internal, "");
871
+ display: inline-block;
872
+ margin-inline-start: 0.15em;
873
+ font-size: 0.85em;
874
+ line-height: 1;
875
+ vertical-align: baseline;
876
+ opacity: 0.7;
877
+ }
878
+ </style>
879
+
530
880
  {metaDescription && <meta name="description" content={metaDescription} />}
531
- {favicon !== false && <link rel="icon" href={favicon} />}
881
+ {favicon && <link rel="icon" href={favicon} />}
532
882
  {themeColor && <meta name="theme-color" content={themeColor} />}
533
883
  {/* External search engine directive — orthogonal to in-site
534
- Pagefind exclusion. */}
535
- {noindex && <meta name="robots" content="noindex, nofollow" />}
884
+ Pagefind exclusion. `noindex` + `nofollow` are independent
885
+ bits per the meta-robots spec; emit only the directives that
886
+ are set. Combining them when both are set keeps the tag
887
+ compact (`<meta name="robots" content="noindex, nofollow">`)
888
+ instead of emitting two tags. */}
889
+ {(noindex || nofollow) && (
890
+ <meta
891
+ name="robots"
892
+ content={[noindex && "noindex", nofollow && "nofollow"]
893
+ .filter(Boolean)
894
+ .join(", ")}
895
+ />
896
+ )}
536
897
 
537
898
  {/* In-site Pagefind exclusion is wired via two coordinated
538
899
  attributes on <body> and <main> below — see the prop docs
@@ -546,7 +907,14 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
546
907
  sidebar / header / TOC text from per-page excerpts. See main. */}
547
908
 
548
909
  {computedCanonical && <link rel="canonical" href={computedCanonical} />}
910
+ {alternates.map((a) => <link rel="alternate" hreflang={a.hreflang} href={a.href} />)}
549
911
  {mdMirrorHref && <link rel="alternate" type="text/markdown" href={mdMirrorHref} />}
912
+ {/* Programmatic llms.txt discovery — agents that follow head
913
+ link rels get the per-mount llms.txt without parsing HTML.
914
+ Mirrors the existing _headers Link rel="describedby" used by
915
+ Cloudflare Pages / Workers. */}
916
+ <link rel="alternate" type="text/plain" title="llms.txt" href={llmsLinkHrefResolved} />
917
+ <link rel="describedby" type="text/plain" href={llmsLinkHrefResolved} />
550
918
 
551
919
  {/* Open Graph */}
552
920
  <meta property="og:type" content={ogType} />
@@ -561,13 +929,36 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
561
929
  {ogType === "article" && tagKeywords.map((keyword) => (
562
930
  <meta property="article:tag" content={keyword} />
563
931
  ))}
932
+ {/* Article dates. `og:type="article"` was declared with no times
933
+ at all, which is an incomplete article object — crawlers and
934
+ social cards had no publish date to show even though the page
935
+ renders one visibly.
936
+
937
+ `article:modified_time` falls back to `lastUpdated`, the prop
938
+ the docs chrome already uses for "Last updated", so a docs page
939
+ gets a modified time without the blog-only `updatedDate` being
940
+ set. Only emitted when the value parses as a date: a
941
+ frontmatter `lastUpdated: v2.1` is a version string, and a
942
+ malformed datetime is worse than an absent one. */}
943
+ {ogType === "article" && isoDate(publishedDate) && (
944
+ <meta property="article:published_time" content={isoDate(publishedDate)} />
945
+ )}
946
+ {ogType === "article" && isoDate(updatedDate ?? lastUpdated) && (
947
+ <meta
948
+ property="article:modified_time"
949
+ content={isoDate(updatedDate ?? lastUpdated)}
950
+ />
951
+ )}
564
952
 
565
953
  {/* JSON-LD primary block — Article / HowTo / TechArticle /
566
954
  Course depending on `pageType`. Emits keywords as a
567
955
  Schema.org keywords string; Google reads this for topical
568
956
  categorization in rich results. */}
569
957
  {articleJsonLd && (
570
- <script type="application/ld+json" set:html={JSON.stringify(articleJsonLd)} />
958
+ <script type="application/ld+json" set:html={JSON.stringify(articleJsonLd).replace(/</g, "\\u003c")} />
959
+ )}
960
+ {breadcrumbJsonLd && (
961
+ <script type="application/ld+json" set:html={JSON.stringify(breadcrumbJsonLd).replace(/</g, "\\u003c")} />
571
962
  )}
572
963
 
573
964
  {/* Per-page / per-site custom structured data. Layered on
@@ -594,29 +985,69 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
594
985
  ></script>
595
986
  )}
596
987
 
988
+ {/*
989
+ Theme bootstrap. Inline and BLOCKING on purpose — it must run
990
+ before first paint or the page flashes light before going dark.
991
+
992
+ An explicit choice wins in BOTH directions. Someone on a
993
+ dark-mode OS who deliberately picked light must stay light, so
994
+ "light" is checked rather than treated as "not dark". Only the
995
+ absence of a stored value falls through to the OS preference.
996
+
997
+ That fallback is the point of this block. Previously the only
998
+ condition was `=== "dark"`, so a visitor whose OS is in dark mode
999
+ got a light page until they found the toggle — and nothing else
1000
+ in the CSS consulted prefers-color-scheme either. It also made
1001
+ dark mode untestable by any tool that emulates the media query
1002
+ (DevTools' Rendering panel, Playwright's colorScheme), because
1003
+ the page did not read it.
1004
+
1005
+ localStorage access throws in some privacy modes; a throw here
1006
+ would skip the class entirely, so it is guarded and degrades to
1007
+ the OS preference.
1008
+ */}
597
1009
  <script is:inline>
598
- if (localStorage.getItem("theme") === "dark") {
599
- document.documentElement.classList.add("dark");
600
- }
1010
+ (function () {
1011
+ var stored = null;
1012
+ try {
1013
+ stored = localStorage.getItem("theme");
1014
+ } catch (e) {}
1015
+ var dark =
1016
+ stored === "dark" ||
1017
+ (stored !== "light" &&
1018
+ window.matchMedia("(prefers-color-scheme: dark)").matches);
1019
+ if (dark) document.documentElement.classList.add("dark");
1020
+ })();
601
1021
  </script>
602
1022
  <slot name="head" />
603
1023
  </head>
604
1024
  <body
605
1025
  class="bg-background text-foreground antialiased"
606
1026
  data-pagefind-ignore={excludeFromSearch ? "" : undefined}
1027
+ data-link-icons={_linkIconTokens || undefined}
1028
+ style={_linkIconStyle || undefined}
607
1029
  >
608
1030
  <SidebarProvider>
1031
+ {/*
1032
+ chrome="blog" drops the sidebar COLUMN only. SidebarProvider and
1033
+ SidebarInset stay, so the content column, header offset and
1034
+ sticky contract are byte-identical to a docs page — which is what
1035
+ makes the blog read as the same site rather than a lookalike.
1036
+ With no <Sidebar> sibling the inset's peer-* selectors simply do
1037
+ not match and it fills the width.
1038
+ */}
1039
+ {chrome === "docs" && (
609
1040
  <Sidebar collapsible="icon">
610
1041
  <SidebarHeader>
611
1042
  <SidebarMenu>
612
1043
  <SidebarMenuItem>
613
- <SidebarMenuButton size="lg" href={siteUrl} isActive={currentPath === siteUrl || currentPath === "/"}>
1044
+ <SidebarMenuButton size="lg" href={basePath || "/"} isActive={currentPath === basePath || currentPath === "/"}>
614
1045
  <div class="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
615
1046
  <Fragment set:html={siteIcon} />
616
1047
  </div>
617
1048
  <div class="grid flex-1 text-left text-sm leading-tight">
618
1049
  <span class="truncate font-semibold">{siteName}</span>
619
- <span class="truncate text-xs text-sidebar-foreground/70">Documentation</span>
1050
+ <span class="truncate text-xs text-sidebar-foreground/70">{siteTagline || "Documentation"}</span>
620
1051
  </div>
621
1052
  </SidebarMenuButton>
622
1053
  </SidebarMenuItem>
@@ -626,7 +1057,27 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
626
1057
  <SidebarSeparator />
627
1058
 
628
1059
  <SidebarContent>
629
- {navGroups ? (
1060
+ {navMode === "client" ? (
1061
+ // Client-render mode: emit one DocsNavClient placeholder
1062
+ // inside a single SidebarGroup, regardless of navGroups.
1063
+ // The group-label shape doesn't apply when the tree is
1064
+ // hydrated by JS — multi-group nav is reconstructed
1065
+ // client-side from the same /_dogsbay/nav.json shape.
1066
+ // See plans/client-rendered-nav.md.
1067
+ <SidebarGroup>
1068
+ <SidebarGroupContent>
1069
+ <DocsNavClient
1070
+ currentPath={currentPath}
1071
+ basePath={basePath ?? ""}
1072
+ namespace={multiSource?.namespace}
1073
+ version={multiSource?.version}
1074
+ locale={multiSource?.locale}
1075
+ rootVersion={navRootVersion.rootVersion}
1076
+ versionSegments={navRootVersion.versionSegments}
1077
+ />
1078
+ </SidebarGroupContent>
1079
+ </SidebarGroup>
1080
+ ) : navGroups ? (
630
1081
  navGroups.map(group => (
631
1082
  <SidebarGroup>
632
1083
  <SidebarGroupLabel>{group.label}</SidebarGroupLabel>
@@ -634,8 +1085,10 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
634
1085
  <SidebarNavTree
635
1086
  items={filterNavByAxis(group.items, {
636
1087
  basePath: basePath ?? "/docs",
1088
+ namespace: multiSource?.namespace,
637
1089
  version: multiSource?.version,
638
1090
  locale: multiSource?.locale,
1091
+ ...navRootVersion,
639
1092
  })}
640
1093
  currentPath={currentPath}
641
1094
  />
@@ -648,8 +1101,10 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
648
1101
  <SidebarNavTree
649
1102
  items={filterNavByAxis(nav, {
650
1103
  basePath: basePath ?? "/docs",
1104
+ namespace: multiSource?.namespace,
651
1105
  version: multiSource?.version,
652
1106
  locale: multiSource?.locale,
1107
+ ...navRootVersion,
653
1108
  })}
654
1109
  currentPath={currentPath}
655
1110
  />
@@ -660,14 +1115,31 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
660
1115
 
661
1116
  <SidebarRail />
662
1117
  </Sidebar>
1118
+ )}
663
1119
 
664
1120
  <SidebarInset>
665
- <header class="sticky top-0 z-40 flex h-12 shrink-0 items-center gap-2 border-b bg-background/95 px-4 backdrop-blur-sm supports-[backdrop-filter]:bg-background/60">
666
- <SidebarTrigger class="-ml-1" />
1121
+ {/*
1122
+ Height comes from `--db-header-height` (declared on :root in
1123
+ <head>) — the CONTRACT any other sticky chrome offsets by, e.g.
1124
+ the release-comparison toolbar. One variable, so a change here
1125
+ can never leave another sticky bar overlapping or floating.
1126
+ */}
1127
+ <header class="sticky top-0 z-40 flex h-[var(--db-header-height)] shrink-0 items-center gap-2 border-b bg-background/95 px-4 backdrop-blur-sm supports-[backdrop-filter]:bg-background/60">
1128
+ {chrome === "docs" && <SidebarTrigger class="-ml-1" />}
667
1129
  <Separator orientation="vertical" class="mr-2 h-4" />
668
1130
  <span class="text-sm text-muted-foreground" data-page-title>{title}</span>
669
1131
  <div class="ml-auto flex items-center gap-2">
670
1132
  <slot name="header" />
1133
+ {tocPlacement.popoverToc && (
1134
+ <details class="dba-toc-popover relative" data-toc-popover>
1135
+ <summary class="cursor-pointer list-none rounded-md px-2 py-1.5 text-sm text-muted-foreground hover:bg-accent hover:text-foreground">
1136
+ On this page
1137
+ </summary>
1138
+ <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">
1139
+ <DocsToc headings={headings} title="" />
1140
+ </div>
1141
+ </details>
1142
+ )}
671
1143
  {switcherMap && multiSource && (
672
1144
  <LocaleSwitcher
673
1145
  switcherMap={switcherMap}
@@ -694,7 +1166,11 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
694
1166
  {!hideSearch && (
695
1167
  <SearchDialog
696
1168
  pagefindUrl={pagefindUrl}
1169
+ pagefindBaseUrl={pagefindBaseUrl}
1170
+ navUrl={basePath ? `${basePath}/_dogsbay/nav.json` : "/_dogsbay/nav.json"}
697
1171
  taxonomyDisplay={taxonomyDisplay}
1172
+ scopeProduct={multiSource?.namespace}
1173
+ scopeVersion={multiSource?.version}
698
1174
  />
699
1175
  )}
700
1176
  {repoUrl && (
@@ -727,9 +1203,30 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
727
1203
  the actual page content. Filter elements live in here
728
1204
  now to stay inside that scope.
729
1205
  */}
730
- {Array.isArray(tags) && tags.map((tag) => (
731
- <div hidden data-pagefind-filter={`tag:${tag}`}></div>
732
- ))}
1206
+ {/*
1207
+ Slash-nested tags whose prefix is declared in
1208
+ `taxonomies.tags.prefixes` emit as per-prefix filter
1209
+ divs so each prefix becomes its own Pagefind facet
1210
+ column (Difficulty, Topic, Persona, …) instead of
1211
+ pooling under a single "Tag" column.
1212
+
1213
+ Plain tags and tags whose prefix isn't declared fall
1214
+ back to the pooled `tag:` filter — backward-compatible
1215
+ for sites that haven't declared prefixes.
1216
+
1217
+ See plans/per-prefix-search-facets.md.
1218
+ */}
1219
+ {Array.isArray(tags) && tags.map((tag) => {
1220
+ const slash = tag.indexOf("/");
1221
+ if (slash > 0) {
1222
+ const prefix = tag.slice(0, slash);
1223
+ const leaf = tag.slice(slash + 1);
1224
+ if (tagPrefixes && tagPrefixes[prefix]) {
1225
+ return <div hidden data-pagefind-filter={`${prefix}:${leaf}`}></div>;
1226
+ }
1227
+ }
1228
+ return <div hidden data-pagefind-filter={`tag:${tag}`}></div>;
1229
+ })}
733
1230
  {Array.isArray(audience) && audience.map((value) => (
734
1231
  <div hidden data-pagefind-filter={`audience:${value}`}></div>
735
1232
  ))}
@@ -738,6 +1235,18 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
738
1235
  ))}
739
1236
  {status && <div hidden data-pagefind-filter={`status:${status}`}></div>}
740
1237
  {pageType && <div hidden data-pagefind-filter={`type:${pageType}`}></div>}
1238
+ {/*
1239
+ Multi-source axis filters — so search on a versioned/multi-
1240
+ product site can be scoped to (or faceted by) the current
1241
+ product and version. `product` = namespace, `version` = the
1242
+ version segment. Only emitted when the axis is active.
1243
+ */}
1244
+ {multiSource?.namespace && (
1245
+ <div hidden data-pagefind-filter={`product:${multiSource.namespace}`}></div>
1246
+ )}
1247
+ {multiSource?.version && (
1248
+ <div hidden data-pagefind-filter={`version:${multiSource.version}`}></div>
1249
+ )}
741
1250
  {/*
742
1251
  Custom-taxonomy filters. Any taxonomy declared in
743
1252
  `dogsbay.config.yml` that isn't one of the five built-ins
@@ -791,12 +1300,62 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
791
1300
  {title}
792
1301
  </h1>
793
1302
  )}
1303
+ {/*
1304
+ Byline — blog chrome only, and only when there is
1305
+ something to say. An absent author means NO byline row,
1306
+ never "Unknown": a placeholder byline is worse than none,
1307
+ because it reads as data rather than as a gap.
1308
+
1309
+ Sits between the H1 and the lede so the reader gets
1310
+ attribution before the summary, which is the order a
1311
+ post is scanned in.
1312
+ */}
1313
+ {chrome === "blog" && (author?.length || publishedDate || readingMinutes) && (
1314
+ <div
1315
+ class="not-prose mb-6 flex flex-wrap items-center gap-x-2 gap-y-1 text-sm text-muted-foreground"
1316
+ data-post-byline
1317
+ data-pagefind-ignore
1318
+ >
1319
+ {author && author.length > 0 && (
1320
+ <span class="font-medium text-foreground">{author.join(", ")}</span>
1321
+ )}
1322
+ {author && author.length > 0 && publishedDate && (
1323
+ <span aria-hidden="true">&middot;</span>
1324
+ )}
1325
+ {publishedDate && (
1326
+ <time datetime={publishedDate}>{_formatPostDate(publishedDate)}</time>
1327
+ )}
1328
+ {publishedDate && readingMinutes && <span aria-hidden="true">&middot;</span>}
1329
+ {readingMinutes && <span>{readingMinutes} min read</span>}
1330
+ {updatedDate && updatedDate !== publishedDate && (
1331
+ <span>
1332
+ &middot; Updated <time datetime={updatedDate}>{_formatPostDate(updatedDate)}</time>
1333
+ </span>
1334
+ )}
1335
+ </div>
1336
+ )}
1337
+
794
1338
  {autoLede && description && (
795
1339
  <p class="text-lg text-muted-foreground mb-6">
796
1340
  {description}
797
1341
  </p>
798
1342
  )}
799
1343
 
1344
+ {/*
1345
+ Hero image. aspect-boxed on purpose — an unsized image
1346
+ above the fold is the classic layout-shift source, and it
1347
+ would shove the prose down after paint.
1348
+ */}
1349
+ {chrome === "blog" && heroImage && (
1350
+ <img
1351
+ src={heroImage}
1352
+ alt=""
1353
+ class="not-prose mb-8 w-full rounded-lg border border-border object-cover aspect-[2/1]"
1354
+ loading="eager"
1355
+ decoding="async"
1356
+ />
1357
+ )}
1358
+
800
1359
  {hasMetaStrip && (
801
1360
  <div
802
1361
  class="not-prose mb-6 flex flex-wrap items-center gap-2"
@@ -820,11 +1379,23 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
820
1379
  indexPath={tagsIndexPath}
821
1380
  prefixes={tagPrefixes}
822
1381
  labels={tagLabels}
1382
+ autoColor={tagAutoColor}
823
1383
  />
824
1384
  )}
825
1385
  </div>
826
1386
  )}
827
1387
 
1388
+ {tocPlacement.topToc && (
1389
+ <details class="dba-toc-top not-prose mb-6 rounded-md border" data-toc-top>
1390
+ <summary class="cursor-pointer list-none px-3 py-2 text-sm font-medium text-muted-foreground">
1391
+ On this page
1392
+ </summary>
1393
+ <div class="border-t px-3 py-2">
1394
+ <DocsToc headings={headings} title="" />
1395
+ </div>
1396
+ </details>
1397
+ )}
1398
+
828
1399
  <slot />
829
1400
 
830
1401
  <DocsFooter
@@ -832,23 +1403,57 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
832
1403
  lastUpdated={lastUpdated}
833
1404
  prev={prev}
834
1405
  next={next}
1406
+ {/*
1407
+ Blog adjacency is chronological, so "Previous/Next"
1408
+ describes the wrong axis. Left is older, right is newer —
1409
+ a timeline runs that way, and so does a series: the
1410
+ right-hand link is the next part.
1411
+ */}
1412
+ prevLabel={chrome === "blog" ? "Older" : undefined}
1413
+ nextLabel={chrome === "blog" ? "Newer" : undefined}
835
1414
  copyright={copyright}
836
1415
  llmsLink={llmFooterLink}
1416
+ llmsLinkHref={llmsLinkHrefResolved}
1417
+ poweredBy={poweredBy}
837
1418
  />
838
1419
  </div>
839
1420
  </main>
840
1421
 
841
- {headings.length > 0 && !wideLayout && (
1422
+ {/* Classic right-hand TOC — only in `toc: rail` mode. */}
1423
+ {tocPlacement.railToc && (
842
1424
  <aside class="sticky top-12 hidden h-[calc(100vh-3rem)] w-56 shrink-0 overflow-y-auto border-l p-4 lg:block">
843
1425
  <DocsToc headings={headings} />
844
1426
  </aside>
845
1427
  )}
1428
+ {/* Right-rail plugin region — host for the `right-rail` named slot
1429
+ (e.g. an Ask AI panel). Rendered in every non-rail mode and not
1430
+ gated on headings, so a plugin can dock on heading-less pages.
1431
+ Hidden when nothing fills it (see the empty-rail style below). */}
1432
+ {tocPlacement.regionRail && (
1433
+ <aside
1434
+ 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"
1435
+ data-right-rail
1436
+ >
1437
+ <slot name="right-rail" />
1438
+ </aside>
1439
+ )}
846
1440
  </div>
847
1441
  </SidebarInset>
848
1442
  </SidebarProvider>
849
1443
  </body>
850
1444
  </html>
851
1445
 
1446
+ <style>
1447
+ /* The right-rail plugin region renders unconditionally in non-rail TOC
1448
+ modes so plugins (e.g. Ask AI) can dock into it. Until something fills
1449
+ the `right-rail` slot it has no element children — hide it so an empty
1450
+ bordered column doesn't show. (:has matches element children only; an
1451
+ unfilled Astro named slot renders none.) */
1452
+ aside[data-right-rail]:not(:has(*)) {
1453
+ display: none;
1454
+ }
1455
+ </style>
1456
+
852
1457
  <script>
853
1458
  import "@dogsbay/ui/sidebar/sidebar.ts";
854
1459