@dogsbay/docs-layout 0.2.0-beta.93 → 0.2.0-beta.95

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.93",
3
+ "version": "0.2.0-beta.95",
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,12 +30,12 @@
29
30
  "./json-ld": "./src/json-ld.ts"
30
31
  },
31
32
  "dependencies": {
32
- "@dogsbay/ui": "0.2.0-beta.93",
33
- "@dogsbay/primitives": "0.2.0-beta.93"
33
+ "@dogsbay/primitives": "0.2.0-beta.95",
34
+ "@dogsbay/ui": "0.2.0-beta.95"
34
35
  },
35
36
  "devDependencies": {
36
- "happy-dom": "^20.8.9",
37
- "vitest": "^3.0.0"
37
+ "happy-dom": "^20.10.6",
38
+ "vitest": "^4.1.10"
38
39
  },
39
40
  "peerDependencies": {
40
41
  "astro": "^5.0.0 || ^6.0.0 || ^7.0.0"
@@ -0,0 +1,179 @@
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
+ import Card from "@dogsbay/ui/card/Card.astro";
18
+
19
+ interface BlogPostRef {
20
+ slug: string;
21
+ title: string;
22
+ url: string;
23
+ description?: string;
24
+ date?: string;
25
+ author?: string[];
26
+ tags?: string[];
27
+ heroImage?: string;
28
+ readingMinutes: number;
29
+ }
30
+
31
+ interface Props {
32
+ /** The posts for THIS page of the index, newest first. */
33
+ posts: BlogPostRef[];
34
+ /** URL-form index path, already basePath-prefixed. */
35
+ indexPath: string;
36
+ pageNo: number;
37
+ totalPages: number;
38
+ /** Page heading. Defaults to "Blog", or "Blog — page N" beyond page 1. */
39
+ heading?: string;
40
+ }
41
+
42
+ const {
43
+ posts,
44
+ indexPath,
45
+ pageNo,
46
+ totalPages,
47
+ heading = pageNo === 1 ? "Blog" : `Blog — page ${pageNo}`,
48
+ } = Astro.props;
49
+
50
+ /** Build time, so every reader sees the same string. See DocsLayout. */
51
+ function formatDate(iso: string): string {
52
+ const d = new Date(iso);
53
+ if (Number.isNaN(d.getTime())) return iso;
54
+ // timeZone: "UTC" is load-bearing. parseMeta normalizes `created:
55
+ // 2026-01-01` to "2026-01-01T00:00:00.000Z", so a build host in any
56
+ // negative-offset zone (the default on many CI runners) would render
57
+ // "31 December 2025". DocsFooter already carries this fix; these
58
+ // copies dropped it.
59
+ return d.toLocaleDateString("en-GB", {
60
+ year: "numeric",
61
+ month: "long",
62
+ day: "numeric",
63
+ timeZone: "UTC",
64
+ });
65
+ }
66
+
67
+ const base = indexPath === "/" ? "" : indexPath.replace(/\/+$/, "");
68
+ const hrefForPage = (n: number): string => (n === 1 ? `${base}/` : `${base}/page/${n}`);
69
+ ---
70
+
71
+ {/*
72
+ The index needs its own <h1>. Post titles are <h2>, so without one the
73
+ page has an h2 with no h1 above it — a heading-order violation axe-core
74
+ flags, and a missing page heading for anyone navigating by headings.
75
+ The sticky header's site name is chrome, not a page heading.
76
+ */}
77
+ <h1 class="text-3xl font-bold tracking-tight mb-8">{heading}</h1>
78
+
79
+ {posts.length === 0 && (
80
+ <p class="text-muted-foreground">No posts yet.</p>
81
+ )}
82
+
83
+ {/*
84
+ Three across at lg, two at sm, one on mobile. The card is one link, not
85
+ a card containing links: nesting interactive elements inside a clickable
86
+ card is the classic keyboard-and-screen-reader trap, so tags and other
87
+ links stay off the card and live on the post page instead.
88
+
89
+ `items-stretch` + `h-full` keeps a short post's card the same height as
90
+ a long one's — ragged card bottoms are the thing that makes a grid look
91
+ broken rather than sparse.
92
+ */}
93
+ <ul
94
+ class="not-prose grid list-none grid-cols-1 items-stretch gap-6 p-0 sm:grid-cols-2 lg:grid-cols-3"
95
+ data-blog-index
96
+ >
97
+ {posts.map((post) => (
98
+ <li class="m-0 p-0">
99
+ <a
100
+ href={post.url}
101
+ class="group block h-full rounded-lg no-underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
102
+ >
103
+ <Card class="flex h-full flex-col gap-3 p-5 transition-colors group-hover:border-ring">
104
+ {post.heroImage && (
105
+ <img
106
+ src={post.heroImage}
107
+ alt=""
108
+ class="aspect-[16/9] w-full rounded-md border border-border object-cover"
109
+ loading="lazy"
110
+ decoding="async"
111
+ />
112
+ )}
113
+
114
+ <h2 class="text-lg font-semibold leading-snug tracking-tight text-foreground group-hover:underline">
115
+ {post.title}
116
+ </h2>
117
+
118
+ {/*
119
+ data-pagefind-ignore: the same values are indexed as structured
120
+ filters on the post itself. Without it every card's date leads
121
+ its search excerpt.
122
+ */}
123
+ <div
124
+ class="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground"
125
+ data-pagefind-ignore
126
+ >
127
+ {post.author && post.author.length > 0 && (
128
+ <span class="font-medium text-foreground">{post.author.join(", ")}</span>
129
+ )}
130
+ {post.author && post.author.length > 0 && post.date && (
131
+ <span aria-hidden="true">&middot;</span>
132
+ )}
133
+ {post.date && <time datetime={post.date}>{formatDate(post.date)}</time>}
134
+ {(post.author?.length || post.date) && <span aria-hidden="true">&middot;</span>}
135
+ <span>{post.readingMinutes} min read</span>
136
+ </div>
137
+
138
+ {post.description && (
139
+ <p class="m-0 text-sm leading-relaxed text-muted-foreground">
140
+ {post.description}
141
+ </p>
142
+ )}
143
+ </Card>
144
+ </a>
145
+ </li>
146
+ ))}
147
+ </ul>
148
+
149
+ {totalPages > 1 && (
150
+ <nav class="not-prose mt-12 flex items-center justify-between" aria-label="Blog pages">
151
+ {pageNo > 1 ? (
152
+ <a
153
+ href={hrefForPage(pageNo - 1)}
154
+ class="text-sm text-foreground no-underline hover:underline"
155
+ rel="prev"
156
+ >
157
+ &larr; Newer posts
158
+ </a>
159
+ ) : (
160
+ <span></span>
161
+ )}
162
+
163
+ <span class="text-sm text-muted-foreground">
164
+ Page {pageNo} of {totalPages}
165
+ </span>
166
+
167
+ {pageNo < totalPages ? (
168
+ <a
169
+ href={hrefForPage(pageNo + 1)}
170
+ class="text-sm text-foreground no-underline hover:underline"
171
+ rel="next"
172
+ >
173
+ Older posts &rarr;
174
+ </a>
175
+ ) : (
176
+ <span></span>
177
+ )}
178
+ </nav>
179
+ )}
@@ -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,6 +40,8 @@ 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",
@@ -74,7 +84,7 @@ function formatDate(value: string): string {
74
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">
75
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>
76
86
  <div class="text-left">
77
- <div class="text-xs text-muted-foreground">Previous</div>
87
+ <div class="text-xs text-muted-foreground">{prevLabel}</div>
78
88
  <div>{prev.label}</div>
79
89
  </div>
80
90
  </a>
@@ -82,7 +92,7 @@ function formatDate(value: string): string {
82
92
  {next ? (
83
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">
84
94
  <div class="text-right">
85
- <div class="text-xs text-muted-foreground">Next</div>
95
+ <div class="text-xs text-muted-foreground">{nextLabel}</div>
86
96
  <div>{next.label}</div>
87
97
  </div>
88
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,44 @@ 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;
435
+ /**
436
+ * Series position, when the post belongs to one. Derived at emit time
437
+ * from publication order, so "Part 3 of 7" cannot disagree with reality
438
+ * the way a hand-typed line does.
439
+ */
440
+ series?: {
441
+ name: string;
442
+ part: number;
443
+ total: number;
444
+ /** URL of part 1, so a reader landing mid-series can start over. */
445
+ startHref?: string;
446
+ };
408
447
  /**
409
448
  * Table-of-contents placement. Default `"top"`.
410
449
  * - `"top"` — expandable "On this page" disclosure at the top of the
@@ -416,6 +455,18 @@ interface Props {
416
455
  * See plans/ask-branch1-placement-toc.md.
417
456
  */
418
457
  toc?: TocMode;
458
+ /**
459
+ * Internal/external link-icon glyphs. When set, a small trailing icon
460
+ * marks links in the prose — external (absolute / protocol-relative
461
+ * href) vs internal (root / relative) — classified purely by href
462
+ * shape in CSS. Either side may be absent to mark only that link kind.
463
+ * Sourced from `content.linkIcons` in `dogsbay.config.yml`.
464
+ * See plans/link-resolution-and-icons.md.
465
+ */
466
+ linkIcons?: {
467
+ external?: string;
468
+ internal?: string;
469
+ };
419
470
  class?: string;
420
471
  }
421
472
 
@@ -476,9 +527,23 @@ const {
476
527
  basePath,
477
528
  navMode = "client",
478
529
  wideLayout = false,
530
+ chrome = "docs",
531
+ author,
532
+ publishedDate,
533
+ updatedDate,
534
+ readingMinutes,
535
+ heroImage,
536
+ series,
537
+ linkIcons,
479
538
  class: className,
480
539
  } = Astro.props;
481
540
 
541
+ // Link icons — classified in CSS by href shape. `data-link-icons`
542
+ // lists which kinds are active; the glyphs ride in CSS custom props
543
+ // (single-quoted so they're valid `content:` string values).
544
+ const { tokens: _linkIconTokens, style: _linkIconStyle } =
545
+ linkIconAttrs(linkIcons);
546
+
482
547
  // Resolve LLM action visibility + placement once. The component
483
548
  // guards against missing markdownBody / mdUrl internally, but we
484
549
  // also gate at the layout level so the slots stay empty when
@@ -562,9 +627,54 @@ const computedCanonical = canonicalUrl
562
627
  ? canonicalOrigin + Astro.url.pathname
563
628
  : undefined);
564
629
 
565
- // Markdown mirror — append `.md` to the current path for the alternate link
630
+ /**
631
+ * Format an ISO date for a byline.
632
+ *
633
+ * Runs at BUILD time, so the output is baked into the HTML and every
634
+ * reader sees the same string — no hydration flash, and no dependence on
635
+ * the visitor's locale for a date the author wrote. Falls back to the
636
+ * raw value if it will not parse, so a typo shows up as itself rather
637
+ * than as "Invalid Date".
638
+ */
639
+ function _formatPostDate(iso: string): string {
640
+ const d = new Date(iso);
641
+ if (Number.isNaN(d.getTime())) return iso;
642
+ // timeZone: "UTC" is load-bearing. parseMeta normalizes `created:
643
+ // 2026-01-01` to "2026-01-01T00:00:00.000Z", so a build host in any
644
+ // negative-offset zone (the default on many CI runners) would render
645
+ // "31 December 2025". DocsFooter already carries this fix; these
646
+ // copies dropped it.
647
+ return d.toLocaleDateString("en-GB", {
648
+ year: "numeric",
649
+ month: "long",
650
+ day: "numeric",
651
+ timeZone: "UTC",
652
+ });
653
+ }
654
+
655
+ // Markdown mirror for the alternate link.
656
+ //
657
+ // Leaf pages append `.md` to the trimmed path (`/getting-started/` →
658
+ // `/getting-started.md`), matching the emitted `<slug>.md.ts` endpoint.
659
+ // The SITE INDEX is the exception: it is emitted as `index.md.ts`, so
660
+ // its mirror is `<base>/index.md`. Appending `.md` there produced
661
+ // `/.md` (root-served) or `/blog.md` (mounted) — neither exists, and on
662
+ // a mounted site `/blog.md` also falls outside the `/blog/*` route.
663
+ // Both leaf and index URLs carry a trailing slash under Astro's
664
+ // directory build format, so only "is this the site root" distinguishes
665
+ // them. Kept in step with `shouldRewriteToMarkdown`, which is handed the
666
+ // same combined prefix.
667
+ // Use the `basePath` PROP, not import.meta.env.BASE_URL. Astro's `base`
668
+ // carries only site.url's path (urlBase); the prop carries the COMBINED
669
+ // urlBase + site.basePath prefix — the same value the middleware gets —
670
+ // so on a `site.basePath: /docs` deploy the two would otherwise disagree
671
+ // about which page is the index.
672
+ const mdMirrorBase = (basePath ?? "").replace(/\/+$/, "");
673
+ const mdMirrorPath = Astro.url.pathname.replace(/\/$/, "");
566
674
  const mdMirrorHref = mdMirror
567
- ? (Astro.url.pathname.replace(/\/$/, "") || "") + ".md"
675
+ ? mdMirrorPath === mdMirrorBase
676
+ ? `${mdMirrorBase}/index.md`
677
+ : `${mdMirrorPath}.md`
568
678
  : undefined;
569
679
 
570
680
  // Tag keywords for HTML meta + JSON-LD. Slug-based identifiers
@@ -614,6 +724,50 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
614
724
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
615
725
  <title>{title} | {siteName}</title>
616
726
 
727
+ <style is:global>
728
+ /*
729
+ The sticky-chrome contract. This header is exactly this tall, and
730
+ anything else that sticks (the release-comparison toolbar) offsets
731
+ by the same variable — so they cannot drift apart.
732
+ */
733
+ :root {
734
+ --db-header-height: 3rem;
735
+ }
736
+
737
+ /*
738
+ Internal/external link icons. Opt-in via `content.linkIcons`,
739
+ which sets `data-link-icons` (which kinds are active) and the
740
+ `--db-link-icon-*` custom props (the glyphs) on <body>. Links are
741
+ classified purely by href shape, so it covers both structured
742
+ links and raw-HTML anchors from imported content. Scoped to
743
+ `.docs-prose` so nav/header/footer links are untouched.
744
+ */
745
+ body[data-link-icons~="external"] .docs-prose a[href^="http://" i]::after,
746
+ body[data-link-icons~="external"] .docs-prose a[href^="https://" i]::after,
747
+ body[data-link-icons~="external"] .docs-prose a[href^="//"]::after {
748
+ content: var(--db-link-icon-external, "");
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
+ body[data-link-icons~="internal"]
757
+ .docs-prose
758
+ a[href^="/"]:not([href^="//"])::after,
759
+ body[data-link-icons~="internal"] .docs-prose a[href^="./"]::after,
760
+ body[data-link-icons~="internal"] .docs-prose a[href^="../"]::after {
761
+ content: var(--db-link-icon-internal, "");
762
+ display: inline-block;
763
+ margin-inline-start: 0.15em;
764
+ font-size: 0.85em;
765
+ line-height: 1;
766
+ vertical-align: baseline;
767
+ opacity: 0.7;
768
+ }
769
+ </style>
770
+
617
771
  {metaDescription && <meta name="description" content={metaDescription} />}
618
772
  {favicon && <link rel="icon" href={favicon} />}
619
773
  {themeColor && <meta name="theme-color" content={themeColor} />}
@@ -708,8 +862,19 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
708
862
  <body
709
863
  class="bg-background text-foreground antialiased"
710
864
  data-pagefind-ignore={excludeFromSearch ? "" : undefined}
865
+ data-link-icons={_linkIconTokens || undefined}
866
+ style={_linkIconStyle || undefined}
711
867
  >
712
868
  <SidebarProvider>
869
+ {/*
870
+ chrome="blog" drops the sidebar COLUMN only. SidebarProvider and
871
+ SidebarInset stay, so the content column, header offset and
872
+ sticky contract are byte-identical to a docs page — which is what
873
+ makes the blog read as the same site rather than a lookalike.
874
+ With no <Sidebar> sibling the inset's peer-* selectors simply do
875
+ not match and it fills the width.
876
+ */}
877
+ {chrome === "docs" && (
713
878
  <Sidebar collapsible="icon">
714
879
  <SidebarHeader>
715
880
  <SidebarMenu>
@@ -742,6 +907,7 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
742
907
  <DocsNavClient
743
908
  currentPath={currentPath}
744
909
  basePath={basePath ?? ""}
910
+ namespace={multiSource?.namespace}
745
911
  version={multiSource?.version}
746
912
  locale={multiSource?.locale}
747
913
  />
@@ -755,6 +921,7 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
755
921
  <SidebarNavTree
756
922
  items={filterNavByAxis(group.items, {
757
923
  basePath: basePath ?? "/docs",
924
+ namespace: multiSource?.namespace,
758
925
  version: multiSource?.version,
759
926
  locale: multiSource?.locale,
760
927
  })}
@@ -769,6 +936,7 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
769
936
  <SidebarNavTree
770
937
  items={filterNavByAxis(nav, {
771
938
  basePath: basePath ?? "/docs",
939
+ namespace: multiSource?.namespace,
772
940
  version: multiSource?.version,
773
941
  locale: multiSource?.locale,
774
942
  })}
@@ -781,10 +949,17 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
781
949
 
782
950
  <SidebarRail />
783
951
  </Sidebar>
952
+ )}
784
953
 
785
954
  <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" />
955
+ {/*
956
+ Height comes from `--db-header-height` (declared on :root in
957
+ <head>) — the CONTRACT any other sticky chrome offsets by, e.g.
958
+ the release-comparison toolbar. One variable, so a change here
959
+ can never leave another sticky bar overlapping or floating.
960
+ */}
961
+ <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">
962
+ {chrome === "docs" && <SidebarTrigger class="-ml-1" />}
788
963
  <Separator orientation="vertical" class="mr-2 h-4" />
789
964
  <span class="text-sm text-muted-foreground" data-page-title>{title}</span>
790
965
  <div class="ml-auto flex items-center gap-2">
@@ -827,6 +1002,8 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
827
1002
  pagefindUrl={pagefindUrl}
828
1003
  navUrl={basePath ? `${basePath}/_dogsbay/nav.json` : "/_dogsbay/nav.json"}
829
1004
  taxonomyDisplay={taxonomyDisplay}
1005
+ scopeProduct={multiSource?.namespace}
1006
+ scopeVersion={multiSource?.version}
830
1007
  />
831
1008
  )}
832
1009
  {repoUrl && (
@@ -891,6 +1068,18 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
891
1068
  ))}
892
1069
  {status && <div hidden data-pagefind-filter={`status:${status}`}></div>}
893
1070
  {pageType && <div hidden data-pagefind-filter={`type:${pageType}`}></div>}
1071
+ {/*
1072
+ Multi-source axis filters — so search on a versioned/multi-
1073
+ product site can be scoped to (or faceted by) the current
1074
+ product and version. `product` = namespace, `version` = the
1075
+ version segment. Only emitted when the axis is active.
1076
+ */}
1077
+ {multiSource?.namespace && (
1078
+ <div hidden data-pagefind-filter={`product:${multiSource.namespace}`}></div>
1079
+ )}
1080
+ {multiSource?.version && (
1081
+ <div hidden data-pagefind-filter={`version:${multiSource.version}`}></div>
1082
+ )}
894
1083
  {/*
895
1084
  Custom-taxonomy filters. Any taxonomy declared in
896
1085
  `dogsbay.config.yml` that isn't one of the five built-ins
@@ -944,12 +1133,90 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
944
1133
  {title}
945
1134
  </h1>
946
1135
  )}
1136
+ {/*
1137
+ Byline — blog chrome only, and only when there is
1138
+ something to say. An absent author means NO byline row,
1139
+ never "Unknown": a placeholder byline is worse than none,
1140
+ because it reads as data rather than as a gap.
1141
+
1142
+ Sits between the H1 and the lede so the reader gets
1143
+ attribution before the summary, which is the order a
1144
+ post is scanned in.
1145
+ */}
1146
+ {chrome === "blog" && (author?.length || publishedDate || readingMinutes) && (
1147
+ <div
1148
+ class="not-prose mb-6 flex flex-wrap items-center gap-x-2 gap-y-1 text-sm text-muted-foreground"
1149
+ data-post-byline
1150
+ data-pagefind-ignore
1151
+ >
1152
+ {author && author.length > 0 && (
1153
+ <span class="font-medium text-foreground">{author.join(", ")}</span>
1154
+ )}
1155
+ {author && author.length > 0 && publishedDate && (
1156
+ <span aria-hidden="true">&middot;</span>
1157
+ )}
1158
+ {publishedDate && (
1159
+ <time datetime={publishedDate}>{_formatPostDate(publishedDate)}</time>
1160
+ )}
1161
+ {publishedDate && readingMinutes && <span aria-hidden="true">&middot;</span>}
1162
+ {readingMinutes && <span>{readingMinutes} min read</span>}
1163
+ {updatedDate && updatedDate !== publishedDate && (
1164
+ <span>
1165
+ &middot; Updated <time datetime={updatedDate}>{_formatPostDate(updatedDate)}</time>
1166
+ </span>
1167
+ )}
1168
+ </div>
1169
+ )}
1170
+
1171
+ {/*
1172
+ Series banner. Sits above the byline because it is the
1173
+ first thing a reader arriving from search needs: which
1174
+ series is this, where am I in it, and where does it start.
1175
+ A blog index is chronological, so someone can easily meet
1176
+ part 7 first.
1177
+ */}
1178
+ {chrome === "blog" && series && (
1179
+ <div
1180
+ class="not-prose mb-4 flex flex-wrap items-center gap-x-2 gap-y-1 rounded-md border border-border bg-muted/40 px-3 py-2 text-sm"
1181
+ data-post-series
1182
+ data-pagefind-ignore
1183
+ >
1184
+ <span class="font-medium text-foreground">
1185
+ Part {series.part} of {series.total}
1186
+ </span>
1187
+ <span class="text-muted-foreground">in {series.name}</span>
1188
+ {series.startHref && series.part !== 1 && (
1189
+ <a
1190
+ href={series.startHref}
1191
+ class="ml-auto text-foreground underline underline-offset-2"
1192
+ >
1193
+ Start at part 1
1194
+ </a>
1195
+ )}
1196
+ </div>
1197
+ )}
1198
+
947
1199
  {autoLede && description && (
948
1200
  <p class="text-lg text-muted-foreground mb-6">
949
1201
  {description}
950
1202
  </p>
951
1203
  )}
952
1204
 
1205
+ {/*
1206
+ Hero image. aspect-boxed on purpose — an unsized image
1207
+ above the fold is the classic layout-shift source, and it
1208
+ would shove the prose down after paint.
1209
+ */}
1210
+ {chrome === "blog" && heroImage && (
1211
+ <img
1212
+ src={heroImage}
1213
+ alt=""
1214
+ class="not-prose mb-8 w-full rounded-lg border border-border object-cover aspect-[2/1]"
1215
+ loading="eager"
1216
+ decoding="async"
1217
+ />
1218
+ )}
1219
+
953
1220
  {hasMetaStrip && (
954
1221
  <div
955
1222
  class="not-prose mb-6 flex flex-wrap items-center gap-2"
@@ -996,6 +1263,12 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
996
1263
  lastUpdated={lastUpdated}
997
1264
  prev={prev}
998
1265
  next={next}
1266
+ {/*
1267
+ Blog adjacency is chronological, so "Previous/Next"
1268
+ would describe the wrong axis — prev is the NEWER post.
1269
+ */}
1270
+ prevLabel={chrome === "blog" ? "Newer" : undefined}
1271
+ nextLabel={chrome === "blog" ? "Older" : undefined}
999
1272
  copyright={copyright}
1000
1273
  llmsLink={llmFooterLink}
1001
1274
  llmsLinkHref={llmsLinkHrefResolved}