@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 +6 -5
- package/src/BlogIndex.astro +179 -0
- package/src/DocsFooter.astro +12 -2
- package/src/DocsLayout.astro +277 -4
- package/src/DocsNavClient.astro +21 -3
- package/src/SearchDialog.astro +29 -1
- package/src/VersionSwitcher.astro +6 -0
- package/src/docs-nav-client.ts +81 -2
- package/src/link-icons.ts +54 -0
- package/src/markdown-negotiation.ts +38 -2
- package/src/nav-filter.ts +42 -129
- package/src/switcher.ts +83 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dogsbay/docs-layout",
|
|
3
|
-
"version": "0.2.0-beta.
|
|
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/
|
|
33
|
-
"@dogsbay/
|
|
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.
|
|
37
|
-
"vitest": "^
|
|
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">·</span>
|
|
132
|
+
)}
|
|
133
|
+
{post.date && <time datetime={post.date}>{formatDate(post.date)}</time>}
|
|
134
|
+
{(post.author?.length || post.date) && <span aria-hidden="true">·</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
|
+
← 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 →
|
|
174
|
+
</a>
|
|
175
|
+
) : (
|
|
176
|
+
<span></span>
|
|
177
|
+
)}
|
|
178
|
+
</nav>
|
|
179
|
+
)}
|
package/src/DocsFooter.astro
CHANGED
|
@@ -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">
|
|
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">
|
|
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>
|
package/src/DocsLayout.astro
CHANGED
|
@@ -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
|
-
|
|
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
|
-
?
|
|
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
|
-
|
|
787
|
-
|
|
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">·</span>
|
|
1157
|
+
)}
|
|
1158
|
+
{publishedDate && (
|
|
1159
|
+
<time datetime={publishedDate}>{_formatPostDate(publishedDate)}</time>
|
|
1160
|
+
)}
|
|
1161
|
+
{publishedDate && readingMinutes && <span aria-hidden="true">·</span>}
|
|
1162
|
+
{readingMinutes && <span>{readingMinutes} min read</span>}
|
|
1163
|
+
{updatedDate && updatedDate !== publishedDate && (
|
|
1164
|
+
<span>
|
|
1165
|
+
· 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}
|