@dogsbay/docs-layout 0.2.0-beta.92 → 0.2.0-beta.94

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dogsbay/docs-layout",
3
- "version": "0.2.0-beta.92",
3
+ "version": "0.2.0-beta.94",
4
4
  "description": "Standard documentation layout components for Dogsbay",
5
5
  "type": "module",
6
6
  "exports": {
@@ -13,6 +13,7 @@
13
13
  "./TagList.astro": "./src/TagList.astro",
14
14
  "./StatusBadge.astro": "./src/StatusBadge.astro",
15
15
  "./TypeBadge.astro": "./src/TypeBadge.astro",
16
+ "./BlogIndex.astro": "./src/BlogIndex.astro",
16
17
  "./TaxonomyIndex.astro": "./src/TaxonomyIndex.astro",
17
18
  "./TaxonomyTerm.astro": "./src/TaxonomyTerm.astro",
18
19
  "./SearchDialog.astro": "./src/SearchDialog.astro",
@@ -29,11 +30,12 @@
29
30
  "./json-ld": "./src/json-ld.ts"
30
31
  },
31
32
  "dependencies": {
32
- "@dogsbay/ui": "0.2.0-beta.92",
33
- "@dogsbay/primitives": "0.2.0-beta.92"
33
+ "@dogsbay/primitives": "0.2.0-beta.94",
34
+ "@dogsbay/ui": "0.2.0-beta.94"
34
35
  },
35
36
  "devDependencies": {
36
- "vitest": "^3.0.0"
37
+ "happy-dom": "^20.10.6",
38
+ "vitest": "^4.1.10"
37
39
  },
38
40
  "peerDependencies": {
39
41
  "astro": "^5.0.0 || ^6.0.0 || ^7.0.0"
@@ -0,0 +1,174 @@
1
+ ---
2
+ /**
3
+ * BlogIndex — reverse-chronological list of posts.
4
+ *
5
+ * Reads the slice its route file hands it from `src/data/blog.json`
6
+ * (emitted by `format-astro/src/blog.ts` during `dogsbay site build`)
7
+ * and renders one card per post plus page navigation.
8
+ *
9
+ * All layout lives here rather than in the generated route files, so a
10
+ * design fix propagates on the next build instead of being baked into
11
+ * every emitted page — the same reason `TaxonomyIndex` exists.
12
+ *
13
+ * Uses the shared theme tokens only. The blog is the same site as the
14
+ * docs, so there is no blog palette, no blog typography scale, and no
15
+ * second set of card styles to keep in sync.
16
+ */
17
+ interface BlogPostRef {
18
+ slug: string;
19
+ title: string;
20
+ url: string;
21
+ description?: string;
22
+ date?: string;
23
+ author?: string[];
24
+ tags?: string[];
25
+ heroImage?: string;
26
+ readingMinutes: number;
27
+ }
28
+
29
+ interface Props {
30
+ /** The posts for THIS page of the index, newest first. */
31
+ posts: BlogPostRef[];
32
+ /** URL-form index path, already basePath-prefixed. */
33
+ indexPath: string;
34
+ pageNo: number;
35
+ totalPages: number;
36
+ /** Page heading. Defaults to "Blog", or "Blog — page N" beyond page 1. */
37
+ heading?: string;
38
+ }
39
+
40
+ const {
41
+ posts,
42
+ indexPath,
43
+ pageNo,
44
+ totalPages,
45
+ heading = pageNo === 1 ? "Blog" : `Blog — page ${pageNo}`,
46
+ } = Astro.props;
47
+
48
+ /** Build time, so every reader sees the same string. See DocsLayout. */
49
+ function formatDate(iso: string): string {
50
+ const d = new Date(iso);
51
+ if (Number.isNaN(d.getTime())) return iso;
52
+ // timeZone: "UTC" is load-bearing. parseMeta normalizes `created:
53
+ // 2026-01-01` to "2026-01-01T00:00:00.000Z", so a build host in any
54
+ // negative-offset zone (the default on many CI runners) would render
55
+ // "31 December 2025". DocsFooter already carries this fix; these
56
+ // copies dropped it.
57
+ return d.toLocaleDateString("en-GB", {
58
+ year: "numeric",
59
+ month: "long",
60
+ day: "numeric",
61
+ timeZone: "UTC",
62
+ });
63
+ }
64
+
65
+ const base = indexPath === "/" ? "" : indexPath.replace(/\/+$/, "");
66
+ const hrefForPage = (n: number): string => (n === 1 ? `${base}/` : `${base}/page/${n}`);
67
+ ---
68
+
69
+ {/*
70
+ The index needs its own <h1>. Post titles are <h2>, so without one the
71
+ page has an h2 with no h1 above it — a heading-order violation axe-core
72
+ flags, and a missing page heading for anyone navigating by headings.
73
+ The sticky header's site name is chrome, not a page heading.
74
+ */}
75
+ <h1 class="text-3xl font-bold tracking-tight mb-8">{heading}</h1>
76
+
77
+ {posts.length === 0 && (
78
+ <p class="text-muted-foreground">No posts yet.</p>
79
+ )}
80
+
81
+ <ul class="not-prose flex flex-col gap-8" data-blog-index>
82
+ {posts.map((post) => (
83
+ <li class="group">
84
+ <article class="flex flex-col gap-2">
85
+ {post.heroImage && (
86
+ <a href={post.url} tabindex="-1" aria-hidden="true">
87
+ {/*
88
+ aspect-boxed for the same reason as the post hero: an
89
+ unsized image in a list shifts every card below it.
90
+ */}
91
+ <img
92
+ src={post.heroImage}
93
+ alt=""
94
+ class="mb-2 w-full rounded-lg border border-border object-cover aspect-[2/1]"
95
+ loading="lazy"
96
+ decoding="async"
97
+ />
98
+ </a>
99
+ )}
100
+
101
+ <h2 class="text-xl font-semibold tracking-tight">
102
+ <a
103
+ href={post.url}
104
+ class="text-foreground no-underline hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm"
105
+ >
106
+ {post.title}
107
+ </a>
108
+ </h2>
109
+
110
+ {/*
111
+ Byline mirrors the post page's, minus the reading estimate's
112
+ separator noise. data-pagefind-ignore because the same values
113
+ are indexed as structured filters on the post itself; without
114
+ it, every card's date leads its search excerpt.
115
+ */}
116
+ <div
117
+ class="flex flex-wrap items-center gap-x-2 gap-y-1 text-sm text-muted-foreground"
118
+ data-pagefind-ignore
119
+ >
120
+ {post.author && post.author.length > 0 && (
121
+ <span class="font-medium text-foreground">{post.author.join(", ")}</span>
122
+ )}
123
+ {post.author && post.author.length > 0 && post.date && (
124
+ <span aria-hidden="true">&middot;</span>
125
+ )}
126
+ {post.date && <time datetime={post.date}>{formatDate(post.date)}</time>}
127
+ {/*
128
+ Guarded like the post page's byline. Unconditional, a post with
129
+ neither author nor date opened with a stray "· 5 min read", and
130
+ the card disagreed with the page it linked to.
131
+ */}
132
+ {(post.author?.length || post.date) && <span aria-hidden="true">&middot;</span>}
133
+ <span>{post.readingMinutes} min read</span>
134
+ </div>
135
+
136
+ {post.description && (
137
+ <p class="text-muted-foreground">{post.description}</p>
138
+ )}
139
+ </article>
140
+ </li>
141
+ ))}
142
+ </ul>
143
+
144
+ {totalPages > 1 && (
145
+ <nav class="not-prose mt-12 flex items-center justify-between" aria-label="Blog pages">
146
+ {pageNo > 1 ? (
147
+ <a
148
+ href={hrefForPage(pageNo - 1)}
149
+ class="text-sm text-foreground no-underline hover:underline"
150
+ rel="prev"
151
+ >
152
+ &larr; Newer posts
153
+ </a>
154
+ ) : (
155
+ <span></span>
156
+ )}
157
+
158
+ <span class="text-sm text-muted-foreground">
159
+ Page {pageNo} of {totalPages}
160
+ </span>
161
+
162
+ {pageNo < totalPages ? (
163
+ <a
164
+ href={hrefForPage(pageNo + 1)}
165
+ class="text-sm text-foreground no-underline hover:underline"
166
+ rel="next"
167
+ >
168
+ Older posts &rarr;
169
+ </a>
170
+ ) : (
171
+ <span></span>
172
+ )}
173
+ </nav>
174
+ )}
@@ -14,6 +14,14 @@ interface Props {
14
14
  lastUpdated?: string;
15
15
  prev?: PaginationLink;
16
16
  next?: PaginationLink;
17
+ /**
18
+ * Captions above the prev/next titles. Default "Previous" / "Next",
19
+ * which is right for docs, where adjacency is position in the sidebar.
20
+ * A blog's adjacency is TIME, so it passes "Newer" / "Older" — the
21
+ * link means something different there and the label should say so.
22
+ */
23
+ prevLabel?: string;
24
+ nextLabel?: string;
17
25
  copyright?: string;
18
26
  /**
19
27
  * When true, render a low-key footer link to `/llms.txt` so
@@ -32,11 +40,27 @@ const {
32
40
  lastUpdated,
33
41
  prev,
34
42
  next,
43
+ prevLabel = "Previous",
44
+ nextLabel = "Next",
35
45
  copyright,
36
46
  llmsLink = false,
37
47
  llmsLinkHref = "/llms.txt",
38
48
  class: className,
39
49
  } = Astro.props;
50
+
51
+ // `lastUpdated` is a canonical ISO string (e.g. "2026-06-16T00:00:00.000Z").
52
+ // Render it as a clean, locale-stable date; UTC avoids an off-by-one when the
53
+ // build host isn't on UTC. Non-date strings (e.g. a version/tag) pass through.
54
+ function formatDate(value: string): string {
55
+ const d = new Date(value);
56
+ if (Number.isNaN(d.getTime())) return value;
57
+ return d.toLocaleDateString("en-US", {
58
+ year: "numeric",
59
+ month: "long",
60
+ day: "numeric",
61
+ timeZone: "UTC",
62
+ });
63
+ }
40
64
  ---
41
65
 
42
66
  <footer class:list={["mt-12 border-t pt-6", className]}>
@@ -49,7 +73,7 @@ const {
49
73
  Edit this page
50
74
  </a>
51
75
  )}
52
- {lastUpdated && <span>Last updated: {lastUpdated}</span>}
76
+ {lastUpdated && <span>Last updated: {formatDate(lastUpdated)}</span>}
53
77
  </div>
54
78
  )}
55
79
 
@@ -60,7 +84,7 @@ const {
60
84
  <a href={prev.href} class="inline-flex items-center gap-2 rounded-md border px-4 py-2 text-sm font-medium transition-colors hover:bg-accent" rel="prev">
61
85
  <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
62
86
  <div class="text-left">
63
- <div class="text-xs text-muted-foreground">Previous</div>
87
+ <div class="text-xs text-muted-foreground">{prevLabel}</div>
64
88
  <div>{prev.label}</div>
65
89
  </div>
66
90
  </a>
@@ -68,7 +92,7 @@ const {
68
92
  {next ? (
69
93
  <a href={next.href} class="inline-flex items-center gap-2 rounded-md border px-4 py-2 text-sm font-medium transition-colors hover:bg-accent" rel="next">
70
94
  <div class="text-right">
71
- <div class="text-xs text-muted-foreground">Next</div>
95
+ <div class="text-xs text-muted-foreground">{nextLabel}</div>
72
96
  <div>{next.label}</div>
73
97
  </div>
74
98
  <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
@@ -36,6 +36,7 @@ import Separator from "@dogsbay/ui/separator/Separator.astro";
36
36
  import ThemeToggle from "@dogsbay/ui/theme-toggle/ThemeToggle.astro";
37
37
  import DocsToc from "./DocsToc.astro";
38
38
  import { resolveTocPlacement, hasDisplayableToc, type TocMode } from "./toc-placement.js";
39
+ import { linkIconAttrs } from "./link-icons.js";
39
40
  import DocsFooter from "./DocsFooter.astro";
40
41
  import SearchDialog from "./SearchDialog.astro";
41
42
  import TagList from "./TagList.astro";
@@ -405,6 +406,32 @@ interface Props {
405
406
  * in per-page.
406
407
  */
407
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;
408
435
  /**
409
436
  * Table-of-contents placement. Default `"top"`.
410
437
  * - `"top"` — expandable "On this page" disclosure at the top of the
@@ -416,6 +443,18 @@ interface Props {
416
443
  * See plans/ask-branch1-placement-toc.md.
417
444
  */
418
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
+ };
419
458
  class?: string;
420
459
  }
421
460
 
@@ -476,9 +515,22 @@ const {
476
515
  basePath,
477
516
  navMode = "client",
478
517
  wideLayout = false,
518
+ chrome = "docs",
519
+ author,
520
+ publishedDate,
521
+ updatedDate,
522
+ readingMinutes,
523
+ heroImage,
524
+ linkIcons,
479
525
  class: className,
480
526
  } = Astro.props;
481
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
+
482
534
  // Resolve LLM action visibility + placement once. The component
483
535
  // guards against missing markdownBody / mdUrl internally, but we
484
536
  // also gate at the layout level so the slots stay empty when
@@ -562,9 +614,54 @@ const computedCanonical = canonicalUrl
562
614
  ? canonicalOrigin + Astro.url.pathname
563
615
  : undefined);
564
616
 
565
- // 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
+ function _formatPostDate(iso: string): string {
627
+ const d = new Date(iso);
628
+ if (Number.isNaN(d.getTime())) return iso;
629
+ // timeZone: "UTC" is load-bearing. parseMeta normalizes `created:
630
+ // 2026-01-01` to "2026-01-01T00:00:00.000Z", so a build host in any
631
+ // negative-offset zone (the default on many CI runners) would render
632
+ // "31 December 2025". DocsFooter already carries this fix; these
633
+ // copies dropped it.
634
+ return d.toLocaleDateString("en-GB", {
635
+ year: "numeric",
636
+ month: "long",
637
+ day: "numeric",
638
+ timeZone: "UTC",
639
+ });
640
+ }
641
+
642
+ // Markdown mirror for the alternate link.
643
+ //
644
+ // Leaf pages append `.md` to the trimmed path (`/getting-started/` →
645
+ // `/getting-started.md`), matching the emitted `<slug>.md.ts` endpoint.
646
+ // The SITE INDEX is the exception: it is emitted as `index.md.ts`, so
647
+ // its mirror is `<base>/index.md`. Appending `.md` there produced
648
+ // `/.md` (root-served) or `/blog.md` (mounted) — neither exists, and on
649
+ // a mounted site `/blog.md` also falls outside the `/blog/*` route.
650
+ // Both leaf and index URLs carry a trailing slash under Astro's
651
+ // directory build format, so only "is this the site root" distinguishes
652
+ // them. Kept in step with `shouldRewriteToMarkdown`, which is handed the
653
+ // same combined prefix.
654
+ // Use the `basePath` PROP, not import.meta.env.BASE_URL. Astro's `base`
655
+ // carries only site.url's path (urlBase); the prop carries the COMBINED
656
+ // urlBase + site.basePath prefix — the same value the middleware gets —
657
+ // so on a `site.basePath: /docs` deploy the two would otherwise disagree
658
+ // about which page is the index.
659
+ const mdMirrorBase = (basePath ?? "").replace(/\/+$/, "");
660
+ const mdMirrorPath = Astro.url.pathname.replace(/\/$/, "");
566
661
  const mdMirrorHref = mdMirror
567
- ? (Astro.url.pathname.replace(/\/$/, "") || "") + ".md"
662
+ ? mdMirrorPath === mdMirrorBase
663
+ ? `${mdMirrorBase}/index.md`
664
+ : `${mdMirrorPath}.md`
568
665
  : undefined;
569
666
 
570
667
  // Tag keywords for HTML meta + JSON-LD. Slug-based identifiers
@@ -614,6 +711,50 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
614
711
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
615
712
  <title>{title} | {siteName}</title>
616
713
 
714
+ <style is:global>
715
+ /*
716
+ The sticky-chrome contract. This header is exactly this tall, and
717
+ anything else that sticks (the release-comparison toolbar) offsets
718
+ by the same variable — so they cannot drift apart.
719
+ */
720
+ :root {
721
+ --db-header-height: 3rem;
722
+ }
723
+
724
+ /*
725
+ Internal/external link icons. Opt-in via `content.linkIcons`,
726
+ which sets `data-link-icons` (which kinds are active) and the
727
+ `--db-link-icon-*` custom props (the glyphs) on <body>. Links are
728
+ classified purely by href shape, so it covers both structured
729
+ links and raw-HTML anchors from imported content. Scoped to
730
+ `.docs-prose` so nav/header/footer links are untouched.
731
+ */
732
+ body[data-link-icons~="external"] .docs-prose a[href^="http://" i]::after,
733
+ body[data-link-icons~="external"] .docs-prose a[href^="https://" i]::after,
734
+ body[data-link-icons~="external"] .docs-prose a[href^="//"]::after {
735
+ content: var(--db-link-icon-external, "");
736
+ display: inline-block;
737
+ margin-inline-start: 0.15em;
738
+ font-size: 0.85em;
739
+ line-height: 1;
740
+ vertical-align: baseline;
741
+ opacity: 0.7;
742
+ }
743
+ body[data-link-icons~="internal"]
744
+ .docs-prose
745
+ a[href^="/"]:not([href^="//"])::after,
746
+ body[data-link-icons~="internal"] .docs-prose a[href^="./"]::after,
747
+ body[data-link-icons~="internal"] .docs-prose a[href^="../"]::after {
748
+ content: var(--db-link-icon-internal, "");
749
+ display: inline-block;
750
+ margin-inline-start: 0.15em;
751
+ font-size: 0.85em;
752
+ line-height: 1;
753
+ vertical-align: baseline;
754
+ opacity: 0.7;
755
+ }
756
+ </style>
757
+
617
758
  {metaDescription && <meta name="description" content={metaDescription} />}
618
759
  {favicon && <link rel="icon" href={favicon} />}
619
760
  {themeColor && <meta name="theme-color" content={themeColor} />}
@@ -708,8 +849,19 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
708
849
  <body
709
850
  class="bg-background text-foreground antialiased"
710
851
  data-pagefind-ignore={excludeFromSearch ? "" : undefined}
852
+ data-link-icons={_linkIconTokens || undefined}
853
+ style={_linkIconStyle || undefined}
711
854
  >
712
855
  <SidebarProvider>
856
+ {/*
857
+ chrome="blog" drops the sidebar COLUMN only. SidebarProvider and
858
+ SidebarInset stay, so the content column, header offset and
859
+ sticky contract are byte-identical to a docs page — which is what
860
+ makes the blog read as the same site rather than a lookalike.
861
+ With no <Sidebar> sibling the inset's peer-* selectors simply do
862
+ not match and it fills the width.
863
+ */}
864
+ {chrome === "docs" && (
713
865
  <Sidebar collapsible="icon">
714
866
  <SidebarHeader>
715
867
  <SidebarMenu>
@@ -742,6 +894,7 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
742
894
  <DocsNavClient
743
895
  currentPath={currentPath}
744
896
  basePath={basePath ?? ""}
897
+ namespace={multiSource?.namespace}
745
898
  version={multiSource?.version}
746
899
  locale={multiSource?.locale}
747
900
  />
@@ -755,6 +908,7 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
755
908
  <SidebarNavTree
756
909
  items={filterNavByAxis(group.items, {
757
910
  basePath: basePath ?? "/docs",
911
+ namespace: multiSource?.namespace,
758
912
  version: multiSource?.version,
759
913
  locale: multiSource?.locale,
760
914
  })}
@@ -769,6 +923,7 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
769
923
  <SidebarNavTree
770
924
  items={filterNavByAxis(nav, {
771
925
  basePath: basePath ?? "/docs",
926
+ namespace: multiSource?.namespace,
772
927
  version: multiSource?.version,
773
928
  locale: multiSource?.locale,
774
929
  })}
@@ -781,10 +936,17 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
781
936
 
782
937
  <SidebarRail />
783
938
  </Sidebar>
939
+ )}
784
940
 
785
941
  <SidebarInset>
786
- <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">
787
- <SidebarTrigger class="-ml-1" />
942
+ {/*
943
+ Height comes from `--db-header-height` (declared on :root in
944
+ <head>) — the CONTRACT any other sticky chrome offsets by, e.g.
945
+ the release-comparison toolbar. One variable, so a change here
946
+ can never leave another sticky bar overlapping or floating.
947
+ */}
948
+ <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">
949
+ {chrome === "docs" && <SidebarTrigger class="-ml-1" />}
788
950
  <Separator orientation="vertical" class="mr-2 h-4" />
789
951
  <span class="text-sm text-muted-foreground" data-page-title>{title}</span>
790
952
  <div class="ml-auto flex items-center gap-2">
@@ -827,6 +989,8 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
827
989
  pagefindUrl={pagefindUrl}
828
990
  navUrl={basePath ? `${basePath}/_dogsbay/nav.json` : "/_dogsbay/nav.json"}
829
991
  taxonomyDisplay={taxonomyDisplay}
992
+ scopeProduct={multiSource?.namespace}
993
+ scopeVersion={multiSource?.version}
830
994
  />
831
995
  )}
832
996
  {repoUrl && (
@@ -891,6 +1055,18 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
891
1055
  ))}
892
1056
  {status && <div hidden data-pagefind-filter={`status:${status}`}></div>}
893
1057
  {pageType && <div hidden data-pagefind-filter={`type:${pageType}`}></div>}
1058
+ {/*
1059
+ Multi-source axis filters — so search on a versioned/multi-
1060
+ product site can be scoped to (or faceted by) the current
1061
+ product and version. `product` = namespace, `version` = the
1062
+ version segment. Only emitted when the axis is active.
1063
+ */}
1064
+ {multiSource?.namespace && (
1065
+ <div hidden data-pagefind-filter={`product:${multiSource.namespace}`}></div>
1066
+ )}
1067
+ {multiSource?.version && (
1068
+ <div hidden data-pagefind-filter={`version:${multiSource.version}`}></div>
1069
+ )}
894
1070
  {/*
895
1071
  Custom-taxonomy filters. Any taxonomy declared in
896
1072
  `dogsbay.config.yml` that isn't one of the five built-ins
@@ -944,12 +1120,62 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
944
1120
  {title}
945
1121
  </h1>
946
1122
  )}
1123
+ {/*
1124
+ Byline — blog chrome only, and only when there is
1125
+ something to say. An absent author means NO byline row,
1126
+ never "Unknown": a placeholder byline is worse than none,
1127
+ because it reads as data rather than as a gap.
1128
+
1129
+ Sits between the H1 and the lede so the reader gets
1130
+ attribution before the summary, which is the order a
1131
+ post is scanned in.
1132
+ */}
1133
+ {chrome === "blog" && (author?.length || publishedDate || readingMinutes) && (
1134
+ <div
1135
+ class="not-prose mb-6 flex flex-wrap items-center gap-x-2 gap-y-1 text-sm text-muted-foreground"
1136
+ data-post-byline
1137
+ data-pagefind-ignore
1138
+ >
1139
+ {author && author.length > 0 && (
1140
+ <span class="font-medium text-foreground">{author.join(", ")}</span>
1141
+ )}
1142
+ {author && author.length > 0 && publishedDate && (
1143
+ <span aria-hidden="true">&middot;</span>
1144
+ )}
1145
+ {publishedDate && (
1146
+ <time datetime={publishedDate}>{_formatPostDate(publishedDate)}</time>
1147
+ )}
1148
+ {publishedDate && readingMinutes && <span aria-hidden="true">&middot;</span>}
1149
+ {readingMinutes && <span>{readingMinutes} min read</span>}
1150
+ {updatedDate && updatedDate !== publishedDate && (
1151
+ <span>
1152
+ &middot; Updated <time datetime={updatedDate}>{_formatPostDate(updatedDate)}</time>
1153
+ </span>
1154
+ )}
1155
+ </div>
1156
+ )}
1157
+
947
1158
  {autoLede && description && (
948
1159
  <p class="text-lg text-muted-foreground mb-6">
949
1160
  {description}
950
1161
  </p>
951
1162
  )}
952
1163
 
1164
+ {/*
1165
+ Hero image. aspect-boxed on purpose — an unsized image
1166
+ above the fold is the classic layout-shift source, and it
1167
+ would shove the prose down after paint.
1168
+ */}
1169
+ {chrome === "blog" && heroImage && (
1170
+ <img
1171
+ src={heroImage}
1172
+ alt=""
1173
+ class="not-prose mb-8 w-full rounded-lg border border-border object-cover aspect-[2/1]"
1174
+ loading="eager"
1175
+ decoding="async"
1176
+ />
1177
+ )}
1178
+
953
1179
  {hasMetaStrip && (
954
1180
  <div
955
1181
  class="not-prose mb-6 flex flex-wrap items-center gap-2"
@@ -996,6 +1222,12 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
996
1222
  lastUpdated={lastUpdated}
997
1223
  prev={prev}
998
1224
  next={next}
1225
+ {/*
1226
+ Blog adjacency is chronological, so "Previous/Next"
1227
+ would describe the wrong axis — prev is the NEWER post.
1228
+ */}
1229
+ prevLabel={chrome === "blog" ? "Newer" : undefined}
1230
+ nextLabel={chrome === "blog" ? "Older" : undefined}
999
1231
  copyright={copyright}
1000
1232
  llmsLink={llmFooterLink}
1001
1233
  llmsLinkHref={llmsLinkHrefResolved}