@dogsbay/docs-layout 0.2.0-beta.1 → 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
  *
@@ -256,6 +274,23 @@ interface Props {
256
274
  * elements in `<body>`.
257
275
  */
258
276
  category?: string[];
277
+ /**
278
+ * Custom-taxonomy values from `meta.taxonomies` — anything declared
279
+ * in `taxonomies:` config that isn't one of the five hardcoded
280
+ * built-ins (`tags`, `category`, `audience`, `type`, `status`).
281
+ *
282
+ * Each entry becomes one `<div data-pagefind-filter="<name>:<value>">`
283
+ * inside the indexed body, so the search dialog grows a checkbox
284
+ * group for every custom taxonomy automatically. No extra config
285
+ * needed beyond declaring the taxonomy in `dogsbay.config.yml` —
286
+ * the search dialog discovers facets at index time and renders
287
+ * whatever Pagefind reports.
288
+ *
289
+ * Display labels for the checkboxes flow through
290
+ * `taxonomyDisplay[<name>]` (same prefix/label config that drives
291
+ * chip rendering elsewhere). When unset, raw slugs are shown.
292
+ */
293
+ taxonomies?: Record<string, string[]>;
259
294
  /**
260
295
  * Map of taxonomy name → index path for declared taxonomies.
261
296
  * Used to wire links from built-in field badges (TypeBadge,
@@ -324,6 +359,19 @@ interface Props {
324
359
  * (`<basePath>/<version>/`). Defaults to "/docs".
325
360
  */
326
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";
327
375
  /**
328
376
  * Per-page LLM action UI. When set and `enabled !== false`, renders
329
377
  * the PageActions cluster (Copy markdown + Open in Claude/ChatGPT/
@@ -358,6 +406,55 @@ interface Props {
358
406
  * in per-page.
359
407
  */
360
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
+ };
361
458
  class?: string;
362
459
  }
363
460
 
@@ -368,6 +465,7 @@ const {
368
465
  nav,
369
466
  navGroups,
370
467
  headings = [],
468
+ toc = "top",
371
469
  prev,
372
470
  next,
373
471
  repoUrl,
@@ -376,7 +474,7 @@ const {
376
474
  editUrl,
377
475
  lastUpdated,
378
476
  copyright,
379
- favicon = "/favicon.ico",
477
+ favicon,
380
478
  ogImage,
381
479
  defaultOgImage,
382
480
  ogType = "article",
@@ -384,11 +482,18 @@ const {
384
482
  twitterHandle,
385
483
  themeColor,
386
484
  noindex,
485
+ nofollow,
387
486
  excludeFromSearch,
388
487
  plausibleDomain,
389
488
  plausibleScriptUrl,
390
489
  hideSearch = false,
391
- 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,
392
497
  mdMirror = false,
393
498
  tags,
394
499
  tagsIndexPath = "/tags",
@@ -398,6 +503,7 @@ const {
398
503
  pageType,
399
504
  audience,
400
505
  category,
506
+ taxonomies,
401
507
  taxonomyIndexPaths,
402
508
  taxonomyDisplay,
403
509
  autoH1,
@@ -407,10 +513,24 @@ const {
407
513
  multiSource,
408
514
  switcherMap,
409
515
  basePath,
516
+ navMode = "client",
410
517
  wideLayout = false,
518
+ chrome = "docs",
519
+ author,
520
+ publishedDate,
521
+ updatedDate,
522
+ readingMinutes,
523
+ heroImage,
524
+ linkIcons,
411
525
  class: className,
412
526
  } = Astro.props;
413
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
+
414
534
  // Resolve LLM action visibility + placement once. The component
415
535
  // guards against missing markdownBody / mdUrl internally, but we
416
536
  // also gate at the layout level so the slots stay empty when
@@ -428,6 +548,16 @@ const showLlmActionsInline =
428
548
  llmActionsEnabled
429
549
  && (llmActionsPlacement === "inline" || llmActionsPlacement === "both");
430
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
+ })();
431
561
 
432
562
  // Compute href targets for the type / status badges. A field is
433
563
  // linkable only when (a) the user declared a `taxonomies.<field>`
@@ -447,20 +577,108 @@ const hasMetaStrip = (
447
577
  (typeof pageType === "string" && pageType.length > 0)
448
578
  );
449
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
+
450
590
  const currentPath = Astro.url.pathname.replace(/\/$/, "") || "/";
451
591
 
452
592
  // SEO computation
453
593
  const metaDescription = description ?? siteDescription;
454
594
  const metaOgImage = ogImage ?? defaultOgImage;
455
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
+ }
456
612
  const computedCanonical = canonicalUrl
457
- ?? (isAbsoluteSiteUrl
458
- ? siteUrl.replace(/\/$/, "") + Astro.url.pathname
613
+ ?? (canonicalOrigin
614
+ ? canonicalOrigin + Astro.url.pathname
459
615
  : undefined);
460
616
 
461
- // 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(/\/$/, "");
462
678
  const mdMirrorHref = mdMirror
463
- ? (Astro.url.pathname.replace(/\/$/, "") || "") + ".md"
679
+ ? mdMirrorPath === mdMirrorBase
680
+ ? `${mdMirrorBase}/index.md`
681
+ : `${mdMirrorPath}.md`
464
682
  : undefined;
465
683
 
466
684
  // Tag keywords for HTML meta + JSON-LD. Slug-based identifiers
@@ -481,17 +699,30 @@ const tagKeywords = resolveTagKeywords(tags, tagLabels);
481
699
  // Course; everything else → Article. Matches the conventions
482
700
  // search engines key off for educational / tutorial / reference
483
701
  // SERP rendering. See `json-ld.ts` for the full mapping table.
484
- 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
- }
494
- : 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
+ });
495
726
 
496
727
  // `customJsonLd` accepts either a single object or an array.
497
728
  // Normalize to an array so we can iterate uniformly. Empty array
@@ -509,12 +740,67 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
509
740
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
510
741
  <title>{title} | {siteName}</title>
511
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
+
512
787
  {metaDescription && <meta name="description" content={metaDescription} />}
513
- {favicon !== false && <link rel="icon" href={favicon} />}
788
+ {favicon && <link rel="icon" href={favicon} />}
514
789
  {themeColor && <meta name="theme-color" content={themeColor} />}
515
790
  {/* External search engine directive — orthogonal to in-site
516
- Pagefind exclusion. */}
517
- {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
+ )}
518
804
 
519
805
  {/* In-site Pagefind exclusion is wired via two coordinated
520
806
  attributes on <body> and <main> below — see the prop docs
@@ -529,6 +815,12 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
529
815
 
530
816
  {computedCanonical && <link rel="canonical" href={computedCanonical} />}
531
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} />
532
824
 
533
825
  {/* Open Graph */}
534
826
  <meta property="og:type" content={ogType} />
@@ -543,6 +835,26 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
543
835
  {ogType === "article" && tagKeywords.map((keyword) => (
544
836
  <meta property="article:tag" content={keyword} />
545
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
+ )}
546
858
 
547
859
  {/* JSON-LD primary block — Article / HowTo / TechArticle /
548
860
  Course depending on `pageType`. Emits keywords as a
@@ -576,23 +888,63 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
576
888
  ></script>
577
889
  )}
578
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
+ */}
579
912
  <script is:inline>
580
- if (localStorage.getItem("theme") === "dark") {
581
- document.documentElement.classList.add("dark");
582
- }
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
+ })();
583
924
  </script>
584
925
  <slot name="head" />
585
926
  </head>
586
927
  <body
587
928
  class="bg-background text-foreground antialiased"
588
929
  data-pagefind-ignore={excludeFromSearch ? "" : undefined}
930
+ data-link-icons={_linkIconTokens || undefined}
931
+ style={_linkIconStyle || undefined}
589
932
  >
590
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" && (
591
943
  <Sidebar collapsible="icon">
592
944
  <SidebarHeader>
593
945
  <SidebarMenu>
594
946
  <SidebarMenuItem>
595
- <SidebarMenuButton size="lg" href={siteUrl} isActive={currentPath === siteUrl || currentPath === "/"}>
947
+ <SidebarMenuButton size="lg" href={basePath || "/"} isActive={currentPath === basePath || currentPath === "/"}>
596
948
  <div class="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
597
949
  <Fragment set:html={siteIcon} />
598
950
  </div>
@@ -608,7 +960,25 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
608
960
  <SidebarSeparator />
609
961
 
610
962
  <SidebarContent>
611
- {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 ? (
612
982
  navGroups.map(group => (
613
983
  <SidebarGroup>
614
984
  <SidebarGroupLabel>{group.label}</SidebarGroupLabel>
@@ -616,6 +986,7 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
616
986
  <SidebarNavTree
617
987
  items={filterNavByAxis(group.items, {
618
988
  basePath: basePath ?? "/docs",
989
+ namespace: multiSource?.namespace,
619
990
  version: multiSource?.version,
620
991
  locale: multiSource?.locale,
621
992
  })}
@@ -630,6 +1001,7 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
630
1001
  <SidebarNavTree
631
1002
  items={filterNavByAxis(nav, {
632
1003
  basePath: basePath ?? "/docs",
1004
+ namespace: multiSource?.namespace,
633
1005
  version: multiSource?.version,
634
1006
  locale: multiSource?.locale,
635
1007
  })}
@@ -642,14 +1014,31 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
642
1014
 
643
1015
  <SidebarRail />
644
1016
  </Sidebar>
1017
+ )}
645
1018
 
646
1019
  <SidebarInset>
647
- <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">
648
- <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" />}
649
1028
  <Separator orientation="vertical" class="mr-2 h-4" />
650
1029
  <span class="text-sm text-muted-foreground" data-page-title>{title}</span>
651
1030
  <div class="ml-auto flex items-center gap-2">
652
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
+ )}
653
1042
  {switcherMap && multiSource && (
654
1043
  <LocaleSwitcher
655
1044
  switcherMap={switcherMap}
@@ -676,7 +1065,10 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
676
1065
  {!hideSearch && (
677
1066
  <SearchDialog
678
1067
  pagefindUrl={pagefindUrl}
1068
+ navUrl={basePath ? `${basePath}/_dogsbay/nav.json` : "/_dogsbay/nav.json"}
679
1069
  taxonomyDisplay={taxonomyDisplay}
1070
+ scopeProduct={multiSource?.namespace}
1071
+ scopeVersion={multiSource?.version}
680
1072
  />
681
1073
  )}
682
1074
  {repoUrl && (
@@ -709,9 +1101,30 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
709
1101
  the actual page content. Filter elements live in here
710
1102
  now to stay inside that scope.
711
1103
  */}
712
- {Array.isArray(tags) && tags.map((tag) => (
713
- <div hidden data-pagefind-filter={`tag:${tag}`}></div>
714
- ))}
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
+ })}
715
1128
  {Array.isArray(audience) && audience.map((value) => (
716
1129
  <div hidden data-pagefind-filter={`audience:${value}`}></div>
717
1130
  ))}
@@ -720,6 +1133,32 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
720
1133
  ))}
721
1134
  {status && <div hidden data-pagefind-filter={`status:${status}`}></div>}
722
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
+ )}
1148
+ {/*
1149
+ Custom-taxonomy filters. Any taxonomy declared in
1150
+ `dogsbay.config.yml` that isn't one of the five built-ins
1151
+ flows through here, so `difficulty: intermediate` (etc.)
1152
+ becomes a real Pagefind facet checkbox automatically.
1153
+ See plans/beta-launch-followups.md for context.
1154
+ */}
1155
+ {taxonomies && Object.entries(taxonomies).flatMap(([name, values]) =>
1156
+ Array.isArray(values)
1157
+ ? values.map((value) => (
1158
+ <div hidden data-pagefind-filter={`${name}:${value}`}></div>
1159
+ ))
1160
+ : []
1161
+ )}
723
1162
 
724
1163
  <div class:list={["mx-auto", wideLayout ? "max-w-7xl" : "max-w-3xl"]}>
725
1164
  {/*
@@ -759,12 +1198,62 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
759
1198
  {title}
760
1199
  </h1>
761
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
+
762
1236
  {autoLede && description && (
763
1237
  <p class="text-lg text-muted-foreground mb-6">
764
1238
  {description}
765
1239
  </p>
766
1240
  )}
767
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
+
768
1257
  {hasMetaStrip && (
769
1258
  <div
770
1259
  class="not-prose mb-6 flex flex-wrap items-center gap-2"
@@ -793,6 +1282,17 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
793
1282
  </div>
794
1283
  )}
795
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
+
796
1296
  <slot />
797
1297
 
798
1298
  <DocsFooter
@@ -800,23 +1300,56 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
800
1300
  lastUpdated={lastUpdated}
801
1301
  prev={prev}
802
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}
803
1311
  copyright={copyright}
804
1312
  llmsLink={llmFooterLink}
1313
+ llmsLinkHref={llmsLinkHrefResolved}
805
1314
  />
806
1315
  </div>
807
1316
  </main>
808
1317
 
809
- {headings.length > 0 && !wideLayout && (
1318
+ {/* Classic right-hand TOC only in `toc: rail` mode. */}
1319
+ {tocPlacement.railToc && (
810
1320
  <aside class="sticky top-12 hidden h-[calc(100vh-3rem)] w-56 shrink-0 overflow-y-auto border-l p-4 lg:block">
811
1321
  <DocsToc headings={headings} />
812
1322
  </aside>
813
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
+ )}
814
1336
  </div>
815
1337
  </SidebarInset>
816
1338
  </SidebarProvider>
817
1339
  </body>
818
1340
  </html>
819
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
+
820
1353
  <script>
821
1354
  import "@dogsbay/ui/sidebar/sidebar.ts";
822
1355