@dogsbay/docs-layout 0.2.0-beta.14 → 0.2.0-beta.140
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 +9 -5
- package/src/BlogIndex.astro +179 -0
- package/src/DocsFooter.astro +79 -15
- package/src/DocsLayout.astro +638 -44
- package/src/DocsNavClient.astro +113 -0
- package/src/DocsToc.astro +1 -1
- package/src/SearchDialog.astro +317 -33
- package/src/TagList.astro +24 -3
- package/src/VersionSwitcher.astro +7 -1
- package/src/docs-nav-client.ts +422 -0
- package/src/json-ld.ts +4 -53
- package/src/link-icons.ts +54 -0
- package/src/markdown-negotiation.ts +38 -2
- package/src/nav-filter.ts +68 -127
- package/src/pagefind-base-url.ts +59 -0
- package/src/search-facets.ts +511 -9
- package/src/switcher.ts +93 -4
- package/src/tag-list-data.ts +26 -3
- package/src/toc-kind.css +10 -10
- package/src/toc-placement.ts +71 -0
- package/src/version-redirect.ts +17 -4
package/src/DocsLayout.astro
CHANGED
|
@@ -31,9 +31,12 @@ import SidebarMenuItem from "@dogsbay/ui/sidebar/SidebarMenuItem.astro";
|
|
|
31
31
|
import SidebarMenuButton from "@dogsbay/ui/sidebar/SidebarMenuButton.astro";
|
|
32
32
|
import SidebarSeparator from "@dogsbay/ui/sidebar/SidebarSeparator.astro";
|
|
33
33
|
import SidebarNavTree from "@dogsbay/ui/sidebar/SidebarNavTree.astro";
|
|
34
|
+
import DocsNavClient from "./DocsNavClient.astro";
|
|
34
35
|
import Separator from "@dogsbay/ui/separator/Separator.astro";
|
|
35
36
|
import ThemeToggle from "@dogsbay/ui/theme-toggle/ThemeToggle.astro";
|
|
36
37
|
import DocsToc from "./DocsToc.astro";
|
|
38
|
+
import { resolveTocPlacement, hasDisplayableToc, type TocMode } from "./toc-placement.js";
|
|
39
|
+
import { linkIconAttrs } from "./link-icons.js";
|
|
37
40
|
import DocsFooter from "./DocsFooter.astro";
|
|
38
41
|
import SearchDialog from "./SearchDialog.astro";
|
|
39
42
|
import TagList from "./TagList.astro";
|
|
@@ -42,9 +45,9 @@ import TypeBadge from "./TypeBadge.astro";
|
|
|
42
45
|
import PageActions from "./PageActions.astro";
|
|
43
46
|
import VersionSwitcher from "./VersionSwitcher.astro";
|
|
44
47
|
import LocaleSwitcher from "./LocaleSwitcher.astro";
|
|
45
|
-
import { filterNavByAxis } from "./nav-filter.js";
|
|
48
|
+
import { filterNavByAxis, rootVersionFilter } from "./nav-filter.js";
|
|
46
49
|
import type { LlmProviderName } from "./llm-actions.js";
|
|
47
|
-
import { jsonLdTypeFor, normalizeCustomJsonLd } from "./json-ld.js";
|
|
50
|
+
import { jsonLdTypeFor, normalizeCustomJsonLd, buildArticleJsonLd, buildWebSiteJsonLd } from "./json-ld.js";
|
|
48
51
|
import { resolveTagKeywords } from "./tag-list-data.js";
|
|
49
52
|
|
|
50
53
|
interface NavItem {
|
|
@@ -86,6 +89,8 @@ type TagPaletteName =
|
|
|
86
89
|
interface Props {
|
|
87
90
|
/** Site name shown in header and sidebar logo */
|
|
88
91
|
siteName: string;
|
|
92
|
+
/** The line under the site name in the sidebar header (`site.tagline`). Default "Documentation". */
|
|
93
|
+
siteTagline?: string;
|
|
89
94
|
/** Page title for <title> tag */
|
|
90
95
|
title: string;
|
|
91
96
|
/** Home URL — also used as the base for the canonical link if absolute */
|
|
@@ -112,7 +117,13 @@ interface Props {
|
|
|
112
117
|
siteDescription?: string;
|
|
113
118
|
/** Copyright text (HTML allowed) */
|
|
114
119
|
copyright?: string;
|
|
115
|
-
/**
|
|
120
|
+
/**
|
|
121
|
+
* Favicon path. No host-root default — that 404s on subpath
|
|
122
|
+
* deploys. The format-astro emitter passes a combined-prefix
|
|
123
|
+
* path (e.g. `/<repo>/favicon.ico`) computed from site.url +
|
|
124
|
+
* basePath. Pass an empty string or `false` to disable. When
|
|
125
|
+
* undefined, no `<link rel="icon">` is emitted.
|
|
126
|
+
*/
|
|
116
127
|
favicon?: string | false;
|
|
117
128
|
/** Per-page OG image URL. Overrides defaultOgImage. */
|
|
118
129
|
ogImage?: string;
|
|
@@ -122,21 +133,34 @@ interface Props {
|
|
|
122
133
|
ogType?: "article" | "website";
|
|
123
134
|
/** Canonical URL for this page. Auto-computed from siteUrl + pathname when absolute siteUrl is provided. */
|
|
124
135
|
canonicalUrl?: string;
|
|
136
|
+
/** `hreflang` alternates for this page's copies in other languages (absolute URLs), written after the canonical link. */
|
|
137
|
+
alternates?: { hreflang: string; href: string }[];
|
|
138
|
+
/** JSON-LD `BreadcrumbList` for this page, written after its own JSON-LD block. */
|
|
139
|
+
breadcrumbJsonLd?: Record<string, unknown>;
|
|
125
140
|
/** Twitter / X handle including the leading "@" */
|
|
126
141
|
twitterHandle?: string;
|
|
127
142
|
/** Theme color hint for browsers (hex string) */
|
|
128
143
|
themeColor?: string;
|
|
129
144
|
/**
|
|
130
|
-
* Emit `<meta name="robots"
|
|
145
|
+
* Emit `noindex` in the `<meta name="robots">` directive when
|
|
131
146
|
* true. Tells external search engines (Google, Bing) to skip
|
|
132
|
-
* this page.
|
|
133
|
-
*
|
|
134
|
-
* page
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
147
|
+
* indexing this page. Independent of `nofollow` — common pattern
|
|
148
|
+
* for tag / index pages is noindex + follow (don't list this
|
|
149
|
+
* page in results, but do crawl through to the real content).
|
|
150
|
+
*
|
|
151
|
+
* Has NO effect on in-site Pagefind search — for that, use
|
|
152
|
+
* `excludeFromSearch`. The two are independent: a page can be
|
|
153
|
+
* excluded from external SEs but still appear in Pagefind (e.g.
|
|
154
|
+
* duplicate / old content readers might still want to find when
|
|
155
|
+
* they're already on the site), or vice versa.
|
|
138
156
|
*/
|
|
139
157
|
noindex?: boolean;
|
|
158
|
+
/**
|
|
159
|
+
* Emit `nofollow` in the `<meta name="robots">` directive when
|
|
160
|
+
* true. Tells crawlers not to follow outbound links from this
|
|
161
|
+
* page. Independent of `noindex`. Default false.
|
|
162
|
+
*/
|
|
163
|
+
nofollow?: boolean;
|
|
140
164
|
/**
|
|
141
165
|
* Exclude this page from in-site Pagefind search results.
|
|
142
166
|
*
|
|
@@ -179,6 +203,12 @@ interface Props {
|
|
|
179
203
|
* a non-root base path (e.g. "/docs/pagefind/").
|
|
180
204
|
*/
|
|
181
205
|
pagefindUrl?: string;
|
|
206
|
+
/**
|
|
207
|
+
* Base Pagefind prefixes result URLs with. Forwarded to SearchDialog;
|
|
208
|
+
* format-astro passes the right value for the deploy layout. See
|
|
209
|
+
* `pagefind-base-url.ts` for why no constant works.
|
|
210
|
+
*/
|
|
211
|
+
pagefindBaseUrl?: string;
|
|
182
212
|
/**
|
|
183
213
|
* Whether the site emits per-page `.md` mirror endpoints. When true,
|
|
184
214
|
* the page advertises `<link rel="alternate" type="text/markdown">`
|
|
@@ -213,6 +243,12 @@ interface Props {
|
|
|
213
243
|
* `taxonomies.tags.labels`.
|
|
214
244
|
*/
|
|
215
245
|
tagLabels?: Record<string, string>;
|
|
246
|
+
/**
|
|
247
|
+
* Color chips whose prefix has no color automatically (a stable
|
|
248
|
+
* palette color per first segment). Sourced from
|
|
249
|
+
* `taxonomies.tags.autoColor`.
|
|
250
|
+
*/
|
|
251
|
+
tagAutoColor?: boolean;
|
|
216
252
|
/**
|
|
217
253
|
* Page lifecycle status. Renders a StatusBadge for non-stable
|
|
218
254
|
* states. Drafts/deprecated get strong colors.
|
|
@@ -333,7 +369,7 @@ interface Props {
|
|
|
333
369
|
* `@/data/switcherMap.json`.
|
|
334
370
|
*/
|
|
335
371
|
switcherMap?: {
|
|
336
|
-
versions: Array<{ id: string; label?: string; eol?: boolean; default?: boolean }>;
|
|
372
|
+
versions: Array<{ id: string; label?: string; eol?: boolean; default?: boolean; root?: boolean }>;
|
|
337
373
|
byLogicalKey: Record<string, Record<string, string>>;
|
|
338
374
|
};
|
|
339
375
|
/**
|
|
@@ -341,6 +377,19 @@ interface Props {
|
|
|
341
377
|
* (`<basePath>/<version>/`). Defaults to "/docs".
|
|
342
378
|
*/
|
|
343
379
|
basePath?: string;
|
|
380
|
+
/**
|
|
381
|
+
* Sidebar navigation render mode.
|
|
382
|
+
*
|
|
383
|
+
* - `"client"` (default): emit the small `<DocsNavClient />` placeholder;
|
|
384
|
+
* the tree is hydrated from `/_dogsbay/nav.json` once per session.
|
|
385
|
+
* Page HTML shrinks dramatically at scale (~50 KB vs ~1.2 MB on a
|
|
386
|
+
* 2k-page site). No-JS users see a sitemap fallback link.
|
|
387
|
+
* - `"ssr-full"`: render the full nav tree into every page's HTML.
|
|
388
|
+
* Best for very small sites or strict no-JS / SEO contexts.
|
|
389
|
+
*
|
|
390
|
+
* See plans/client-rendered-nav.md.
|
|
391
|
+
*/
|
|
392
|
+
navMode?: "client" | "ssr-full";
|
|
344
393
|
/**
|
|
345
394
|
* Per-page LLM action UI. When set and `enabled !== false`, renders
|
|
346
395
|
* the PageActions cluster (Copy markdown + Open in Claude/ChatGPT/
|
|
@@ -375,16 +424,74 @@ interface Props {
|
|
|
375
424
|
* in per-page.
|
|
376
425
|
*/
|
|
377
426
|
wideLayout?: boolean;
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Which chrome this page wears.
|
|
430
|
+
*
|
|
431
|
+
* `"docs"` (default) is the full documentation shell. `"blog"` drops
|
|
432
|
+
* the sidebar and its nav fetch, and turns on the byline row — the
|
|
433
|
+
* blog is the same SITE, not a second design system, so the header,
|
|
434
|
+
* footer, search, page actions, TOC and every theme token stay
|
|
435
|
+
* identical. See plans/blog-capability.md Phase 3.
|
|
436
|
+
*/
|
|
437
|
+
chrome?: "docs" | "blog";
|
|
438
|
+
|
|
439
|
+
/** Post authors. No byline is rendered when absent — never a placeholder. */
|
|
440
|
+
author?: string[];
|
|
441
|
+
/** ISO publish date for the byline. */
|
|
442
|
+
publishedDate?: string;
|
|
443
|
+
/** ISO updated date; shown only when it differs from publishedDate. */
|
|
444
|
+
updatedDate?: string;
|
|
445
|
+
/**
|
|
446
|
+
* Reading estimate in whole minutes. Computed at EMIT time from the
|
|
447
|
+
* TreeNode word count — a browser-side count would re-walk the DOM on
|
|
448
|
+
* every load for a number that cannot change.
|
|
449
|
+
*/
|
|
450
|
+
readingMinutes?: number;
|
|
451
|
+
/** Optional hero image URL, rendered above the prose. */
|
|
452
|
+
heroImage?: string;
|
|
453
|
+
/**
|
|
454
|
+
* Table-of-contents placement. Default `"top"`.
|
|
455
|
+
* - `"top"` — expandable "On this page" disclosure at the top of the
|
|
456
|
+
* article, identical on desktop and mobile; frees the right rail for the
|
|
457
|
+
* `right-rail` named slot (e.g. an Ask AI panel).
|
|
458
|
+
* - `"popover"` — an "On this page" dropdown in the header.
|
|
459
|
+
* - `"rail"` — the classic right-hand TOC sidebar (pre-`toc` behaviour).
|
|
460
|
+
* - `"off"` — no table of contents.
|
|
461
|
+
* See plans/ask-branch1-placement-toc.md.
|
|
462
|
+
*/
|
|
463
|
+
toc?: TocMode;
|
|
464
|
+
/**
|
|
465
|
+
* Internal/external link-icon glyphs. When set, a small trailing icon
|
|
466
|
+
* marks links in the prose — external (absolute / protocol-relative
|
|
467
|
+
* href) vs internal (root / relative) — classified purely by href
|
|
468
|
+
* shape in CSS. Either side may be absent to mark only that link kind.
|
|
469
|
+
* Sourced from `content.linkIcons` in `dogsbay.config.yml`.
|
|
470
|
+
* See plans/link-resolution-and-icons.md.
|
|
471
|
+
*/
|
|
472
|
+
linkIcons?: {
|
|
473
|
+
external?: string;
|
|
474
|
+
internal?: string;
|
|
475
|
+
};
|
|
476
|
+
/**
|
|
477
|
+
* "Powered by DogsBay" footer attribution. Generated sites pass
|
|
478
|
+
* `siteConfig.poweredBy`, which defaults to true and is turned off with
|
|
479
|
+
* `poweredBy: false` in dogsbay.config.yml. See docs/powered-by.md.
|
|
480
|
+
*/
|
|
481
|
+
poweredBy?: boolean;
|
|
378
482
|
class?: string;
|
|
379
483
|
}
|
|
380
484
|
|
|
381
485
|
const {
|
|
486
|
+
poweredBy = false,
|
|
382
487
|
siteName,
|
|
488
|
+
siteTagline,
|
|
383
489
|
title,
|
|
384
490
|
siteUrl = "/",
|
|
385
491
|
nav,
|
|
386
492
|
navGroups,
|
|
387
493
|
headings = [],
|
|
494
|
+
toc = "top",
|
|
388
495
|
prev,
|
|
389
496
|
next,
|
|
390
497
|
repoUrl,
|
|
@@ -393,24 +500,35 @@ const {
|
|
|
393
500
|
editUrl,
|
|
394
501
|
lastUpdated,
|
|
395
502
|
copyright,
|
|
396
|
-
favicon
|
|
503
|
+
favicon,
|
|
397
504
|
ogImage,
|
|
398
505
|
defaultOgImage,
|
|
399
506
|
ogType = "article",
|
|
400
507
|
canonicalUrl,
|
|
508
|
+
alternates = [],
|
|
509
|
+
breadcrumbJsonLd,
|
|
401
510
|
twitterHandle,
|
|
402
511
|
themeColor,
|
|
403
512
|
noindex,
|
|
513
|
+
nofollow,
|
|
404
514
|
excludeFromSearch,
|
|
405
515
|
plausibleDomain,
|
|
406
516
|
plausibleScriptUrl,
|
|
407
517
|
hideSearch = false,
|
|
408
|
-
pagefindUrl
|
|
518
|
+
// No default for pagefindUrl — host-root absolute paths break on
|
|
519
|
+
// subpath-mounted deploys (GH Pages project pages, multi-mount
|
|
520
|
+
// Cloudflare). format-astro's emitter always passes the
|
|
521
|
+
// combined-prefix-aware URL; manual instantiation must too.
|
|
522
|
+
// Undefined here propagates to SearchDialog where the JS loader
|
|
523
|
+
// throws on first open instead of silently 404'ing the bundle.
|
|
524
|
+
pagefindUrl,
|
|
525
|
+
pagefindBaseUrl,
|
|
409
526
|
mdMirror = false,
|
|
410
527
|
tags,
|
|
411
528
|
tagsIndexPath = "/tags",
|
|
412
529
|
tagPrefixes,
|
|
413
530
|
tagLabels,
|
|
531
|
+
tagAutoColor = false,
|
|
414
532
|
status,
|
|
415
533
|
pageType,
|
|
416
534
|
audience,
|
|
@@ -425,10 +543,28 @@ const {
|
|
|
425
543
|
multiSource,
|
|
426
544
|
switcherMap,
|
|
427
545
|
basePath,
|
|
546
|
+
navMode = "client",
|
|
428
547
|
wideLayout = false,
|
|
548
|
+
chrome = "docs",
|
|
549
|
+
author,
|
|
550
|
+
publishedDate,
|
|
551
|
+
updatedDate,
|
|
552
|
+
readingMinutes,
|
|
553
|
+
heroImage,
|
|
554
|
+
linkIcons,
|
|
429
555
|
class: className,
|
|
430
556
|
} = Astro.props;
|
|
431
557
|
|
|
558
|
+
// The version served at the site root (`path: ""`), if any: the sidebar
|
|
559
|
+
// keeps its pages by leaving out the other versions' folders.
|
|
560
|
+
const navRootVersion = rootVersionFilter(switcherMap?.versions);
|
|
561
|
+
|
|
562
|
+
// Link icons — classified in CSS by href shape. `data-link-icons`
|
|
563
|
+
// lists which kinds are active; the glyphs ride in CSS custom props
|
|
564
|
+
// (single-quoted so they're valid `content:` string values).
|
|
565
|
+
const { tokens: _linkIconTokens, style: _linkIconStyle } =
|
|
566
|
+
linkIconAttrs(linkIcons);
|
|
567
|
+
|
|
432
568
|
// Resolve LLM action visibility + placement once. The component
|
|
433
569
|
// guards against missing markdownBody / mdUrl internally, but we
|
|
434
570
|
// also gate at the layout level so the slots stay empty when
|
|
@@ -475,20 +611,108 @@ const hasMetaStrip = (
|
|
|
475
611
|
(typeof pageType === "string" && pageType.length > 0)
|
|
476
612
|
);
|
|
477
613
|
|
|
614
|
+
// TOC placement: which container(s) + the right-rail plugin region render.
|
|
615
|
+
// Logic lives in toc-placement.ts so it's unit-tested; this file just consumes.
|
|
616
|
+
const tocPlacement = resolveTocPlacement(toc, {
|
|
617
|
+
// Only show a TOC when the page has 2+ displayable headings (depth 2–3);
|
|
618
|
+
// the H1 doesn't count and a one-item TOC is noise. Keeps "On this page"
|
|
619
|
+
// off landing/welcome pages that are just an H1 + prose.
|
|
620
|
+
hasHeadings: hasDisplayableToc(headings),
|
|
621
|
+
wideLayout: !!wideLayout,
|
|
622
|
+
});
|
|
623
|
+
|
|
478
624
|
const currentPath = Astro.url.pathname.replace(/\/$/, "") || "/";
|
|
479
625
|
|
|
480
626
|
// SEO computation
|
|
481
627
|
const metaDescription = description ?? siteDescription;
|
|
482
628
|
const metaOgImage = ogImage ?? defaultOgImage;
|
|
483
629
|
const isAbsoluteSiteUrl = /^https?:\/\//.test(siteUrl);
|
|
630
|
+
// Compose canonical from ORIGIN + pathname. siteUrl may carry a
|
|
631
|
+
// path component (the urlBase that drives Astro's `base` — see
|
|
632
|
+
// plans/astro-base-from-site-url.md), and Astro.url.pathname
|
|
633
|
+
// already includes that prefix. Naively concatenating siteUrl +
|
|
634
|
+
// pathname double-counts the urlBase (e.g. .../repo/repo/page).
|
|
635
|
+
// Strip path off siteUrl by reparsing as a URL.
|
|
636
|
+
let canonicalOrigin: string | undefined;
|
|
637
|
+
if (isAbsoluteSiteUrl) {
|
|
638
|
+
try {
|
|
639
|
+
const u = new URL(siteUrl);
|
|
640
|
+
canonicalOrigin = `${u.protocol}//${u.host}`;
|
|
641
|
+
} catch {
|
|
642
|
+
// Malformed siteUrl — fall back to the original (no path) behavior.
|
|
643
|
+
canonicalOrigin = siteUrl.replace(/\/$/, "");
|
|
644
|
+
}
|
|
645
|
+
}
|
|
484
646
|
const computedCanonical = canonicalUrl
|
|
485
|
-
?? (
|
|
486
|
-
?
|
|
647
|
+
?? (canonicalOrigin
|
|
648
|
+
? canonicalOrigin + Astro.url.pathname
|
|
487
649
|
: undefined);
|
|
488
650
|
|
|
489
|
-
|
|
651
|
+
/**
|
|
652
|
+
* Format an ISO date for a byline.
|
|
653
|
+
*
|
|
654
|
+
* Runs at BUILD time, so the output is baked into the HTML and every
|
|
655
|
+
* reader sees the same string — no hydration flash, and no dependence on
|
|
656
|
+
* the visitor's locale for a date the author wrote. Falls back to the
|
|
657
|
+
* raw value if it will not parse, so a typo shows up as itself rather
|
|
658
|
+
* than as "Invalid Date".
|
|
659
|
+
*/
|
|
660
|
+
/**
|
|
661
|
+
* A value as a machine-readable ISO 8601 datetime, or "" when it is
|
|
662
|
+
* not a date at all.
|
|
663
|
+
*
|
|
664
|
+
* Unlike `_formatPostDate`, an unparseable value must NOT pass
|
|
665
|
+
* through: this feeds `article:published_time` / `modified_time`,
|
|
666
|
+
* where a malformed datetime is worse than an absent one — a consumer
|
|
667
|
+
* either ignores the whole article object or shows garbage.
|
|
668
|
+
* `lastUpdated` is documented as also accepting a version/tag string
|
|
669
|
+
* ("v2.1"), which must never reach these tags.
|
|
670
|
+
*/
|
|
671
|
+
function isoDate(value: string | undefined): string {
|
|
672
|
+
if (!value) return "";
|
|
673
|
+
const d = new Date(value);
|
|
674
|
+
return Number.isNaN(d.getTime()) ? "" : d.toISOString();
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function _formatPostDate(iso: string): string {
|
|
678
|
+
const d = new Date(iso);
|
|
679
|
+
if (Number.isNaN(d.getTime())) return iso;
|
|
680
|
+
// timeZone: "UTC" is load-bearing. parseMeta normalizes `created:
|
|
681
|
+
// 2026-01-01` to "2026-01-01T00:00:00.000Z", so a build host in any
|
|
682
|
+
// negative-offset zone (the default on many CI runners) would render
|
|
683
|
+
// "31 December 2025". DocsFooter already carries this fix; these
|
|
684
|
+
// copies dropped it.
|
|
685
|
+
return d.toLocaleDateString("en-GB", {
|
|
686
|
+
year: "numeric",
|
|
687
|
+
month: "long",
|
|
688
|
+
day: "numeric",
|
|
689
|
+
timeZone: "UTC",
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// Markdown mirror for the alternate link.
|
|
694
|
+
//
|
|
695
|
+
// Leaf pages append `.md` to the trimmed path (`/getting-started/` →
|
|
696
|
+
// `/getting-started.md`), matching the emitted `<slug>.md.ts` endpoint.
|
|
697
|
+
// The SITE INDEX is the exception: it is emitted as `index.md.ts`, so
|
|
698
|
+
// its mirror is `<base>/index.md`. Appending `.md` there produced
|
|
699
|
+
// `/.md` (root-served) or `/blog.md` (mounted) — neither exists, and on
|
|
700
|
+
// a mounted site `/blog.md` also falls outside the `/blog/*` route.
|
|
701
|
+
// Both leaf and index URLs carry a trailing slash under Astro's
|
|
702
|
+
// directory build format, so only "is this the site root" distinguishes
|
|
703
|
+
// them. Kept in step with `shouldRewriteToMarkdown`, which is handed the
|
|
704
|
+
// same combined prefix.
|
|
705
|
+
// Use the `basePath` PROP, not import.meta.env.BASE_URL. Astro's `base`
|
|
706
|
+
// carries only site.url's path (urlBase); the prop carries the COMBINED
|
|
707
|
+
// urlBase + site.basePath prefix — the same value the middleware gets —
|
|
708
|
+
// so on a `site.basePath: /docs` deploy the two would otherwise disagree
|
|
709
|
+
// about which page is the index.
|
|
710
|
+
const mdMirrorBase = (basePath ?? "").replace(/\/+$/, "");
|
|
711
|
+
const mdMirrorPath = Astro.url.pathname.replace(/\/$/, "");
|
|
490
712
|
const mdMirrorHref = mdMirror
|
|
491
|
-
?
|
|
713
|
+
? mdMirrorPath === mdMirrorBase
|
|
714
|
+
? `${mdMirrorBase}/index.md`
|
|
715
|
+
: `${mdMirrorPath}.md`
|
|
492
716
|
: undefined;
|
|
493
717
|
|
|
494
718
|
// Tag keywords for HTML meta + JSON-LD. Slug-based identifiers
|
|
@@ -509,17 +733,30 @@ const tagKeywords = resolveTagKeywords(tags, tagLabels);
|
|
|
509
733
|
// Course; everything else → Article. Matches the conventions
|
|
510
734
|
// search engines key off for educational / tutorial / reference
|
|
511
735
|
// SERP rendering. See `json-ld.ts` for the full mapping table.
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
736
|
+
// Structured data is emitted for EVERY page, not only tagged articles.
|
|
737
|
+
// This used to also require `tagKeywords.length > 0`, so an untagged
|
|
738
|
+
// page — a homepage, a landing page, a section index — emitted none at
|
|
739
|
+
// all. An external agent audit reported "No JSON-LD structured data
|
|
740
|
+
// found on homepage" and it was right: the page most likely to be an
|
|
741
|
+
// agent's entry point was the one describing itself the least.
|
|
742
|
+
// Keywords are enrichment, not a precondition for identity.
|
|
743
|
+
const articleJsonLd = ogType === "article"
|
|
744
|
+
? buildArticleJsonLd({
|
|
745
|
+
type: jsonLdTypeFor(pageType),
|
|
746
|
+
title,
|
|
747
|
+
siteName,
|
|
748
|
+
keywords: tagKeywords,
|
|
749
|
+
description: metaDescription,
|
|
750
|
+
image: metaOgImage,
|
|
751
|
+
url: computedCanonical,
|
|
752
|
+
headings,
|
|
753
|
+
})
|
|
754
|
+
: buildWebSiteJsonLd({
|
|
755
|
+
siteName,
|
|
756
|
+
title,
|
|
757
|
+
description: metaDescription,
|
|
758
|
+
url: computedCanonical,
|
|
759
|
+
});
|
|
523
760
|
|
|
524
761
|
// `customJsonLd` accepts either a single object or an array.
|
|
525
762
|
// Normalize to an array so we can iterate uniformly. Empty array
|
|
@@ -537,12 +774,126 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
|
|
|
537
774
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
538
775
|
<title>{title} | {siteName}</title>
|
|
539
776
|
|
|
777
|
+
<style is:global>
|
|
778
|
+
/*
|
|
779
|
+
The sticky-chrome contract. This header is exactly this tall, and
|
|
780
|
+
anything else that sticks (the release-comparison toolbar) offsets
|
|
781
|
+
by the same variable — so they cannot drift apart.
|
|
782
|
+
*/
|
|
783
|
+
:root {
|
|
784
|
+
--db-header-height: 3rem;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
/*
|
|
788
|
+
Diagrams drawn by Pele (`content.diagrams: pele`, plans/pele-diagrams.md).
|
|
789
|
+
Pele colours its SVGs from these variables, so a diagram takes the theme
|
|
790
|
+
and .dark switches them. Here rather than in Diagram.astro or global.css:
|
|
791
|
+
those are copied into a site once, this package is re-pinned on every
|
|
792
|
+
build. Borders are thin, so the theme's --border alone is too faint for
|
|
793
|
+
node outlines. The base mapping is the same as dogsbay-ssg's ssg.css.
|
|
794
|
+
*/
|
|
795
|
+
.pele {
|
|
796
|
+
--pele-bg: var(--background);
|
|
797
|
+
--pele-fg: var(--foreground);
|
|
798
|
+
--pele-muted: var(--muted-foreground);
|
|
799
|
+
--pele-line: var(--foreground);
|
|
800
|
+
--pele-surface: var(--muted);
|
|
801
|
+
--pele-surface-alt: color-mix(in oklab, var(--muted), var(--foreground) 7%);
|
|
802
|
+
--pele-border: color-mix(in oklab, var(--border), var(--muted-foreground) 45%);
|
|
803
|
+
--pele-accent: var(--primary);
|
|
804
|
+
--pele-font: var(--font-sans);
|
|
805
|
+
--pele-font-mono: var(--font-code, monospace);
|
|
806
|
+
--pele-radius: calc(var(--radius) - 4px);
|
|
807
|
+
/*
|
|
808
|
+
Series colours (pie, xy, git, timeline, treemap, …; gantt's critical
|
|
809
|
+
tasks). Pele writes labels in --pele-bg on these fills, so they are
|
|
810
|
+
darker in light mode and lighter in dark mode. Its own palette is
|
|
811
|
+
fixed, and put white text on yellow (1.6:1). Same hues, in Pele's
|
|
812
|
+
order. dogsbay-ssg's ssg.css does not map these yet.
|
|
813
|
+
*/
|
|
814
|
+
--pele-series-1: oklch(0.5 0.13 250);
|
|
815
|
+
--pele-series-2: oklch(0.5 0.15 55);
|
|
816
|
+
--pele-series-3: oklch(0.5 0.13 145);
|
|
817
|
+
--pele-series-4: oklch(0.5 0.16 25);
|
|
818
|
+
--pele-series-5: oklch(0.5 0.09 195);
|
|
819
|
+
--pele-series-6: oklch(0.5 0.12 95);
|
|
820
|
+
--pele-series-7: oklch(0.5 0.12 320);
|
|
821
|
+
--pele-series-8: oklch(0.5 0.06 60);
|
|
822
|
+
--pele-critical: var(--pele-series-4);
|
|
823
|
+
--pele-error: var(--pele-series-4);
|
|
824
|
+
}
|
|
825
|
+
.dark .pele {
|
|
826
|
+
--pele-series-1: oklch(0.78 0.13 250);
|
|
827
|
+
--pele-series-2: oklch(0.78 0.15 55);
|
|
828
|
+
--pele-series-3: oklch(0.78 0.13 145);
|
|
829
|
+
--pele-series-4: oklch(0.78 0.16 25);
|
|
830
|
+
--pele-series-5: oklch(0.78 0.09 195);
|
|
831
|
+
--pele-series-6: oklch(0.78 0.12 95);
|
|
832
|
+
--pele-series-7: oklch(0.78 0.12 320);
|
|
833
|
+
--pele-series-8: oklch(0.78 0.06 60);
|
|
834
|
+
}
|
|
835
|
+
/*
|
|
836
|
+
Diagram.astro inverts diagrams in dark mode, for Mermaid's fixed colours;
|
|
837
|
+
Pele's are already dark. Unlayered, so this wins over the dark:invert and
|
|
838
|
+
dark:hue-rotate utilities.
|
|
839
|
+
*/
|
|
840
|
+
.diagram-content:has(> svg.pele),
|
|
841
|
+
.diagram-content > svg.pele {
|
|
842
|
+
filter: none;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/*
|
|
846
|
+
Internal/external link icons. Opt-in via `content.linkIcons`,
|
|
847
|
+
which sets `data-link-icons` (which kinds are active) and the
|
|
848
|
+
`--db-link-icon-*` custom props (the glyphs) on <body>. Links are
|
|
849
|
+
classified purely by href shape, so it covers both structured
|
|
850
|
+
links and raw-HTML anchors from imported content. Scoped to
|
|
851
|
+
`.docs-prose` so nav/header/footer links are untouched; buttons
|
|
852
|
+
(`<a data-variant>`, from Button.astro) carry no icon.
|
|
853
|
+
*/
|
|
854
|
+
body[data-link-icons~="external"] .docs-prose a[href^="http://" i]:not([data-variant])::after,
|
|
855
|
+
body[data-link-icons~="external"] .docs-prose a[href^="https://" i]:not([data-variant])::after,
|
|
856
|
+
body[data-link-icons~="external"] .docs-prose a[href^="//"]:not([data-variant])::after {
|
|
857
|
+
content: var(--db-link-icon-external, "");
|
|
858
|
+
display: inline-block;
|
|
859
|
+
margin-inline-start: 0.15em;
|
|
860
|
+
font-size: 0.85em;
|
|
861
|
+
line-height: 1;
|
|
862
|
+
vertical-align: baseline;
|
|
863
|
+
opacity: 0.7;
|
|
864
|
+
}
|
|
865
|
+
body[data-link-icons~="internal"]
|
|
866
|
+
.docs-prose
|
|
867
|
+
a[href^="/"]:not([href^="//"]):not([data-variant])::after,
|
|
868
|
+
body[data-link-icons~="internal"] .docs-prose a[href^="./"]:not([data-variant])::after,
|
|
869
|
+
body[data-link-icons~="internal"] .docs-prose a[href^="../"]:not([data-variant])::after {
|
|
870
|
+
content: var(--db-link-icon-internal, "");
|
|
871
|
+
display: inline-block;
|
|
872
|
+
margin-inline-start: 0.15em;
|
|
873
|
+
font-size: 0.85em;
|
|
874
|
+
line-height: 1;
|
|
875
|
+
vertical-align: baseline;
|
|
876
|
+
opacity: 0.7;
|
|
877
|
+
}
|
|
878
|
+
</style>
|
|
879
|
+
|
|
540
880
|
{metaDescription && <meta name="description" content={metaDescription} />}
|
|
541
|
-
{favicon
|
|
881
|
+
{favicon && <link rel="icon" href={favicon} />}
|
|
542
882
|
{themeColor && <meta name="theme-color" content={themeColor} />}
|
|
543
883
|
{/* External search engine directive — orthogonal to in-site
|
|
544
|
-
Pagefind exclusion.
|
|
545
|
-
|
|
884
|
+
Pagefind exclusion. `noindex` + `nofollow` are independent
|
|
885
|
+
bits per the meta-robots spec; emit only the directives that
|
|
886
|
+
are set. Combining them when both are set keeps the tag
|
|
887
|
+
compact (`<meta name="robots" content="noindex, nofollow">`)
|
|
888
|
+
instead of emitting two tags. */}
|
|
889
|
+
{(noindex || nofollow) && (
|
|
890
|
+
<meta
|
|
891
|
+
name="robots"
|
|
892
|
+
content={[noindex && "noindex", nofollow && "nofollow"]
|
|
893
|
+
.filter(Boolean)
|
|
894
|
+
.join(", ")}
|
|
895
|
+
/>
|
|
896
|
+
)}
|
|
546
897
|
|
|
547
898
|
{/* In-site Pagefind exclusion is wired via two coordinated
|
|
548
899
|
attributes on <body> and <main> below — see the prop docs
|
|
@@ -556,7 +907,14 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
|
|
|
556
907
|
sidebar / header / TOC text from per-page excerpts. See main. */}
|
|
557
908
|
|
|
558
909
|
{computedCanonical && <link rel="canonical" href={computedCanonical} />}
|
|
910
|
+
{alternates.map((a) => <link rel="alternate" hreflang={a.hreflang} href={a.href} />)}
|
|
559
911
|
{mdMirrorHref && <link rel="alternate" type="text/markdown" href={mdMirrorHref} />}
|
|
912
|
+
{/* Programmatic llms.txt discovery — agents that follow head
|
|
913
|
+
link rels get the per-mount llms.txt without parsing HTML.
|
|
914
|
+
Mirrors the existing _headers Link rel="describedby" used by
|
|
915
|
+
Cloudflare Pages / Workers. */}
|
|
916
|
+
<link rel="alternate" type="text/plain" title="llms.txt" href={llmsLinkHrefResolved} />
|
|
917
|
+
<link rel="describedby" type="text/plain" href={llmsLinkHrefResolved} />
|
|
560
918
|
|
|
561
919
|
{/* Open Graph */}
|
|
562
920
|
<meta property="og:type" content={ogType} />
|
|
@@ -571,13 +929,36 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
|
|
|
571
929
|
{ogType === "article" && tagKeywords.map((keyword) => (
|
|
572
930
|
<meta property="article:tag" content={keyword} />
|
|
573
931
|
))}
|
|
932
|
+
{/* Article dates. `og:type="article"` was declared with no times
|
|
933
|
+
at all, which is an incomplete article object — crawlers and
|
|
934
|
+
social cards had no publish date to show even though the page
|
|
935
|
+
renders one visibly.
|
|
936
|
+
|
|
937
|
+
`article:modified_time` falls back to `lastUpdated`, the prop
|
|
938
|
+
the docs chrome already uses for "Last updated", so a docs page
|
|
939
|
+
gets a modified time without the blog-only `updatedDate` being
|
|
940
|
+
set. Only emitted when the value parses as a date: a
|
|
941
|
+
frontmatter `lastUpdated: v2.1` is a version string, and a
|
|
942
|
+
malformed datetime is worse than an absent one. */}
|
|
943
|
+
{ogType === "article" && isoDate(publishedDate) && (
|
|
944
|
+
<meta property="article:published_time" content={isoDate(publishedDate)} />
|
|
945
|
+
)}
|
|
946
|
+
{ogType === "article" && isoDate(updatedDate ?? lastUpdated) && (
|
|
947
|
+
<meta
|
|
948
|
+
property="article:modified_time"
|
|
949
|
+
content={isoDate(updatedDate ?? lastUpdated)}
|
|
950
|
+
/>
|
|
951
|
+
)}
|
|
574
952
|
|
|
575
953
|
{/* JSON-LD primary block — Article / HowTo / TechArticle /
|
|
576
954
|
Course depending on `pageType`. Emits keywords as a
|
|
577
955
|
Schema.org keywords string; Google reads this for topical
|
|
578
956
|
categorization in rich results. */}
|
|
579
957
|
{articleJsonLd && (
|
|
580
|
-
<script type="application/ld+json" set:html={JSON.stringify(articleJsonLd)} />
|
|
958
|
+
<script type="application/ld+json" set:html={JSON.stringify(articleJsonLd).replace(/</g, "\\u003c")} />
|
|
959
|
+
)}
|
|
960
|
+
{breadcrumbJsonLd && (
|
|
961
|
+
<script type="application/ld+json" set:html={JSON.stringify(breadcrumbJsonLd).replace(/</g, "\\u003c")} />
|
|
581
962
|
)}
|
|
582
963
|
|
|
583
964
|
{/* Per-page / per-site custom structured data. Layered on
|
|
@@ -604,29 +985,69 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
|
|
|
604
985
|
></script>
|
|
605
986
|
)}
|
|
606
987
|
|
|
988
|
+
{/*
|
|
989
|
+
Theme bootstrap. Inline and BLOCKING on purpose — it must run
|
|
990
|
+
before first paint or the page flashes light before going dark.
|
|
991
|
+
|
|
992
|
+
An explicit choice wins in BOTH directions. Someone on a
|
|
993
|
+
dark-mode OS who deliberately picked light must stay light, so
|
|
994
|
+
"light" is checked rather than treated as "not dark". Only the
|
|
995
|
+
absence of a stored value falls through to the OS preference.
|
|
996
|
+
|
|
997
|
+
That fallback is the point of this block. Previously the only
|
|
998
|
+
condition was `=== "dark"`, so a visitor whose OS is in dark mode
|
|
999
|
+
got a light page until they found the toggle — and nothing else
|
|
1000
|
+
in the CSS consulted prefers-color-scheme either. It also made
|
|
1001
|
+
dark mode untestable by any tool that emulates the media query
|
|
1002
|
+
(DevTools' Rendering panel, Playwright's colorScheme), because
|
|
1003
|
+
the page did not read it.
|
|
1004
|
+
|
|
1005
|
+
localStorage access throws in some privacy modes; a throw here
|
|
1006
|
+
would skip the class entirely, so it is guarded and degrades to
|
|
1007
|
+
the OS preference.
|
|
1008
|
+
*/}
|
|
607
1009
|
<script is:inline>
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
1010
|
+
(function () {
|
|
1011
|
+
var stored = null;
|
|
1012
|
+
try {
|
|
1013
|
+
stored = localStorage.getItem("theme");
|
|
1014
|
+
} catch (e) {}
|
|
1015
|
+
var dark =
|
|
1016
|
+
stored === "dark" ||
|
|
1017
|
+
(stored !== "light" &&
|
|
1018
|
+
window.matchMedia("(prefers-color-scheme: dark)").matches);
|
|
1019
|
+
if (dark) document.documentElement.classList.add("dark");
|
|
1020
|
+
})();
|
|
611
1021
|
</script>
|
|
612
1022
|
<slot name="head" />
|
|
613
1023
|
</head>
|
|
614
1024
|
<body
|
|
615
1025
|
class="bg-background text-foreground antialiased"
|
|
616
1026
|
data-pagefind-ignore={excludeFromSearch ? "" : undefined}
|
|
1027
|
+
data-link-icons={_linkIconTokens || undefined}
|
|
1028
|
+
style={_linkIconStyle || undefined}
|
|
617
1029
|
>
|
|
618
1030
|
<SidebarProvider>
|
|
1031
|
+
{/*
|
|
1032
|
+
chrome="blog" drops the sidebar COLUMN only. SidebarProvider and
|
|
1033
|
+
SidebarInset stay, so the content column, header offset and
|
|
1034
|
+
sticky contract are byte-identical to a docs page — which is what
|
|
1035
|
+
makes the blog read as the same site rather than a lookalike.
|
|
1036
|
+
With no <Sidebar> sibling the inset's peer-* selectors simply do
|
|
1037
|
+
not match and it fills the width.
|
|
1038
|
+
*/}
|
|
1039
|
+
{chrome === "docs" && (
|
|
619
1040
|
<Sidebar collapsible="icon">
|
|
620
1041
|
<SidebarHeader>
|
|
621
1042
|
<SidebarMenu>
|
|
622
1043
|
<SidebarMenuItem>
|
|
623
|
-
<SidebarMenuButton size="lg" href={
|
|
1044
|
+
<SidebarMenuButton size="lg" href={basePath || "/"} isActive={currentPath === basePath || currentPath === "/"}>
|
|
624
1045
|
<div class="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
|
|
625
1046
|
<Fragment set:html={siteIcon} />
|
|
626
1047
|
</div>
|
|
627
1048
|
<div class="grid flex-1 text-left text-sm leading-tight">
|
|
628
1049
|
<span class="truncate font-semibold">{siteName}</span>
|
|
629
|
-
<span class="truncate text-xs text-sidebar-foreground/70">Documentation</span>
|
|
1050
|
+
<span class="truncate text-xs text-sidebar-foreground/70">{siteTagline || "Documentation"}</span>
|
|
630
1051
|
</div>
|
|
631
1052
|
</SidebarMenuButton>
|
|
632
1053
|
</SidebarMenuItem>
|
|
@@ -636,7 +1057,27 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
|
|
|
636
1057
|
<SidebarSeparator />
|
|
637
1058
|
|
|
638
1059
|
<SidebarContent>
|
|
639
|
-
{
|
|
1060
|
+
{navMode === "client" ? (
|
|
1061
|
+
// Client-render mode: emit one DocsNavClient placeholder
|
|
1062
|
+
// inside a single SidebarGroup, regardless of navGroups.
|
|
1063
|
+
// The group-label shape doesn't apply when the tree is
|
|
1064
|
+
// hydrated by JS — multi-group nav is reconstructed
|
|
1065
|
+
// client-side from the same /_dogsbay/nav.json shape.
|
|
1066
|
+
// See plans/client-rendered-nav.md.
|
|
1067
|
+
<SidebarGroup>
|
|
1068
|
+
<SidebarGroupContent>
|
|
1069
|
+
<DocsNavClient
|
|
1070
|
+
currentPath={currentPath}
|
|
1071
|
+
basePath={basePath ?? ""}
|
|
1072
|
+
namespace={multiSource?.namespace}
|
|
1073
|
+
version={multiSource?.version}
|
|
1074
|
+
locale={multiSource?.locale}
|
|
1075
|
+
rootVersion={navRootVersion.rootVersion}
|
|
1076
|
+
versionSegments={navRootVersion.versionSegments}
|
|
1077
|
+
/>
|
|
1078
|
+
</SidebarGroupContent>
|
|
1079
|
+
</SidebarGroup>
|
|
1080
|
+
) : navGroups ? (
|
|
640
1081
|
navGroups.map(group => (
|
|
641
1082
|
<SidebarGroup>
|
|
642
1083
|
<SidebarGroupLabel>{group.label}</SidebarGroupLabel>
|
|
@@ -644,8 +1085,10 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
|
|
|
644
1085
|
<SidebarNavTree
|
|
645
1086
|
items={filterNavByAxis(group.items, {
|
|
646
1087
|
basePath: basePath ?? "/docs",
|
|
1088
|
+
namespace: multiSource?.namespace,
|
|
647
1089
|
version: multiSource?.version,
|
|
648
1090
|
locale: multiSource?.locale,
|
|
1091
|
+
...navRootVersion,
|
|
649
1092
|
})}
|
|
650
1093
|
currentPath={currentPath}
|
|
651
1094
|
/>
|
|
@@ -658,8 +1101,10 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
|
|
|
658
1101
|
<SidebarNavTree
|
|
659
1102
|
items={filterNavByAxis(nav, {
|
|
660
1103
|
basePath: basePath ?? "/docs",
|
|
1104
|
+
namespace: multiSource?.namespace,
|
|
661
1105
|
version: multiSource?.version,
|
|
662
1106
|
locale: multiSource?.locale,
|
|
1107
|
+
...navRootVersion,
|
|
663
1108
|
})}
|
|
664
1109
|
currentPath={currentPath}
|
|
665
1110
|
/>
|
|
@@ -670,14 +1115,31 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
|
|
|
670
1115
|
|
|
671
1116
|
<SidebarRail />
|
|
672
1117
|
</Sidebar>
|
|
1118
|
+
)}
|
|
673
1119
|
|
|
674
1120
|
<SidebarInset>
|
|
675
|
-
|
|
676
|
-
|
|
1121
|
+
{/*
|
|
1122
|
+
Height comes from `--db-header-height` (declared on :root in
|
|
1123
|
+
<head>) — the CONTRACT any other sticky chrome offsets by, e.g.
|
|
1124
|
+
the release-comparison toolbar. One variable, so a change here
|
|
1125
|
+
can never leave another sticky bar overlapping or floating.
|
|
1126
|
+
*/}
|
|
1127
|
+
<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">
|
|
1128
|
+
{chrome === "docs" && <SidebarTrigger class="-ml-1" />}
|
|
677
1129
|
<Separator orientation="vertical" class="mr-2 h-4" />
|
|
678
1130
|
<span class="text-sm text-muted-foreground" data-page-title>{title}</span>
|
|
679
1131
|
<div class="ml-auto flex items-center gap-2">
|
|
680
1132
|
<slot name="header" />
|
|
1133
|
+
{tocPlacement.popoverToc && (
|
|
1134
|
+
<details class="dba-toc-popover relative" data-toc-popover>
|
|
1135
|
+
<summary class="cursor-pointer list-none rounded-md px-2 py-1.5 text-sm text-muted-foreground hover:bg-accent hover:text-foreground">
|
|
1136
|
+
On this page
|
|
1137
|
+
</summary>
|
|
1138
|
+
<div class="absolute right-0 z-50 mt-1 max-h-[70vh] w-64 overflow-y-auto rounded-md border bg-background p-3 shadow-md">
|
|
1139
|
+
<DocsToc headings={headings} title="" />
|
|
1140
|
+
</div>
|
|
1141
|
+
</details>
|
|
1142
|
+
)}
|
|
681
1143
|
{switcherMap && multiSource && (
|
|
682
1144
|
<LocaleSwitcher
|
|
683
1145
|
switcherMap={switcherMap}
|
|
@@ -704,7 +1166,11 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
|
|
|
704
1166
|
{!hideSearch && (
|
|
705
1167
|
<SearchDialog
|
|
706
1168
|
pagefindUrl={pagefindUrl}
|
|
1169
|
+
pagefindBaseUrl={pagefindBaseUrl}
|
|
1170
|
+
navUrl={basePath ? `${basePath}/_dogsbay/nav.json` : "/_dogsbay/nav.json"}
|
|
707
1171
|
taxonomyDisplay={taxonomyDisplay}
|
|
1172
|
+
scopeProduct={multiSource?.namespace}
|
|
1173
|
+
scopeVersion={multiSource?.version}
|
|
708
1174
|
/>
|
|
709
1175
|
)}
|
|
710
1176
|
{repoUrl && (
|
|
@@ -737,9 +1203,30 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
|
|
|
737
1203
|
the actual page content. Filter elements live in here
|
|
738
1204
|
now to stay inside that scope.
|
|
739
1205
|
*/}
|
|
740
|
-
{
|
|
741
|
-
|
|
742
|
-
|
|
1206
|
+
{/*
|
|
1207
|
+
Slash-nested tags whose prefix is declared in
|
|
1208
|
+
`taxonomies.tags.prefixes` emit as per-prefix filter
|
|
1209
|
+
divs so each prefix becomes its own Pagefind facet
|
|
1210
|
+
column (Difficulty, Topic, Persona, …) instead of
|
|
1211
|
+
pooling under a single "Tag" column.
|
|
1212
|
+
|
|
1213
|
+
Plain tags and tags whose prefix isn't declared fall
|
|
1214
|
+
back to the pooled `tag:` filter — backward-compatible
|
|
1215
|
+
for sites that haven't declared prefixes.
|
|
1216
|
+
|
|
1217
|
+
See plans/per-prefix-search-facets.md.
|
|
1218
|
+
*/}
|
|
1219
|
+
{Array.isArray(tags) && tags.map((tag) => {
|
|
1220
|
+
const slash = tag.indexOf("/");
|
|
1221
|
+
if (slash > 0) {
|
|
1222
|
+
const prefix = tag.slice(0, slash);
|
|
1223
|
+
const leaf = tag.slice(slash + 1);
|
|
1224
|
+
if (tagPrefixes && tagPrefixes[prefix]) {
|
|
1225
|
+
return <div hidden data-pagefind-filter={`${prefix}:${leaf}`}></div>;
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
return <div hidden data-pagefind-filter={`tag:${tag}`}></div>;
|
|
1229
|
+
})}
|
|
743
1230
|
{Array.isArray(audience) && audience.map((value) => (
|
|
744
1231
|
<div hidden data-pagefind-filter={`audience:${value}`}></div>
|
|
745
1232
|
))}
|
|
@@ -748,6 +1235,18 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
|
|
|
748
1235
|
))}
|
|
749
1236
|
{status && <div hidden data-pagefind-filter={`status:${status}`}></div>}
|
|
750
1237
|
{pageType && <div hidden data-pagefind-filter={`type:${pageType}`}></div>}
|
|
1238
|
+
{/*
|
|
1239
|
+
Multi-source axis filters — so search on a versioned/multi-
|
|
1240
|
+
product site can be scoped to (or faceted by) the current
|
|
1241
|
+
product and version. `product` = namespace, `version` = the
|
|
1242
|
+
version segment. Only emitted when the axis is active.
|
|
1243
|
+
*/}
|
|
1244
|
+
{multiSource?.namespace && (
|
|
1245
|
+
<div hidden data-pagefind-filter={`product:${multiSource.namespace}`}></div>
|
|
1246
|
+
)}
|
|
1247
|
+
{multiSource?.version && (
|
|
1248
|
+
<div hidden data-pagefind-filter={`version:${multiSource.version}`}></div>
|
|
1249
|
+
)}
|
|
751
1250
|
{/*
|
|
752
1251
|
Custom-taxonomy filters. Any taxonomy declared in
|
|
753
1252
|
`dogsbay.config.yml` that isn't one of the five built-ins
|
|
@@ -801,12 +1300,62 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
|
|
|
801
1300
|
{title}
|
|
802
1301
|
</h1>
|
|
803
1302
|
)}
|
|
1303
|
+
{/*
|
|
1304
|
+
Byline — blog chrome only, and only when there is
|
|
1305
|
+
something to say. An absent author means NO byline row,
|
|
1306
|
+
never "Unknown": a placeholder byline is worse than none,
|
|
1307
|
+
because it reads as data rather than as a gap.
|
|
1308
|
+
|
|
1309
|
+
Sits between the H1 and the lede so the reader gets
|
|
1310
|
+
attribution before the summary, which is the order a
|
|
1311
|
+
post is scanned in.
|
|
1312
|
+
*/}
|
|
1313
|
+
{chrome === "blog" && (author?.length || publishedDate || readingMinutes) && (
|
|
1314
|
+
<div
|
|
1315
|
+
class="not-prose mb-6 flex flex-wrap items-center gap-x-2 gap-y-1 text-sm text-muted-foreground"
|
|
1316
|
+
data-post-byline
|
|
1317
|
+
data-pagefind-ignore
|
|
1318
|
+
>
|
|
1319
|
+
{author && author.length > 0 && (
|
|
1320
|
+
<span class="font-medium text-foreground">{author.join(", ")}</span>
|
|
1321
|
+
)}
|
|
1322
|
+
{author && author.length > 0 && publishedDate && (
|
|
1323
|
+
<span aria-hidden="true">·</span>
|
|
1324
|
+
)}
|
|
1325
|
+
{publishedDate && (
|
|
1326
|
+
<time datetime={publishedDate}>{_formatPostDate(publishedDate)}</time>
|
|
1327
|
+
)}
|
|
1328
|
+
{publishedDate && readingMinutes && <span aria-hidden="true">·</span>}
|
|
1329
|
+
{readingMinutes && <span>{readingMinutes} min read</span>}
|
|
1330
|
+
{updatedDate && updatedDate !== publishedDate && (
|
|
1331
|
+
<span>
|
|
1332
|
+
· Updated <time datetime={updatedDate}>{_formatPostDate(updatedDate)}</time>
|
|
1333
|
+
</span>
|
|
1334
|
+
)}
|
|
1335
|
+
</div>
|
|
1336
|
+
)}
|
|
1337
|
+
|
|
804
1338
|
{autoLede && description && (
|
|
805
1339
|
<p class="text-lg text-muted-foreground mb-6">
|
|
806
1340
|
{description}
|
|
807
1341
|
</p>
|
|
808
1342
|
)}
|
|
809
1343
|
|
|
1344
|
+
{/*
|
|
1345
|
+
Hero image. aspect-boxed on purpose — an unsized image
|
|
1346
|
+
above the fold is the classic layout-shift source, and it
|
|
1347
|
+
would shove the prose down after paint.
|
|
1348
|
+
*/}
|
|
1349
|
+
{chrome === "blog" && heroImage && (
|
|
1350
|
+
<img
|
|
1351
|
+
src={heroImage}
|
|
1352
|
+
alt=""
|
|
1353
|
+
class="not-prose mb-8 w-full rounded-lg border border-border object-cover aspect-[2/1]"
|
|
1354
|
+
loading="eager"
|
|
1355
|
+
decoding="async"
|
|
1356
|
+
/>
|
|
1357
|
+
)}
|
|
1358
|
+
|
|
810
1359
|
{hasMetaStrip && (
|
|
811
1360
|
<div
|
|
812
1361
|
class="not-prose mb-6 flex flex-wrap items-center gap-2"
|
|
@@ -830,11 +1379,23 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
|
|
|
830
1379
|
indexPath={tagsIndexPath}
|
|
831
1380
|
prefixes={tagPrefixes}
|
|
832
1381
|
labels={tagLabels}
|
|
1382
|
+
autoColor={tagAutoColor}
|
|
833
1383
|
/>
|
|
834
1384
|
)}
|
|
835
1385
|
</div>
|
|
836
1386
|
)}
|
|
837
1387
|
|
|
1388
|
+
{tocPlacement.topToc && (
|
|
1389
|
+
<details class="dba-toc-top not-prose mb-6 rounded-md border" data-toc-top>
|
|
1390
|
+
<summary class="cursor-pointer list-none px-3 py-2 text-sm font-medium text-muted-foreground">
|
|
1391
|
+
On this page
|
|
1392
|
+
</summary>
|
|
1393
|
+
<div class="border-t px-3 py-2">
|
|
1394
|
+
<DocsToc headings={headings} title="" />
|
|
1395
|
+
</div>
|
|
1396
|
+
</details>
|
|
1397
|
+
)}
|
|
1398
|
+
|
|
838
1399
|
<slot />
|
|
839
1400
|
|
|
840
1401
|
<DocsFooter
|
|
@@ -842,24 +1403,57 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
|
|
|
842
1403
|
lastUpdated={lastUpdated}
|
|
843
1404
|
prev={prev}
|
|
844
1405
|
next={next}
|
|
1406
|
+
{/*
|
|
1407
|
+
Blog adjacency is chronological, so "Previous/Next"
|
|
1408
|
+
describes the wrong axis. Left is older, right is newer —
|
|
1409
|
+
a timeline runs that way, and so does a series: the
|
|
1410
|
+
right-hand link is the next part.
|
|
1411
|
+
*/}
|
|
1412
|
+
prevLabel={chrome === "blog" ? "Older" : undefined}
|
|
1413
|
+
nextLabel={chrome === "blog" ? "Newer" : undefined}
|
|
845
1414
|
copyright={copyright}
|
|
846
1415
|
llmsLink={llmFooterLink}
|
|
847
1416
|
llmsLinkHref={llmsLinkHrefResolved}
|
|
1417
|
+
poweredBy={poweredBy}
|
|
848
1418
|
/>
|
|
849
1419
|
</div>
|
|
850
1420
|
</main>
|
|
851
1421
|
|
|
852
|
-
{
|
|
1422
|
+
{/* Classic right-hand TOC — only in `toc: rail` mode. */}
|
|
1423
|
+
{tocPlacement.railToc && (
|
|
853
1424
|
<aside class="sticky top-12 hidden h-[calc(100vh-3rem)] w-56 shrink-0 overflow-y-auto border-l p-4 lg:block">
|
|
854
1425
|
<DocsToc headings={headings} />
|
|
855
1426
|
</aside>
|
|
856
1427
|
)}
|
|
1428
|
+
{/* Right-rail plugin region — host for the `right-rail` named slot
|
|
1429
|
+
(e.g. an Ask AI panel). Rendered in every non-rail mode and not
|
|
1430
|
+
gated on headings, so a plugin can dock on heading-less pages.
|
|
1431
|
+
Hidden when nothing fills it (see the empty-rail style below). */}
|
|
1432
|
+
{tocPlacement.regionRail && (
|
|
1433
|
+
<aside
|
|
1434
|
+
class="dba-right-rail sticky top-12 hidden h-[calc(100vh-3rem)] w-80 shrink-0 overflow-y-auto border-l p-4 lg:block xl:w-96"
|
|
1435
|
+
data-right-rail
|
|
1436
|
+
>
|
|
1437
|
+
<slot name="right-rail" />
|
|
1438
|
+
</aside>
|
|
1439
|
+
)}
|
|
857
1440
|
</div>
|
|
858
1441
|
</SidebarInset>
|
|
859
1442
|
</SidebarProvider>
|
|
860
1443
|
</body>
|
|
861
1444
|
</html>
|
|
862
1445
|
|
|
1446
|
+
<style>
|
|
1447
|
+
/* The right-rail plugin region renders unconditionally in non-rail TOC
|
|
1448
|
+
modes so plugins (e.g. Ask AI) can dock into it. Until something fills
|
|
1449
|
+
the `right-rail` slot it has no element children — hide it so an empty
|
|
1450
|
+
bordered column doesn't show. (:has matches element children only; an
|
|
1451
|
+
unfilled Astro named slot renders none.) */
|
|
1452
|
+
aside[data-right-rail]:not(:has(*)) {
|
|
1453
|
+
display: none;
|
|
1454
|
+
}
|
|
1455
|
+
</style>
|
|
1456
|
+
|
|
863
1457
|
<script>
|
|
864
1458
|
import "@dogsbay/ui/sidebar/sidebar.ts";
|
|
865
1459
|
|