@dogsbay/docs-layout 0.2.0-beta.10 → 0.2.0-beta.100

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";
@@ -44,7 +47,7 @@ import VersionSwitcher from "./VersionSwitcher.astro";
44
47
  import LocaleSwitcher from "./LocaleSwitcher.astro";
45
48
  import { filterNavByAxis } 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 {
@@ -112,7 +115,13 @@ interface Props {
112
115
  siteDescription?: string;
113
116
  /** Copyright text (HTML allowed) */
114
117
  copyright?: string;
115
- /** Favicon path (default: "/favicon.ico"). Set to false to disable. */
118
+ /**
119
+ * Favicon path. No host-root default — that 404s on subpath
120
+ * deploys. The format-astro emitter passes a combined-prefix
121
+ * path (e.g. `/<repo>/favicon.ico`) computed from site.url +
122
+ * basePath. Pass an empty string or `false` to disable. When
123
+ * undefined, no `<link rel="icon">` is emitted.
124
+ */
116
125
  favicon?: string | false;
117
126
  /** Per-page OG image URL. Overrides defaultOgImage. */
118
127
  ogImage?: string;
@@ -127,16 +136,25 @@ interface Props {
127
136
  /** Theme color hint for browsers (hex string) */
128
137
  themeColor?: string;
129
138
  /**
130
- * Emit `<meta name="robots" content="noindex, nofollow">` when
139
+ * Emit `noindex` in the `<meta name="robots">` directive when
131
140
  * 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.
141
+ * indexing this page. Independent of `nofollow` common pattern
142
+ * for tag / index pages is noindex + follow (don't list this
143
+ * page in results, but do crawl through to the real content).
144
+ *
145
+ * Has NO effect on in-site Pagefind search for that, use
146
+ * `excludeFromSearch`. The two are independent: a page can be
147
+ * excluded from external SEs but still appear in Pagefind (e.g.
148
+ * duplicate / old content readers might still want to find when
149
+ * they're already on the site), or vice versa.
138
150
  */
139
151
  noindex?: boolean;
152
+ /**
153
+ * Emit `nofollow` in the `<meta name="robots">` directive when
154
+ * true. Tells crawlers not to follow outbound links from this
155
+ * page. Independent of `noindex`. Default false.
156
+ */
157
+ nofollow?: boolean;
140
158
  /**
141
159
  * Exclude this page from in-site Pagefind search results.
142
160
  *
@@ -341,6 +359,19 @@ interface Props {
341
359
  * (`<basePath>/<version>/`). Defaults to "/docs".
342
360
  */
343
361
  basePath?: string;
362
+ /**
363
+ * Sidebar navigation render mode.
364
+ *
365
+ * - `"client"` (default): emit the small `<DocsNavClient />` placeholder;
366
+ * the tree is hydrated from `/_dogsbay/nav.json` once per session.
367
+ * Page HTML shrinks dramatically at scale (~50 KB vs ~1.2 MB on a
368
+ * 2k-page site). No-JS users see a sitemap fallback link.
369
+ * - `"ssr-full"`: render the full nav tree into every page's HTML.
370
+ * Best for very small sites or strict no-JS / SEO contexts.
371
+ *
372
+ * See plans/client-rendered-nav.md.
373
+ */
374
+ navMode?: "client" | "ssr-full";
344
375
  /**
345
376
  * Per-page LLM action UI. When set and `enabled !== false`, renders
346
377
  * the PageActions cluster (Copy markdown + Open in Claude/ChatGPT/
@@ -375,6 +406,55 @@ interface Props {
375
406
  * in per-page.
376
407
  */
377
408
  wideLayout?: boolean;
409
+
410
+ /**
411
+ * Which chrome this page wears.
412
+ *
413
+ * `"docs"` (default) is the full documentation shell. `"blog"` drops
414
+ * the sidebar and its nav fetch, and turns on the byline row — the
415
+ * blog is the same SITE, not a second design system, so the header,
416
+ * footer, search, page actions, TOC and every theme token stay
417
+ * identical. See plans/blog-capability.md Phase 3.
418
+ */
419
+ chrome?: "docs" | "blog";
420
+
421
+ /** Post authors. No byline is rendered when absent — never a placeholder. */
422
+ author?: string[];
423
+ /** ISO publish date for the byline. */
424
+ publishedDate?: string;
425
+ /** ISO updated date; shown only when it differs from publishedDate. */
426
+ updatedDate?: string;
427
+ /**
428
+ * Reading estimate in whole minutes. Computed at EMIT time from the
429
+ * TreeNode word count — a browser-side count would re-walk the DOM on
430
+ * every load for a number that cannot change.
431
+ */
432
+ readingMinutes?: number;
433
+ /** Optional hero image URL, rendered above the prose. */
434
+ heroImage?: string;
435
+ /**
436
+ * Table-of-contents placement. Default `"top"`.
437
+ * - `"top"` — expandable "On this page" disclosure at the top of the
438
+ * article, identical on desktop and mobile; frees the right rail for the
439
+ * `right-rail` named slot (e.g. an Ask AI panel).
440
+ * - `"popover"` — an "On this page" dropdown in the header.
441
+ * - `"rail"` — the classic right-hand TOC sidebar (pre-`toc` behaviour).
442
+ * - `"off"` — no table of contents.
443
+ * See plans/ask-branch1-placement-toc.md.
444
+ */
445
+ toc?: TocMode;
446
+ /**
447
+ * Internal/external link-icon glyphs. When set, a small trailing icon
448
+ * marks links in the prose — external (absolute / protocol-relative
449
+ * href) vs internal (root / relative) — classified purely by href
450
+ * shape in CSS. Either side may be absent to mark only that link kind.
451
+ * Sourced from `content.linkIcons` in `dogsbay.config.yml`.
452
+ * See plans/link-resolution-and-icons.md.
453
+ */
454
+ linkIcons?: {
455
+ external?: string;
456
+ internal?: string;
457
+ };
378
458
  class?: string;
379
459
  }
380
460
 
@@ -385,6 +465,7 @@ const {
385
465
  nav,
386
466
  navGroups,
387
467
  headings = [],
468
+ toc = "top",
388
469
  prev,
389
470
  next,
390
471
  repoUrl,
@@ -393,7 +474,7 @@ const {
393
474
  editUrl,
394
475
  lastUpdated,
395
476
  copyright,
396
- favicon = "/favicon.ico",
477
+ favicon,
397
478
  ogImage,
398
479
  defaultOgImage,
399
480
  ogType = "article",
@@ -401,11 +482,18 @@ const {
401
482
  twitterHandle,
402
483
  themeColor,
403
484
  noindex,
485
+ nofollow,
404
486
  excludeFromSearch,
405
487
  plausibleDomain,
406
488
  plausibleScriptUrl,
407
489
  hideSearch = false,
408
- pagefindUrl = "/pagefind/",
490
+ // No default for pagefindUrl host-root absolute paths break on
491
+ // subpath-mounted deploys (GH Pages project pages, multi-mount
492
+ // Cloudflare). format-astro's emitter always passes the
493
+ // combined-prefix-aware URL; manual instantiation must too.
494
+ // Undefined here propagates to SearchDialog where the JS loader
495
+ // throws on first open instead of silently 404'ing the bundle.
496
+ pagefindUrl,
409
497
  mdMirror = false,
410
498
  tags,
411
499
  tagsIndexPath = "/tags",
@@ -425,10 +513,24 @@ const {
425
513
  multiSource,
426
514
  switcherMap,
427
515
  basePath,
516
+ navMode = "client",
428
517
  wideLayout = false,
518
+ chrome = "docs",
519
+ author,
520
+ publishedDate,
521
+ updatedDate,
522
+ readingMinutes,
523
+ heroImage,
524
+ linkIcons,
429
525
  class: className,
430
526
  } = Astro.props;
431
527
 
528
+ // Link icons — classified in CSS by href shape. `data-link-icons`
529
+ // lists which kinds are active; the glyphs ride in CSS custom props
530
+ // (single-quoted so they're valid `content:` string values).
531
+ const { tokens: _linkIconTokens, style: _linkIconStyle } =
532
+ linkIconAttrs(linkIcons);
533
+
432
534
  // Resolve LLM action visibility + placement once. The component
433
535
  // guards against missing markdownBody / mdUrl internally, but we
434
536
  // also gate at the layout level so the slots stay empty when
@@ -446,6 +548,16 @@ const showLlmActionsInline =
446
548
  llmActionsEnabled
447
549
  && (llmActionsPlacement === "inline" || llmActionsPlacement === "both");
448
550
  const llmFooterLink = !!llmActions && llmActions.footerLink !== false;
551
+ // Per-mount llms.txt URL — Dogsbay emits `<basePath>/llms.txt`
552
+ // (sitemap-index pattern), so the footer link must be basePath-
553
+ // prefixed too. Falls back to `/llms.txt` when basePath is empty
554
+ // or unset, matching the platform's host-root single-site case.
555
+ const llmsLinkHrefResolved = (() => {
556
+ const bp = (basePath ?? "").replace(/\/+$/, "");
557
+ if (!bp) return "/llms.txt";
558
+ const prefix = bp.startsWith("/") ? bp : `/${bp}`;
559
+ return `${prefix}/llms.txt`;
560
+ })();
449
561
 
450
562
  // Compute href targets for the type / status badges. A field is
451
563
  // linkable only when (a) the user declared a `taxonomies.<field>`
@@ -465,20 +577,108 @@ const hasMetaStrip = (
465
577
  (typeof pageType === "string" && pageType.length > 0)
466
578
  );
467
579
 
580
+ // TOC placement: which container(s) + the right-rail plugin region render.
581
+ // Logic lives in toc-placement.ts so it's unit-tested; this file just consumes.
582
+ const tocPlacement = resolveTocPlacement(toc, {
583
+ // Only show a TOC when the page has 2+ displayable headings (depth 2–3);
584
+ // the H1 doesn't count and a one-item TOC is noise. Keeps "On this page"
585
+ // off landing/welcome pages that are just an H1 + prose.
586
+ hasHeadings: hasDisplayableToc(headings),
587
+ wideLayout: !!wideLayout,
588
+ });
589
+
468
590
  const currentPath = Astro.url.pathname.replace(/\/$/, "") || "/";
469
591
 
470
592
  // SEO computation
471
593
  const metaDescription = description ?? siteDescription;
472
594
  const metaOgImage = ogImage ?? defaultOgImage;
473
595
  const isAbsoluteSiteUrl = /^https?:\/\//.test(siteUrl);
596
+ // Compose canonical from ORIGIN + pathname. siteUrl may carry a
597
+ // path component (the urlBase that drives Astro's `base` — see
598
+ // plans/astro-base-from-site-url.md), and Astro.url.pathname
599
+ // already includes that prefix. Naively concatenating siteUrl +
600
+ // pathname double-counts the urlBase (e.g. .../repo/repo/page).
601
+ // Strip path off siteUrl by reparsing as a URL.
602
+ let canonicalOrigin: string | undefined;
603
+ if (isAbsoluteSiteUrl) {
604
+ try {
605
+ const u = new URL(siteUrl);
606
+ canonicalOrigin = `${u.protocol}//${u.host}`;
607
+ } catch {
608
+ // Malformed siteUrl — fall back to the original (no path) behavior.
609
+ canonicalOrigin = siteUrl.replace(/\/$/, "");
610
+ }
611
+ }
474
612
  const computedCanonical = canonicalUrl
475
- ?? (isAbsoluteSiteUrl
476
- ? siteUrl.replace(/\/$/, "") + Astro.url.pathname
613
+ ?? (canonicalOrigin
614
+ ? canonicalOrigin + Astro.url.pathname
477
615
  : undefined);
478
616
 
479
- // Markdown mirror — append `.md` to the current path for the alternate link
617
+ /**
618
+ * Format an ISO date for a byline.
619
+ *
620
+ * Runs at BUILD time, so the output is baked into the HTML and every
621
+ * reader sees the same string — no hydration flash, and no dependence on
622
+ * the visitor's locale for a date the author wrote. Falls back to the
623
+ * raw value if it will not parse, so a typo shows up as itself rather
624
+ * than as "Invalid Date".
625
+ */
626
+ /**
627
+ * A value as a machine-readable ISO 8601 datetime, or "" when it is
628
+ * not a date at all.
629
+ *
630
+ * Unlike `_formatPostDate`, an unparseable value must NOT pass
631
+ * through: this feeds `article:published_time` / `modified_time`,
632
+ * where a malformed datetime is worse than an absent one — a consumer
633
+ * either ignores the whole article object or shows garbage.
634
+ * `lastUpdated` is documented as also accepting a version/tag string
635
+ * ("v2.1"), which must never reach these tags.
636
+ */
637
+ function isoDate(value: string | undefined): string {
638
+ if (!value) return "";
639
+ const d = new Date(value);
640
+ return Number.isNaN(d.getTime()) ? "" : d.toISOString();
641
+ }
642
+
643
+ function _formatPostDate(iso: string): string {
644
+ const d = new Date(iso);
645
+ if (Number.isNaN(d.getTime())) return iso;
646
+ // timeZone: "UTC" is load-bearing. parseMeta normalizes `created:
647
+ // 2026-01-01` to "2026-01-01T00:00:00.000Z", so a build host in any
648
+ // negative-offset zone (the default on many CI runners) would render
649
+ // "31 December 2025". DocsFooter already carries this fix; these
650
+ // copies dropped it.
651
+ return d.toLocaleDateString("en-GB", {
652
+ year: "numeric",
653
+ month: "long",
654
+ day: "numeric",
655
+ timeZone: "UTC",
656
+ });
657
+ }
658
+
659
+ // Markdown mirror for the alternate link.
660
+ //
661
+ // Leaf pages append `.md` to the trimmed path (`/getting-started/` →
662
+ // `/getting-started.md`), matching the emitted `<slug>.md.ts` endpoint.
663
+ // The SITE INDEX is the exception: it is emitted as `index.md.ts`, so
664
+ // its mirror is `<base>/index.md`. Appending `.md` there produced
665
+ // `/.md` (root-served) or `/blog.md` (mounted) — neither exists, and on
666
+ // a mounted site `/blog.md` also falls outside the `/blog/*` route.
667
+ // Both leaf and index URLs carry a trailing slash under Astro's
668
+ // directory build format, so only "is this the site root" distinguishes
669
+ // them. Kept in step with `shouldRewriteToMarkdown`, which is handed the
670
+ // same combined prefix.
671
+ // Use the `basePath` PROP, not import.meta.env.BASE_URL. Astro's `base`
672
+ // carries only site.url's path (urlBase); the prop carries the COMBINED
673
+ // urlBase + site.basePath prefix — the same value the middleware gets —
674
+ // so on a `site.basePath: /docs` deploy the two would otherwise disagree
675
+ // about which page is the index.
676
+ const mdMirrorBase = (basePath ?? "").replace(/\/+$/, "");
677
+ const mdMirrorPath = Astro.url.pathname.replace(/\/$/, "");
480
678
  const mdMirrorHref = mdMirror
481
- ? (Astro.url.pathname.replace(/\/$/, "") || "") + ".md"
679
+ ? mdMirrorPath === mdMirrorBase
680
+ ? `${mdMirrorBase}/index.md`
681
+ : `${mdMirrorPath}.md`
482
682
  : undefined;
483
683
 
484
684
  // Tag keywords for HTML meta + JSON-LD. Slug-based identifiers
@@ -499,17 +699,30 @@ const tagKeywords = resolveTagKeywords(tags, tagLabels);
499
699
  // Course; everything else → Article. Matches the conventions
500
700
  // search engines key off for educational / tutorial / reference
501
701
  // 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;
702
+ // Structured data is emitted for EVERY page, not only tagged articles.
703
+ // This used to also require `tagKeywords.length > 0`, so an untagged
704
+ // page — a homepage, a landing page, a section index — emitted none at
705
+ // all. An external agent audit reported "No JSON-LD structured data
706
+ // found on homepage" and it was right: the page most likely to be an
707
+ // agent's entry point was the one describing itself the least.
708
+ // Keywords are enrichment, not a precondition for identity.
709
+ const articleJsonLd = ogType === "article"
710
+ ? buildArticleJsonLd({
711
+ type: jsonLdTypeFor(pageType),
712
+ title,
713
+ siteName,
714
+ keywords: tagKeywords,
715
+ description: metaDescription,
716
+ image: metaOgImage,
717
+ url: computedCanonical,
718
+ headings,
719
+ })
720
+ : buildWebSiteJsonLd({
721
+ siteName,
722
+ title,
723
+ description: metaDescription,
724
+ url: computedCanonical,
725
+ });
513
726
 
514
727
  // `customJsonLd` accepts either a single object or an array.
515
728
  // Normalize to an array so we can iterate uniformly. Empty array
@@ -527,12 +740,67 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
527
740
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
528
741
  <title>{title} | {siteName}</title>
529
742
 
743
+ <style is:global>
744
+ /*
745
+ The sticky-chrome contract. This header is exactly this tall, and
746
+ anything else that sticks (the release-comparison toolbar) offsets
747
+ by the same variable — so they cannot drift apart.
748
+ */
749
+ :root {
750
+ --db-header-height: 3rem;
751
+ }
752
+
753
+ /*
754
+ Internal/external link icons. Opt-in via `content.linkIcons`,
755
+ which sets `data-link-icons` (which kinds are active) and the
756
+ `--db-link-icon-*` custom props (the glyphs) on <body>. Links are
757
+ classified purely by href shape, so it covers both structured
758
+ links and raw-HTML anchors from imported content. Scoped to
759
+ `.docs-prose` so nav/header/footer links are untouched.
760
+ */
761
+ body[data-link-icons~="external"] .docs-prose a[href^="http://" i]::after,
762
+ body[data-link-icons~="external"] .docs-prose a[href^="https://" i]::after,
763
+ body[data-link-icons~="external"] .docs-prose a[href^="//"]::after {
764
+ content: var(--db-link-icon-external, "");
765
+ display: inline-block;
766
+ margin-inline-start: 0.15em;
767
+ font-size: 0.85em;
768
+ line-height: 1;
769
+ vertical-align: baseline;
770
+ opacity: 0.7;
771
+ }
772
+ body[data-link-icons~="internal"]
773
+ .docs-prose
774
+ a[href^="/"]:not([href^="//"])::after,
775
+ body[data-link-icons~="internal"] .docs-prose a[href^="./"]::after,
776
+ body[data-link-icons~="internal"] .docs-prose a[href^="../"]::after {
777
+ content: var(--db-link-icon-internal, "");
778
+ display: inline-block;
779
+ margin-inline-start: 0.15em;
780
+ font-size: 0.85em;
781
+ line-height: 1;
782
+ vertical-align: baseline;
783
+ opacity: 0.7;
784
+ }
785
+ </style>
786
+
530
787
  {metaDescription && <meta name="description" content={metaDescription} />}
531
- {favicon !== false && <link rel="icon" href={favicon} />}
788
+ {favicon && <link rel="icon" href={favicon} />}
532
789
  {themeColor && <meta name="theme-color" content={themeColor} />}
533
790
  {/* External search engine directive — orthogonal to in-site
534
- Pagefind exclusion. */}
535
- {noindex && <meta name="robots" content="noindex, nofollow" />}
791
+ Pagefind exclusion. `noindex` + `nofollow` are independent
792
+ bits per the meta-robots spec; emit only the directives that
793
+ are set. Combining them when both are set keeps the tag
794
+ compact (`<meta name="robots" content="noindex, nofollow">`)
795
+ instead of emitting two tags. */}
796
+ {(noindex || nofollow) && (
797
+ <meta
798
+ name="robots"
799
+ content={[noindex && "noindex", nofollow && "nofollow"]
800
+ .filter(Boolean)
801
+ .join(", ")}
802
+ />
803
+ )}
536
804
 
537
805
  {/* In-site Pagefind exclusion is wired via two coordinated
538
806
  attributes on <body> and <main> below — see the prop docs
@@ -547,6 +815,12 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
547
815
 
548
816
  {computedCanonical && <link rel="canonical" href={computedCanonical} />}
549
817
  {mdMirrorHref && <link rel="alternate" type="text/markdown" href={mdMirrorHref} />}
818
+ {/* Programmatic llms.txt discovery — agents that follow head
819
+ link rels get the per-mount llms.txt without parsing HTML.
820
+ Mirrors the existing _headers Link rel="describedby" used by
821
+ Cloudflare Pages / Workers. */}
822
+ <link rel="alternate" type="text/plain" title="llms.txt" href={llmsLinkHrefResolved} />
823
+ <link rel="describedby" type="text/plain" href={llmsLinkHrefResolved} />
550
824
 
551
825
  {/* Open Graph */}
552
826
  <meta property="og:type" content={ogType} />
@@ -561,6 +835,26 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
561
835
  {ogType === "article" && tagKeywords.map((keyword) => (
562
836
  <meta property="article:tag" content={keyword} />
563
837
  ))}
838
+ {/* Article dates. `og:type="article"` was declared with no times
839
+ at all, which is an incomplete article object — crawlers and
840
+ social cards had no publish date to show even though the page
841
+ renders one visibly.
842
+
843
+ `article:modified_time` falls back to `lastUpdated`, the prop
844
+ the docs chrome already uses for "Last updated", so a docs page
845
+ gets a modified time without the blog-only `updatedDate` being
846
+ set. Only emitted when the value parses as a date: a
847
+ frontmatter `lastUpdated: v2.1` is a version string, and a
848
+ malformed datetime is worse than an absent one. */}
849
+ {ogType === "article" && isoDate(publishedDate) && (
850
+ <meta property="article:published_time" content={isoDate(publishedDate)} />
851
+ )}
852
+ {ogType === "article" && isoDate(updatedDate ?? lastUpdated) && (
853
+ <meta
854
+ property="article:modified_time"
855
+ content={isoDate(updatedDate ?? lastUpdated)}
856
+ />
857
+ )}
564
858
 
565
859
  {/* JSON-LD primary block — Article / HowTo / TechArticle /
566
860
  Course depending on `pageType`. Emits keywords as a
@@ -594,23 +888,63 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
594
888
  ></script>
595
889
  )}
596
890
 
891
+ {/*
892
+ Theme bootstrap. Inline and BLOCKING on purpose — it must run
893
+ before first paint or the page flashes light before going dark.
894
+
895
+ An explicit choice wins in BOTH directions. Someone on a
896
+ dark-mode OS who deliberately picked light must stay light, so
897
+ "light" is checked rather than treated as "not dark". Only the
898
+ absence of a stored value falls through to the OS preference.
899
+
900
+ That fallback is the point of this block. Previously the only
901
+ condition was `=== "dark"`, so a visitor whose OS is in dark mode
902
+ got a light page until they found the toggle — and nothing else
903
+ in the CSS consulted prefers-color-scheme either. It also made
904
+ dark mode untestable by any tool that emulates the media query
905
+ (DevTools' Rendering panel, Playwright's colorScheme), because
906
+ the page did not read it.
907
+
908
+ localStorage access throws in some privacy modes; a throw here
909
+ would skip the class entirely, so it is guarded and degrades to
910
+ the OS preference.
911
+ */}
597
912
  <script is:inline>
598
- if (localStorage.getItem("theme") === "dark") {
599
- document.documentElement.classList.add("dark");
600
- }
913
+ (function () {
914
+ var stored = null;
915
+ try {
916
+ stored = localStorage.getItem("theme");
917
+ } catch (e) {}
918
+ var dark =
919
+ stored === "dark" ||
920
+ (stored !== "light" &&
921
+ window.matchMedia("(prefers-color-scheme: dark)").matches);
922
+ if (dark) document.documentElement.classList.add("dark");
923
+ })();
601
924
  </script>
602
925
  <slot name="head" />
603
926
  </head>
604
927
  <body
605
928
  class="bg-background text-foreground antialiased"
606
929
  data-pagefind-ignore={excludeFromSearch ? "" : undefined}
930
+ data-link-icons={_linkIconTokens || undefined}
931
+ style={_linkIconStyle || undefined}
607
932
  >
608
933
  <SidebarProvider>
934
+ {/*
935
+ chrome="blog" drops the sidebar COLUMN only. SidebarProvider and
936
+ SidebarInset stay, so the content column, header offset and
937
+ sticky contract are byte-identical to a docs page — which is what
938
+ makes the blog read as the same site rather than a lookalike.
939
+ With no <Sidebar> sibling the inset's peer-* selectors simply do
940
+ not match and it fills the width.
941
+ */}
942
+ {chrome === "docs" && (
609
943
  <Sidebar collapsible="icon">
610
944
  <SidebarHeader>
611
945
  <SidebarMenu>
612
946
  <SidebarMenuItem>
613
- <SidebarMenuButton size="lg" href={siteUrl} isActive={currentPath === siteUrl || currentPath === "/"}>
947
+ <SidebarMenuButton size="lg" href={basePath || "/"} isActive={currentPath === basePath || currentPath === "/"}>
614
948
  <div class="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
615
949
  <Fragment set:html={siteIcon} />
616
950
  </div>
@@ -626,7 +960,25 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
626
960
  <SidebarSeparator />
627
961
 
628
962
  <SidebarContent>
629
- {navGroups ? (
963
+ {navMode === "client" ? (
964
+ // Client-render mode: emit one DocsNavClient placeholder
965
+ // inside a single SidebarGroup, regardless of navGroups.
966
+ // The group-label shape doesn't apply when the tree is
967
+ // hydrated by JS — multi-group nav is reconstructed
968
+ // client-side from the same /_dogsbay/nav.json shape.
969
+ // See plans/client-rendered-nav.md.
970
+ <SidebarGroup>
971
+ <SidebarGroupContent>
972
+ <DocsNavClient
973
+ currentPath={currentPath}
974
+ basePath={basePath ?? ""}
975
+ namespace={multiSource?.namespace}
976
+ version={multiSource?.version}
977
+ locale={multiSource?.locale}
978
+ />
979
+ </SidebarGroupContent>
980
+ </SidebarGroup>
981
+ ) : navGroups ? (
630
982
  navGroups.map(group => (
631
983
  <SidebarGroup>
632
984
  <SidebarGroupLabel>{group.label}</SidebarGroupLabel>
@@ -634,6 +986,7 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
634
986
  <SidebarNavTree
635
987
  items={filterNavByAxis(group.items, {
636
988
  basePath: basePath ?? "/docs",
989
+ namespace: multiSource?.namespace,
637
990
  version: multiSource?.version,
638
991
  locale: multiSource?.locale,
639
992
  })}
@@ -648,6 +1001,7 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
648
1001
  <SidebarNavTree
649
1002
  items={filterNavByAxis(nav, {
650
1003
  basePath: basePath ?? "/docs",
1004
+ namespace: multiSource?.namespace,
651
1005
  version: multiSource?.version,
652
1006
  locale: multiSource?.locale,
653
1007
  })}
@@ -660,14 +1014,31 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
660
1014
 
661
1015
  <SidebarRail />
662
1016
  </Sidebar>
1017
+ )}
663
1018
 
664
1019
  <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" />
1020
+ {/*
1021
+ Height comes from `--db-header-height` (declared on :root in
1022
+ <head>) — the CONTRACT any other sticky chrome offsets by, e.g.
1023
+ the release-comparison toolbar. One variable, so a change here
1024
+ can never leave another sticky bar overlapping or floating.
1025
+ */}
1026
+ <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">
1027
+ {chrome === "docs" && <SidebarTrigger class="-ml-1" />}
667
1028
  <Separator orientation="vertical" class="mr-2 h-4" />
668
1029
  <span class="text-sm text-muted-foreground" data-page-title>{title}</span>
669
1030
  <div class="ml-auto flex items-center gap-2">
670
1031
  <slot name="header" />
1032
+ {tocPlacement.popoverToc && (
1033
+ <details class="dba-toc-popover relative" data-toc-popover>
1034
+ <summary class="cursor-pointer list-none rounded-md px-2 py-1.5 text-sm text-muted-foreground hover:bg-accent hover:text-foreground">
1035
+ On this page
1036
+ </summary>
1037
+ <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">
1038
+ <DocsToc headings={headings} title="" />
1039
+ </div>
1040
+ </details>
1041
+ )}
671
1042
  {switcherMap && multiSource && (
672
1043
  <LocaleSwitcher
673
1044
  switcherMap={switcherMap}
@@ -694,7 +1065,10 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
694
1065
  {!hideSearch && (
695
1066
  <SearchDialog
696
1067
  pagefindUrl={pagefindUrl}
1068
+ navUrl={basePath ? `${basePath}/_dogsbay/nav.json` : "/_dogsbay/nav.json"}
697
1069
  taxonomyDisplay={taxonomyDisplay}
1070
+ scopeProduct={multiSource?.namespace}
1071
+ scopeVersion={multiSource?.version}
698
1072
  />
699
1073
  )}
700
1074
  {repoUrl && (
@@ -727,9 +1101,30 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
727
1101
  the actual page content. Filter elements live in here
728
1102
  now to stay inside that scope.
729
1103
  */}
730
- {Array.isArray(tags) && tags.map((tag) => (
731
- <div hidden data-pagefind-filter={`tag:${tag}`}></div>
732
- ))}
1104
+ {/*
1105
+ Slash-nested tags whose prefix is declared in
1106
+ `taxonomies.tags.prefixes` emit as per-prefix filter
1107
+ divs so each prefix becomes its own Pagefind facet
1108
+ column (Difficulty, Topic, Persona, …) instead of
1109
+ pooling under a single "Tag" column.
1110
+
1111
+ Plain tags and tags whose prefix isn't declared fall
1112
+ back to the pooled `tag:` filter — backward-compatible
1113
+ for sites that haven't declared prefixes.
1114
+
1115
+ See plans/per-prefix-search-facets.md.
1116
+ */}
1117
+ {Array.isArray(tags) && tags.map((tag) => {
1118
+ const slash = tag.indexOf("/");
1119
+ if (slash > 0) {
1120
+ const prefix = tag.slice(0, slash);
1121
+ const leaf = tag.slice(slash + 1);
1122
+ if (tagPrefixes && tagPrefixes[prefix]) {
1123
+ return <div hidden data-pagefind-filter={`${prefix}:${leaf}`}></div>;
1124
+ }
1125
+ }
1126
+ return <div hidden data-pagefind-filter={`tag:${tag}`}></div>;
1127
+ })}
733
1128
  {Array.isArray(audience) && audience.map((value) => (
734
1129
  <div hidden data-pagefind-filter={`audience:${value}`}></div>
735
1130
  ))}
@@ -738,6 +1133,18 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
738
1133
  ))}
739
1134
  {status && <div hidden data-pagefind-filter={`status:${status}`}></div>}
740
1135
  {pageType && <div hidden data-pagefind-filter={`type:${pageType}`}></div>}
1136
+ {/*
1137
+ Multi-source axis filters — so search on a versioned/multi-
1138
+ product site can be scoped to (or faceted by) the current
1139
+ product and version. `product` = namespace, `version` = the
1140
+ version segment. Only emitted when the axis is active.
1141
+ */}
1142
+ {multiSource?.namespace && (
1143
+ <div hidden data-pagefind-filter={`product:${multiSource.namespace}`}></div>
1144
+ )}
1145
+ {multiSource?.version && (
1146
+ <div hidden data-pagefind-filter={`version:${multiSource.version}`}></div>
1147
+ )}
741
1148
  {/*
742
1149
  Custom-taxonomy filters. Any taxonomy declared in
743
1150
  `dogsbay.config.yml` that isn't one of the five built-ins
@@ -791,12 +1198,62 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
791
1198
  {title}
792
1199
  </h1>
793
1200
  )}
1201
+ {/*
1202
+ Byline — blog chrome only, and only when there is
1203
+ something to say. An absent author means NO byline row,
1204
+ never "Unknown": a placeholder byline is worse than none,
1205
+ because it reads as data rather than as a gap.
1206
+
1207
+ Sits between the H1 and the lede so the reader gets
1208
+ attribution before the summary, which is the order a
1209
+ post is scanned in.
1210
+ */}
1211
+ {chrome === "blog" && (author?.length || publishedDate || readingMinutes) && (
1212
+ <div
1213
+ class="not-prose mb-6 flex flex-wrap items-center gap-x-2 gap-y-1 text-sm text-muted-foreground"
1214
+ data-post-byline
1215
+ data-pagefind-ignore
1216
+ >
1217
+ {author && author.length > 0 && (
1218
+ <span class="font-medium text-foreground">{author.join(", ")}</span>
1219
+ )}
1220
+ {author && author.length > 0 && publishedDate && (
1221
+ <span aria-hidden="true">&middot;</span>
1222
+ )}
1223
+ {publishedDate && (
1224
+ <time datetime={publishedDate}>{_formatPostDate(publishedDate)}</time>
1225
+ )}
1226
+ {publishedDate && readingMinutes && <span aria-hidden="true">&middot;</span>}
1227
+ {readingMinutes && <span>{readingMinutes} min read</span>}
1228
+ {updatedDate && updatedDate !== publishedDate && (
1229
+ <span>
1230
+ &middot; Updated <time datetime={updatedDate}>{_formatPostDate(updatedDate)}</time>
1231
+ </span>
1232
+ )}
1233
+ </div>
1234
+ )}
1235
+
794
1236
  {autoLede && description && (
795
1237
  <p class="text-lg text-muted-foreground mb-6">
796
1238
  {description}
797
1239
  </p>
798
1240
  )}
799
1241
 
1242
+ {/*
1243
+ Hero image. aspect-boxed on purpose — an unsized image
1244
+ above the fold is the classic layout-shift source, and it
1245
+ would shove the prose down after paint.
1246
+ */}
1247
+ {chrome === "blog" && heroImage && (
1248
+ <img
1249
+ src={heroImage}
1250
+ alt=""
1251
+ class="not-prose mb-8 w-full rounded-lg border border-border object-cover aspect-[2/1]"
1252
+ loading="eager"
1253
+ decoding="async"
1254
+ />
1255
+ )}
1256
+
800
1257
  {hasMetaStrip && (
801
1258
  <div
802
1259
  class="not-prose mb-6 flex flex-wrap items-center gap-2"
@@ -825,6 +1282,17 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
825
1282
  </div>
826
1283
  )}
827
1284
 
1285
+ {tocPlacement.topToc && (
1286
+ <details class="dba-toc-top not-prose mb-6 rounded-md border" data-toc-top>
1287
+ <summary class="cursor-pointer list-none px-3 py-2 text-sm font-medium text-muted-foreground">
1288
+ On this page
1289
+ </summary>
1290
+ <div class="border-t px-3 py-2">
1291
+ <DocsToc headings={headings} title="" />
1292
+ </div>
1293
+ </details>
1294
+ )}
1295
+
828
1296
  <slot />
829
1297
 
830
1298
  <DocsFooter
@@ -832,23 +1300,56 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
832
1300
  lastUpdated={lastUpdated}
833
1301
  prev={prev}
834
1302
  next={next}
1303
+ {/*
1304
+ Blog adjacency is chronological, so "Previous/Next"
1305
+ describes the wrong axis. Left is older, right is newer —
1306
+ a timeline runs that way, and so does a series: the
1307
+ right-hand link is the next part.
1308
+ */}
1309
+ prevLabel={chrome === "blog" ? "Older" : undefined}
1310
+ nextLabel={chrome === "blog" ? "Newer" : undefined}
835
1311
  copyright={copyright}
836
1312
  llmsLink={llmFooterLink}
1313
+ llmsLinkHref={llmsLinkHrefResolved}
837
1314
  />
838
1315
  </div>
839
1316
  </main>
840
1317
 
841
- {headings.length > 0 && !wideLayout && (
1318
+ {/* Classic right-hand TOC only in `toc: rail` mode. */}
1319
+ {tocPlacement.railToc && (
842
1320
  <aside class="sticky top-12 hidden h-[calc(100vh-3rem)] w-56 shrink-0 overflow-y-auto border-l p-4 lg:block">
843
1321
  <DocsToc headings={headings} />
844
1322
  </aside>
845
1323
  )}
1324
+ {/* Right-rail plugin region — host for the `right-rail` named slot
1325
+ (e.g. an Ask AI panel). Rendered in every non-rail mode and not
1326
+ gated on headings, so a plugin can dock on heading-less pages.
1327
+ Hidden when nothing fills it (see the empty-rail style below). */}
1328
+ {tocPlacement.regionRail && (
1329
+ <aside
1330
+ 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"
1331
+ data-right-rail
1332
+ >
1333
+ <slot name="right-rail" />
1334
+ </aside>
1335
+ )}
846
1336
  </div>
847
1337
  </SidebarInset>
848
1338
  </SidebarProvider>
849
1339
  </body>
850
1340
  </html>
851
1341
 
1342
+ <style>
1343
+ /* The right-rail plugin region renders unconditionally in non-rail TOC
1344
+ modes so plugins (e.g. Ask AI) can dock into it. Until something fills
1345
+ the `right-rail` slot it has no element children — hide it so an empty
1346
+ bordered column doesn't show. (:has matches element children only; an
1347
+ unfilled Astro named slot renders none.) */
1348
+ aside[data-right-rail]:not(:has(*)) {
1349
+ display: none;
1350
+ }
1351
+ </style>
1352
+
852
1353
  <script>
853
1354
  import "@dogsbay/ui/sidebar/sidebar.ts";
854
1355