@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 +6 -4
- package/src/BlogIndex.astro +174 -0
- package/src/DocsFooter.astro +27 -3
- package/src/DocsLayout.astro +236 -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 +167 -13
- 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.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/
|
|
33
|
-
"@dogsbay/
|
|
33
|
+
"@dogsbay/primitives": "0.2.0-beta.94",
|
|
34
|
+
"@dogsbay/ui": "0.2.0-beta.94"
|
|
34
35
|
},
|
|
35
36
|
"devDependencies": {
|
|
36
|
-
"
|
|
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">·</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">·</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
|
+
← 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 →
|
|
169
|
+
</a>
|
|
170
|
+
) : (
|
|
171
|
+
<span></span>
|
|
172
|
+
)}
|
|
173
|
+
</nav>
|
|
174
|
+
)}
|
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,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">
|
|
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">
|
|
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>
|
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,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
|
-
|
|
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
|
-
?
|
|
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
|
-
|
|
787
|
-
|
|
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">·</span>
|
|
1144
|
+
)}
|
|
1145
|
+
{publishedDate && (
|
|
1146
|
+
<time datetime={publishedDate}>{_formatPostDate(publishedDate)}</time>
|
|
1147
|
+
)}
|
|
1148
|
+
{publishedDate && readingMinutes && <span aria-hidden="true">·</span>}
|
|
1149
|
+
{readingMinutes && <span>{readingMinutes} min read</span>}
|
|
1150
|
+
{updatedDate && updatedDate !== publishedDate && (
|
|
1151
|
+
<span>
|
|
1152
|
+
· 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}
|