@dogsbay/docs-layout 0.2.0-beta.1 → 0.2.0-beta.100
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 +7 -5
- package/src/BlogIndex.astro +179 -0
- package/src/DocsFooter.astro +27 -3
- package/src/DocsLayout.astro +573 -40
- package/src/DocsNavClient.astro +107 -0
- package/src/DocsToc.astro +1 -1
- package/src/SearchDialog.astro +301 -33
- package/src/TagList.astro +17 -2
- package/src/TaxonomyTerm.astro +15 -4
- package/src/VersionSwitcher.astro +6 -0
- package/src/docs-nav-client.ts +419 -0
- package/src/json-ld.ts +112 -0
- package/src/link-icons.ts +54 -0
- package/src/markdown-negotiation.ts +38 -2
- package/src/nav-filter.ts +42 -129
- package/src/search-facets.ts +511 -9
- package/src/switcher.ts +83 -2
- package/src/toc-placement.ts +71 -0
- package/src/version-redirect.ts +23 -0
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";
|
|
@@ -44,7 +47,7 @@ import VersionSwitcher from "./VersionSwitcher.astro";
|
|
|
44
47
|
import LocaleSwitcher from "./LocaleSwitcher.astro";
|
|
45
48
|
import { filterNavByAxis } 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 {
|
|
@@ -112,7 +115,13 @@ interface Props {
|
|
|
112
115
|
siteDescription?: string;
|
|
113
116
|
/** Copyright text (HTML allowed) */
|
|
114
117
|
copyright?: string;
|
|
115
|
-
/**
|
|
118
|
+
/**
|
|
119
|
+
* Favicon path. No host-root default — that 404s on subpath
|
|
120
|
+
* deploys. The format-astro emitter passes a combined-prefix
|
|
121
|
+
* path (e.g. `/<repo>/favicon.ico`) computed from site.url +
|
|
122
|
+
* basePath. Pass an empty string or `false` to disable. When
|
|
123
|
+
* undefined, no `<link rel="icon">` is emitted.
|
|
124
|
+
*/
|
|
116
125
|
favicon?: string | false;
|
|
117
126
|
/** Per-page OG image URL. Overrides defaultOgImage. */
|
|
118
127
|
ogImage?: string;
|
|
@@ -127,16 +136,25 @@ interface Props {
|
|
|
127
136
|
/** Theme color hint for browsers (hex string) */
|
|
128
137
|
themeColor?: string;
|
|
129
138
|
/**
|
|
130
|
-
* Emit `<meta name="robots"
|
|
139
|
+
* Emit `noindex` in the `<meta name="robots">` directive when
|
|
131
140
|
* true. Tells external search engines (Google, Bing) to skip
|
|
132
|
-
* this page.
|
|
133
|
-
*
|
|
134
|
-
* page
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
141
|
+
* indexing this page. Independent of `nofollow` — common pattern
|
|
142
|
+
* for tag / index pages is noindex + follow (don't list this
|
|
143
|
+
* page in results, but do crawl through to the real content).
|
|
144
|
+
*
|
|
145
|
+
* Has NO effect on in-site Pagefind search — for that, use
|
|
146
|
+
* `excludeFromSearch`. The two are independent: a page can be
|
|
147
|
+
* excluded from external SEs but still appear in Pagefind (e.g.
|
|
148
|
+
* duplicate / old content readers might still want to find when
|
|
149
|
+
* they're already on the site), or vice versa.
|
|
138
150
|
*/
|
|
139
151
|
noindex?: boolean;
|
|
152
|
+
/**
|
|
153
|
+
* Emit `nofollow` in the `<meta name="robots">` directive when
|
|
154
|
+
* true. Tells crawlers not to follow outbound links from this
|
|
155
|
+
* page. Independent of `noindex`. Default false.
|
|
156
|
+
*/
|
|
157
|
+
nofollow?: boolean;
|
|
140
158
|
/**
|
|
141
159
|
* Exclude this page from in-site Pagefind search results.
|
|
142
160
|
*
|
|
@@ -256,6 +274,23 @@ interface Props {
|
|
|
256
274
|
* elements in `<body>`.
|
|
257
275
|
*/
|
|
258
276
|
category?: string[];
|
|
277
|
+
/**
|
|
278
|
+
* Custom-taxonomy values from `meta.taxonomies` — anything declared
|
|
279
|
+
* in `taxonomies:` config that isn't one of the five hardcoded
|
|
280
|
+
* built-ins (`tags`, `category`, `audience`, `type`, `status`).
|
|
281
|
+
*
|
|
282
|
+
* Each entry becomes one `<div data-pagefind-filter="<name>:<value>">`
|
|
283
|
+
* inside the indexed body, so the search dialog grows a checkbox
|
|
284
|
+
* group for every custom taxonomy automatically. No extra config
|
|
285
|
+
* needed beyond declaring the taxonomy in `dogsbay.config.yml` —
|
|
286
|
+
* the search dialog discovers facets at index time and renders
|
|
287
|
+
* whatever Pagefind reports.
|
|
288
|
+
*
|
|
289
|
+
* Display labels for the checkboxes flow through
|
|
290
|
+
* `taxonomyDisplay[<name>]` (same prefix/label config that drives
|
|
291
|
+
* chip rendering elsewhere). When unset, raw slugs are shown.
|
|
292
|
+
*/
|
|
293
|
+
taxonomies?: Record<string, string[]>;
|
|
259
294
|
/**
|
|
260
295
|
* Map of taxonomy name → index path for declared taxonomies.
|
|
261
296
|
* Used to wire links from built-in field badges (TypeBadge,
|
|
@@ -324,6 +359,19 @@ interface Props {
|
|
|
324
359
|
* (`<basePath>/<version>/`). Defaults to "/docs".
|
|
325
360
|
*/
|
|
326
361
|
basePath?: string;
|
|
362
|
+
/**
|
|
363
|
+
* Sidebar navigation render mode.
|
|
364
|
+
*
|
|
365
|
+
* - `"client"` (default): emit the small `<DocsNavClient />` placeholder;
|
|
366
|
+
* the tree is hydrated from `/_dogsbay/nav.json` once per session.
|
|
367
|
+
* Page HTML shrinks dramatically at scale (~50 KB vs ~1.2 MB on a
|
|
368
|
+
* 2k-page site). No-JS users see a sitemap fallback link.
|
|
369
|
+
* - `"ssr-full"`: render the full nav tree into every page's HTML.
|
|
370
|
+
* Best for very small sites or strict no-JS / SEO contexts.
|
|
371
|
+
*
|
|
372
|
+
* See plans/client-rendered-nav.md.
|
|
373
|
+
*/
|
|
374
|
+
navMode?: "client" | "ssr-full";
|
|
327
375
|
/**
|
|
328
376
|
* Per-page LLM action UI. When set and `enabled !== false`, renders
|
|
329
377
|
* the PageActions cluster (Copy markdown + Open in Claude/ChatGPT/
|
|
@@ -358,6 +406,55 @@ interface Props {
|
|
|
358
406
|
* in per-page.
|
|
359
407
|
*/
|
|
360
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
|
+
* Table-of-contents placement. Default `"top"`.
|
|
437
|
+
* - `"top"` — expandable "On this page" disclosure at the top of the
|
|
438
|
+
* article, identical on desktop and mobile; frees the right rail for the
|
|
439
|
+
* `right-rail` named slot (e.g. an Ask AI panel).
|
|
440
|
+
* - `"popover"` — an "On this page" dropdown in the header.
|
|
441
|
+
* - `"rail"` — the classic right-hand TOC sidebar (pre-`toc` behaviour).
|
|
442
|
+
* - `"off"` — no table of contents.
|
|
443
|
+
* See plans/ask-branch1-placement-toc.md.
|
|
444
|
+
*/
|
|
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
|
+
};
|
|
361
458
|
class?: string;
|
|
362
459
|
}
|
|
363
460
|
|
|
@@ -368,6 +465,7 @@ const {
|
|
|
368
465
|
nav,
|
|
369
466
|
navGroups,
|
|
370
467
|
headings = [],
|
|
468
|
+
toc = "top",
|
|
371
469
|
prev,
|
|
372
470
|
next,
|
|
373
471
|
repoUrl,
|
|
@@ -376,7 +474,7 @@ const {
|
|
|
376
474
|
editUrl,
|
|
377
475
|
lastUpdated,
|
|
378
476
|
copyright,
|
|
379
|
-
favicon
|
|
477
|
+
favicon,
|
|
380
478
|
ogImage,
|
|
381
479
|
defaultOgImage,
|
|
382
480
|
ogType = "article",
|
|
@@ -384,11 +482,18 @@ const {
|
|
|
384
482
|
twitterHandle,
|
|
385
483
|
themeColor,
|
|
386
484
|
noindex,
|
|
485
|
+
nofollow,
|
|
387
486
|
excludeFromSearch,
|
|
388
487
|
plausibleDomain,
|
|
389
488
|
plausibleScriptUrl,
|
|
390
489
|
hideSearch = false,
|
|
391
|
-
pagefindUrl
|
|
490
|
+
// No default for pagefindUrl — host-root absolute paths break on
|
|
491
|
+
// subpath-mounted deploys (GH Pages project pages, multi-mount
|
|
492
|
+
// Cloudflare). format-astro's emitter always passes the
|
|
493
|
+
// combined-prefix-aware URL; manual instantiation must too.
|
|
494
|
+
// Undefined here propagates to SearchDialog where the JS loader
|
|
495
|
+
// throws on first open instead of silently 404'ing the bundle.
|
|
496
|
+
pagefindUrl,
|
|
392
497
|
mdMirror = false,
|
|
393
498
|
tags,
|
|
394
499
|
tagsIndexPath = "/tags",
|
|
@@ -398,6 +503,7 @@ const {
|
|
|
398
503
|
pageType,
|
|
399
504
|
audience,
|
|
400
505
|
category,
|
|
506
|
+
taxonomies,
|
|
401
507
|
taxonomyIndexPaths,
|
|
402
508
|
taxonomyDisplay,
|
|
403
509
|
autoH1,
|
|
@@ -407,10 +513,24 @@ const {
|
|
|
407
513
|
multiSource,
|
|
408
514
|
switcherMap,
|
|
409
515
|
basePath,
|
|
516
|
+
navMode = "client",
|
|
410
517
|
wideLayout = false,
|
|
518
|
+
chrome = "docs",
|
|
519
|
+
author,
|
|
520
|
+
publishedDate,
|
|
521
|
+
updatedDate,
|
|
522
|
+
readingMinutes,
|
|
523
|
+
heroImage,
|
|
524
|
+
linkIcons,
|
|
411
525
|
class: className,
|
|
412
526
|
} = Astro.props;
|
|
413
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
|
+
|
|
414
534
|
// Resolve LLM action visibility + placement once. The component
|
|
415
535
|
// guards against missing markdownBody / mdUrl internally, but we
|
|
416
536
|
// also gate at the layout level so the slots stay empty when
|
|
@@ -428,6 +548,16 @@ const showLlmActionsInline =
|
|
|
428
548
|
llmActionsEnabled
|
|
429
549
|
&& (llmActionsPlacement === "inline" || llmActionsPlacement === "both");
|
|
430
550
|
const llmFooterLink = !!llmActions && llmActions.footerLink !== false;
|
|
551
|
+
// Per-mount llms.txt URL — Dogsbay emits `<basePath>/llms.txt`
|
|
552
|
+
// (sitemap-index pattern), so the footer link must be basePath-
|
|
553
|
+
// prefixed too. Falls back to `/llms.txt` when basePath is empty
|
|
554
|
+
// or unset, matching the platform's host-root single-site case.
|
|
555
|
+
const llmsLinkHrefResolved = (() => {
|
|
556
|
+
const bp = (basePath ?? "").replace(/\/+$/, "");
|
|
557
|
+
if (!bp) return "/llms.txt";
|
|
558
|
+
const prefix = bp.startsWith("/") ? bp : `/${bp}`;
|
|
559
|
+
return `${prefix}/llms.txt`;
|
|
560
|
+
})();
|
|
431
561
|
|
|
432
562
|
// Compute href targets for the type / status badges. A field is
|
|
433
563
|
// linkable only when (a) the user declared a `taxonomies.<field>`
|
|
@@ -447,20 +577,108 @@ const hasMetaStrip = (
|
|
|
447
577
|
(typeof pageType === "string" && pageType.length > 0)
|
|
448
578
|
);
|
|
449
579
|
|
|
580
|
+
// TOC placement: which container(s) + the right-rail plugin region render.
|
|
581
|
+
// Logic lives in toc-placement.ts so it's unit-tested; this file just consumes.
|
|
582
|
+
const tocPlacement = resolveTocPlacement(toc, {
|
|
583
|
+
// Only show a TOC when the page has 2+ displayable headings (depth 2–3);
|
|
584
|
+
// the H1 doesn't count and a one-item TOC is noise. Keeps "On this page"
|
|
585
|
+
// off landing/welcome pages that are just an H1 + prose.
|
|
586
|
+
hasHeadings: hasDisplayableToc(headings),
|
|
587
|
+
wideLayout: !!wideLayout,
|
|
588
|
+
});
|
|
589
|
+
|
|
450
590
|
const currentPath = Astro.url.pathname.replace(/\/$/, "") || "/";
|
|
451
591
|
|
|
452
592
|
// SEO computation
|
|
453
593
|
const metaDescription = description ?? siteDescription;
|
|
454
594
|
const metaOgImage = ogImage ?? defaultOgImage;
|
|
455
595
|
const isAbsoluteSiteUrl = /^https?:\/\//.test(siteUrl);
|
|
596
|
+
// Compose canonical from ORIGIN + pathname. siteUrl may carry a
|
|
597
|
+
// path component (the urlBase that drives Astro's `base` — see
|
|
598
|
+
// plans/astro-base-from-site-url.md), and Astro.url.pathname
|
|
599
|
+
// already includes that prefix. Naively concatenating siteUrl +
|
|
600
|
+
// pathname double-counts the urlBase (e.g. .../repo/repo/page).
|
|
601
|
+
// Strip path off siteUrl by reparsing as a URL.
|
|
602
|
+
let canonicalOrigin: string | undefined;
|
|
603
|
+
if (isAbsoluteSiteUrl) {
|
|
604
|
+
try {
|
|
605
|
+
const u = new URL(siteUrl);
|
|
606
|
+
canonicalOrigin = `${u.protocol}//${u.host}`;
|
|
607
|
+
} catch {
|
|
608
|
+
// Malformed siteUrl — fall back to the original (no path) behavior.
|
|
609
|
+
canonicalOrigin = siteUrl.replace(/\/$/, "");
|
|
610
|
+
}
|
|
611
|
+
}
|
|
456
612
|
const computedCanonical = canonicalUrl
|
|
457
|
-
?? (
|
|
458
|
-
?
|
|
613
|
+
?? (canonicalOrigin
|
|
614
|
+
? canonicalOrigin + Astro.url.pathname
|
|
459
615
|
: undefined);
|
|
460
616
|
|
|
461
|
-
|
|
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
|
+
/**
|
|
627
|
+
* A value as a machine-readable ISO 8601 datetime, or "" when it is
|
|
628
|
+
* not a date at all.
|
|
629
|
+
*
|
|
630
|
+
* Unlike `_formatPostDate`, an unparseable value must NOT pass
|
|
631
|
+
* through: this feeds `article:published_time` / `modified_time`,
|
|
632
|
+
* where a malformed datetime is worse than an absent one — a consumer
|
|
633
|
+
* either ignores the whole article object or shows garbage.
|
|
634
|
+
* `lastUpdated` is documented as also accepting a version/tag string
|
|
635
|
+
* ("v2.1"), which must never reach these tags.
|
|
636
|
+
*/
|
|
637
|
+
function isoDate(value: string | undefined): string {
|
|
638
|
+
if (!value) return "";
|
|
639
|
+
const d = new Date(value);
|
|
640
|
+
return Number.isNaN(d.getTime()) ? "" : d.toISOString();
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function _formatPostDate(iso: string): string {
|
|
644
|
+
const d = new Date(iso);
|
|
645
|
+
if (Number.isNaN(d.getTime())) return iso;
|
|
646
|
+
// timeZone: "UTC" is load-bearing. parseMeta normalizes `created:
|
|
647
|
+
// 2026-01-01` to "2026-01-01T00:00:00.000Z", so a build host in any
|
|
648
|
+
// negative-offset zone (the default on many CI runners) would render
|
|
649
|
+
// "31 December 2025". DocsFooter already carries this fix; these
|
|
650
|
+
// copies dropped it.
|
|
651
|
+
return d.toLocaleDateString("en-GB", {
|
|
652
|
+
year: "numeric",
|
|
653
|
+
month: "long",
|
|
654
|
+
day: "numeric",
|
|
655
|
+
timeZone: "UTC",
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// Markdown mirror for the alternate link.
|
|
660
|
+
//
|
|
661
|
+
// Leaf pages append `.md` to the trimmed path (`/getting-started/` →
|
|
662
|
+
// `/getting-started.md`), matching the emitted `<slug>.md.ts` endpoint.
|
|
663
|
+
// The SITE INDEX is the exception: it is emitted as `index.md.ts`, so
|
|
664
|
+
// its mirror is `<base>/index.md`. Appending `.md` there produced
|
|
665
|
+
// `/.md` (root-served) or `/blog.md` (mounted) — neither exists, and on
|
|
666
|
+
// a mounted site `/blog.md` also falls outside the `/blog/*` route.
|
|
667
|
+
// Both leaf and index URLs carry a trailing slash under Astro's
|
|
668
|
+
// directory build format, so only "is this the site root" distinguishes
|
|
669
|
+
// them. Kept in step with `shouldRewriteToMarkdown`, which is handed the
|
|
670
|
+
// same combined prefix.
|
|
671
|
+
// Use the `basePath` PROP, not import.meta.env.BASE_URL. Astro's `base`
|
|
672
|
+
// carries only site.url's path (urlBase); the prop carries the COMBINED
|
|
673
|
+
// urlBase + site.basePath prefix — the same value the middleware gets —
|
|
674
|
+
// so on a `site.basePath: /docs` deploy the two would otherwise disagree
|
|
675
|
+
// about which page is the index.
|
|
676
|
+
const mdMirrorBase = (basePath ?? "").replace(/\/+$/, "");
|
|
677
|
+
const mdMirrorPath = Astro.url.pathname.replace(/\/$/, "");
|
|
462
678
|
const mdMirrorHref = mdMirror
|
|
463
|
-
?
|
|
679
|
+
? mdMirrorPath === mdMirrorBase
|
|
680
|
+
? `${mdMirrorBase}/index.md`
|
|
681
|
+
: `${mdMirrorPath}.md`
|
|
464
682
|
: undefined;
|
|
465
683
|
|
|
466
684
|
// Tag keywords for HTML meta + JSON-LD. Slug-based identifiers
|
|
@@ -481,17 +699,30 @@ const tagKeywords = resolveTagKeywords(tags, tagLabels);
|
|
|
481
699
|
// Course; everything else → Article. Matches the conventions
|
|
482
700
|
// search engines key off for educational / tutorial / reference
|
|
483
701
|
// SERP rendering. See `json-ld.ts` for the full mapping table.
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
702
|
+
// Structured data is emitted for EVERY page, not only tagged articles.
|
|
703
|
+
// This used to also require `tagKeywords.length > 0`, so an untagged
|
|
704
|
+
// page — a homepage, a landing page, a section index — emitted none at
|
|
705
|
+
// all. An external agent audit reported "No JSON-LD structured data
|
|
706
|
+
// found on homepage" and it was right: the page most likely to be an
|
|
707
|
+
// agent's entry point was the one describing itself the least.
|
|
708
|
+
// Keywords are enrichment, not a precondition for identity.
|
|
709
|
+
const articleJsonLd = ogType === "article"
|
|
710
|
+
? buildArticleJsonLd({
|
|
711
|
+
type: jsonLdTypeFor(pageType),
|
|
712
|
+
title,
|
|
713
|
+
siteName,
|
|
714
|
+
keywords: tagKeywords,
|
|
715
|
+
description: metaDescription,
|
|
716
|
+
image: metaOgImage,
|
|
717
|
+
url: computedCanonical,
|
|
718
|
+
headings,
|
|
719
|
+
})
|
|
720
|
+
: buildWebSiteJsonLd({
|
|
721
|
+
siteName,
|
|
722
|
+
title,
|
|
723
|
+
description: metaDescription,
|
|
724
|
+
url: computedCanonical,
|
|
725
|
+
});
|
|
495
726
|
|
|
496
727
|
// `customJsonLd` accepts either a single object or an array.
|
|
497
728
|
// Normalize to an array so we can iterate uniformly. Empty array
|
|
@@ -509,12 +740,67 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
|
|
|
509
740
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
510
741
|
<title>{title} | {siteName}</title>
|
|
511
742
|
|
|
743
|
+
<style is:global>
|
|
744
|
+
/*
|
|
745
|
+
The sticky-chrome contract. This header is exactly this tall, and
|
|
746
|
+
anything else that sticks (the release-comparison toolbar) offsets
|
|
747
|
+
by the same variable — so they cannot drift apart.
|
|
748
|
+
*/
|
|
749
|
+
:root {
|
|
750
|
+
--db-header-height: 3rem;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/*
|
|
754
|
+
Internal/external link icons. Opt-in via `content.linkIcons`,
|
|
755
|
+
which sets `data-link-icons` (which kinds are active) and the
|
|
756
|
+
`--db-link-icon-*` custom props (the glyphs) on <body>. Links are
|
|
757
|
+
classified purely by href shape, so it covers both structured
|
|
758
|
+
links and raw-HTML anchors from imported content. Scoped to
|
|
759
|
+
`.docs-prose` so nav/header/footer links are untouched.
|
|
760
|
+
*/
|
|
761
|
+
body[data-link-icons~="external"] .docs-prose a[href^="http://" i]::after,
|
|
762
|
+
body[data-link-icons~="external"] .docs-prose a[href^="https://" i]::after,
|
|
763
|
+
body[data-link-icons~="external"] .docs-prose a[href^="//"]::after {
|
|
764
|
+
content: var(--db-link-icon-external, "");
|
|
765
|
+
display: inline-block;
|
|
766
|
+
margin-inline-start: 0.15em;
|
|
767
|
+
font-size: 0.85em;
|
|
768
|
+
line-height: 1;
|
|
769
|
+
vertical-align: baseline;
|
|
770
|
+
opacity: 0.7;
|
|
771
|
+
}
|
|
772
|
+
body[data-link-icons~="internal"]
|
|
773
|
+
.docs-prose
|
|
774
|
+
a[href^="/"]:not([href^="//"])::after,
|
|
775
|
+
body[data-link-icons~="internal"] .docs-prose a[href^="./"]::after,
|
|
776
|
+
body[data-link-icons~="internal"] .docs-prose a[href^="../"]::after {
|
|
777
|
+
content: var(--db-link-icon-internal, "");
|
|
778
|
+
display: inline-block;
|
|
779
|
+
margin-inline-start: 0.15em;
|
|
780
|
+
font-size: 0.85em;
|
|
781
|
+
line-height: 1;
|
|
782
|
+
vertical-align: baseline;
|
|
783
|
+
opacity: 0.7;
|
|
784
|
+
}
|
|
785
|
+
</style>
|
|
786
|
+
|
|
512
787
|
{metaDescription && <meta name="description" content={metaDescription} />}
|
|
513
|
-
{favicon
|
|
788
|
+
{favicon && <link rel="icon" href={favicon} />}
|
|
514
789
|
{themeColor && <meta name="theme-color" content={themeColor} />}
|
|
515
790
|
{/* External search engine directive — orthogonal to in-site
|
|
516
|
-
Pagefind exclusion.
|
|
517
|
-
|
|
791
|
+
Pagefind exclusion. `noindex` + `nofollow` are independent
|
|
792
|
+
bits per the meta-robots spec; emit only the directives that
|
|
793
|
+
are set. Combining them when both are set keeps the tag
|
|
794
|
+
compact (`<meta name="robots" content="noindex, nofollow">`)
|
|
795
|
+
instead of emitting two tags. */}
|
|
796
|
+
{(noindex || nofollow) && (
|
|
797
|
+
<meta
|
|
798
|
+
name="robots"
|
|
799
|
+
content={[noindex && "noindex", nofollow && "nofollow"]
|
|
800
|
+
.filter(Boolean)
|
|
801
|
+
.join(", ")}
|
|
802
|
+
/>
|
|
803
|
+
)}
|
|
518
804
|
|
|
519
805
|
{/* In-site Pagefind exclusion is wired via two coordinated
|
|
520
806
|
attributes on <body> and <main> below — see the prop docs
|
|
@@ -529,6 +815,12 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
|
|
|
529
815
|
|
|
530
816
|
{computedCanonical && <link rel="canonical" href={computedCanonical} />}
|
|
531
817
|
{mdMirrorHref && <link rel="alternate" type="text/markdown" href={mdMirrorHref} />}
|
|
818
|
+
{/* Programmatic llms.txt discovery — agents that follow head
|
|
819
|
+
link rels get the per-mount llms.txt without parsing HTML.
|
|
820
|
+
Mirrors the existing _headers Link rel="describedby" used by
|
|
821
|
+
Cloudflare Pages / Workers. */}
|
|
822
|
+
<link rel="alternate" type="text/plain" title="llms.txt" href={llmsLinkHrefResolved} />
|
|
823
|
+
<link rel="describedby" type="text/plain" href={llmsLinkHrefResolved} />
|
|
532
824
|
|
|
533
825
|
{/* Open Graph */}
|
|
534
826
|
<meta property="og:type" content={ogType} />
|
|
@@ -543,6 +835,26 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
|
|
|
543
835
|
{ogType === "article" && tagKeywords.map((keyword) => (
|
|
544
836
|
<meta property="article:tag" content={keyword} />
|
|
545
837
|
))}
|
|
838
|
+
{/* Article dates. `og:type="article"` was declared with no times
|
|
839
|
+
at all, which is an incomplete article object — crawlers and
|
|
840
|
+
social cards had no publish date to show even though the page
|
|
841
|
+
renders one visibly.
|
|
842
|
+
|
|
843
|
+
`article:modified_time` falls back to `lastUpdated`, the prop
|
|
844
|
+
the docs chrome already uses for "Last updated", so a docs page
|
|
845
|
+
gets a modified time without the blog-only `updatedDate` being
|
|
846
|
+
set. Only emitted when the value parses as a date: a
|
|
847
|
+
frontmatter `lastUpdated: v2.1` is a version string, and a
|
|
848
|
+
malformed datetime is worse than an absent one. */}
|
|
849
|
+
{ogType === "article" && isoDate(publishedDate) && (
|
|
850
|
+
<meta property="article:published_time" content={isoDate(publishedDate)} />
|
|
851
|
+
)}
|
|
852
|
+
{ogType === "article" && isoDate(updatedDate ?? lastUpdated) && (
|
|
853
|
+
<meta
|
|
854
|
+
property="article:modified_time"
|
|
855
|
+
content={isoDate(updatedDate ?? lastUpdated)}
|
|
856
|
+
/>
|
|
857
|
+
)}
|
|
546
858
|
|
|
547
859
|
{/* JSON-LD primary block — Article / HowTo / TechArticle /
|
|
548
860
|
Course depending on `pageType`. Emits keywords as a
|
|
@@ -576,23 +888,63 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
|
|
|
576
888
|
></script>
|
|
577
889
|
)}
|
|
578
890
|
|
|
891
|
+
{/*
|
|
892
|
+
Theme bootstrap. Inline and BLOCKING on purpose — it must run
|
|
893
|
+
before first paint or the page flashes light before going dark.
|
|
894
|
+
|
|
895
|
+
An explicit choice wins in BOTH directions. Someone on a
|
|
896
|
+
dark-mode OS who deliberately picked light must stay light, so
|
|
897
|
+
"light" is checked rather than treated as "not dark". Only the
|
|
898
|
+
absence of a stored value falls through to the OS preference.
|
|
899
|
+
|
|
900
|
+
That fallback is the point of this block. Previously the only
|
|
901
|
+
condition was `=== "dark"`, so a visitor whose OS is in dark mode
|
|
902
|
+
got a light page until they found the toggle — and nothing else
|
|
903
|
+
in the CSS consulted prefers-color-scheme either. It also made
|
|
904
|
+
dark mode untestable by any tool that emulates the media query
|
|
905
|
+
(DevTools' Rendering panel, Playwright's colorScheme), because
|
|
906
|
+
the page did not read it.
|
|
907
|
+
|
|
908
|
+
localStorage access throws in some privacy modes; a throw here
|
|
909
|
+
would skip the class entirely, so it is guarded and degrades to
|
|
910
|
+
the OS preference.
|
|
911
|
+
*/}
|
|
579
912
|
<script is:inline>
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
913
|
+
(function () {
|
|
914
|
+
var stored = null;
|
|
915
|
+
try {
|
|
916
|
+
stored = localStorage.getItem("theme");
|
|
917
|
+
} catch (e) {}
|
|
918
|
+
var dark =
|
|
919
|
+
stored === "dark" ||
|
|
920
|
+
(stored !== "light" &&
|
|
921
|
+
window.matchMedia("(prefers-color-scheme: dark)").matches);
|
|
922
|
+
if (dark) document.documentElement.classList.add("dark");
|
|
923
|
+
})();
|
|
583
924
|
</script>
|
|
584
925
|
<slot name="head" />
|
|
585
926
|
</head>
|
|
586
927
|
<body
|
|
587
928
|
class="bg-background text-foreground antialiased"
|
|
588
929
|
data-pagefind-ignore={excludeFromSearch ? "" : undefined}
|
|
930
|
+
data-link-icons={_linkIconTokens || undefined}
|
|
931
|
+
style={_linkIconStyle || undefined}
|
|
589
932
|
>
|
|
590
933
|
<SidebarProvider>
|
|
934
|
+
{/*
|
|
935
|
+
chrome="blog" drops the sidebar COLUMN only. SidebarProvider and
|
|
936
|
+
SidebarInset stay, so the content column, header offset and
|
|
937
|
+
sticky contract are byte-identical to a docs page — which is what
|
|
938
|
+
makes the blog read as the same site rather than a lookalike.
|
|
939
|
+
With no <Sidebar> sibling the inset's peer-* selectors simply do
|
|
940
|
+
not match and it fills the width.
|
|
941
|
+
*/}
|
|
942
|
+
{chrome === "docs" && (
|
|
591
943
|
<Sidebar collapsible="icon">
|
|
592
944
|
<SidebarHeader>
|
|
593
945
|
<SidebarMenu>
|
|
594
946
|
<SidebarMenuItem>
|
|
595
|
-
<SidebarMenuButton size="lg" href={
|
|
947
|
+
<SidebarMenuButton size="lg" href={basePath || "/"} isActive={currentPath === basePath || currentPath === "/"}>
|
|
596
948
|
<div class="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
|
|
597
949
|
<Fragment set:html={siteIcon} />
|
|
598
950
|
</div>
|
|
@@ -608,7 +960,25 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
|
|
|
608
960
|
<SidebarSeparator />
|
|
609
961
|
|
|
610
962
|
<SidebarContent>
|
|
611
|
-
{
|
|
963
|
+
{navMode === "client" ? (
|
|
964
|
+
// Client-render mode: emit one DocsNavClient placeholder
|
|
965
|
+
// inside a single SidebarGroup, regardless of navGroups.
|
|
966
|
+
// The group-label shape doesn't apply when the tree is
|
|
967
|
+
// hydrated by JS — multi-group nav is reconstructed
|
|
968
|
+
// client-side from the same /_dogsbay/nav.json shape.
|
|
969
|
+
// See plans/client-rendered-nav.md.
|
|
970
|
+
<SidebarGroup>
|
|
971
|
+
<SidebarGroupContent>
|
|
972
|
+
<DocsNavClient
|
|
973
|
+
currentPath={currentPath}
|
|
974
|
+
basePath={basePath ?? ""}
|
|
975
|
+
namespace={multiSource?.namespace}
|
|
976
|
+
version={multiSource?.version}
|
|
977
|
+
locale={multiSource?.locale}
|
|
978
|
+
/>
|
|
979
|
+
</SidebarGroupContent>
|
|
980
|
+
</SidebarGroup>
|
|
981
|
+
) : navGroups ? (
|
|
612
982
|
navGroups.map(group => (
|
|
613
983
|
<SidebarGroup>
|
|
614
984
|
<SidebarGroupLabel>{group.label}</SidebarGroupLabel>
|
|
@@ -616,6 +986,7 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
|
|
|
616
986
|
<SidebarNavTree
|
|
617
987
|
items={filterNavByAxis(group.items, {
|
|
618
988
|
basePath: basePath ?? "/docs",
|
|
989
|
+
namespace: multiSource?.namespace,
|
|
619
990
|
version: multiSource?.version,
|
|
620
991
|
locale: multiSource?.locale,
|
|
621
992
|
})}
|
|
@@ -630,6 +1001,7 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
|
|
|
630
1001
|
<SidebarNavTree
|
|
631
1002
|
items={filterNavByAxis(nav, {
|
|
632
1003
|
basePath: basePath ?? "/docs",
|
|
1004
|
+
namespace: multiSource?.namespace,
|
|
633
1005
|
version: multiSource?.version,
|
|
634
1006
|
locale: multiSource?.locale,
|
|
635
1007
|
})}
|
|
@@ -642,14 +1014,31 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
|
|
|
642
1014
|
|
|
643
1015
|
<SidebarRail />
|
|
644
1016
|
</Sidebar>
|
|
1017
|
+
)}
|
|
645
1018
|
|
|
646
1019
|
<SidebarInset>
|
|
647
|
-
|
|
648
|
-
|
|
1020
|
+
{/*
|
|
1021
|
+
Height comes from `--db-header-height` (declared on :root in
|
|
1022
|
+
<head>) — the CONTRACT any other sticky chrome offsets by, e.g.
|
|
1023
|
+
the release-comparison toolbar. One variable, so a change here
|
|
1024
|
+
can never leave another sticky bar overlapping or floating.
|
|
1025
|
+
*/}
|
|
1026
|
+
<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">
|
|
1027
|
+
{chrome === "docs" && <SidebarTrigger class="-ml-1" />}
|
|
649
1028
|
<Separator orientation="vertical" class="mr-2 h-4" />
|
|
650
1029
|
<span class="text-sm text-muted-foreground" data-page-title>{title}</span>
|
|
651
1030
|
<div class="ml-auto flex items-center gap-2">
|
|
652
1031
|
<slot name="header" />
|
|
1032
|
+
{tocPlacement.popoverToc && (
|
|
1033
|
+
<details class="dba-toc-popover relative" data-toc-popover>
|
|
1034
|
+
<summary class="cursor-pointer list-none rounded-md px-2 py-1.5 text-sm text-muted-foreground hover:bg-accent hover:text-foreground">
|
|
1035
|
+
On this page
|
|
1036
|
+
</summary>
|
|
1037
|
+
<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">
|
|
1038
|
+
<DocsToc headings={headings} title="" />
|
|
1039
|
+
</div>
|
|
1040
|
+
</details>
|
|
1041
|
+
)}
|
|
653
1042
|
{switcherMap && multiSource && (
|
|
654
1043
|
<LocaleSwitcher
|
|
655
1044
|
switcherMap={switcherMap}
|
|
@@ -676,7 +1065,10 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
|
|
|
676
1065
|
{!hideSearch && (
|
|
677
1066
|
<SearchDialog
|
|
678
1067
|
pagefindUrl={pagefindUrl}
|
|
1068
|
+
navUrl={basePath ? `${basePath}/_dogsbay/nav.json` : "/_dogsbay/nav.json"}
|
|
679
1069
|
taxonomyDisplay={taxonomyDisplay}
|
|
1070
|
+
scopeProduct={multiSource?.namespace}
|
|
1071
|
+
scopeVersion={multiSource?.version}
|
|
680
1072
|
/>
|
|
681
1073
|
)}
|
|
682
1074
|
{repoUrl && (
|
|
@@ -709,9 +1101,30 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
|
|
|
709
1101
|
the actual page content. Filter elements live in here
|
|
710
1102
|
now to stay inside that scope.
|
|
711
1103
|
*/}
|
|
712
|
-
{
|
|
713
|
-
|
|
714
|
-
|
|
1104
|
+
{/*
|
|
1105
|
+
Slash-nested tags whose prefix is declared in
|
|
1106
|
+
`taxonomies.tags.prefixes` emit as per-prefix filter
|
|
1107
|
+
divs so each prefix becomes its own Pagefind facet
|
|
1108
|
+
column (Difficulty, Topic, Persona, …) instead of
|
|
1109
|
+
pooling under a single "Tag" column.
|
|
1110
|
+
|
|
1111
|
+
Plain tags and tags whose prefix isn't declared fall
|
|
1112
|
+
back to the pooled `tag:` filter — backward-compatible
|
|
1113
|
+
for sites that haven't declared prefixes.
|
|
1114
|
+
|
|
1115
|
+
See plans/per-prefix-search-facets.md.
|
|
1116
|
+
*/}
|
|
1117
|
+
{Array.isArray(tags) && tags.map((tag) => {
|
|
1118
|
+
const slash = tag.indexOf("/");
|
|
1119
|
+
if (slash > 0) {
|
|
1120
|
+
const prefix = tag.slice(0, slash);
|
|
1121
|
+
const leaf = tag.slice(slash + 1);
|
|
1122
|
+
if (tagPrefixes && tagPrefixes[prefix]) {
|
|
1123
|
+
return <div hidden data-pagefind-filter={`${prefix}:${leaf}`}></div>;
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
return <div hidden data-pagefind-filter={`tag:${tag}`}></div>;
|
|
1127
|
+
})}
|
|
715
1128
|
{Array.isArray(audience) && audience.map((value) => (
|
|
716
1129
|
<div hidden data-pagefind-filter={`audience:${value}`}></div>
|
|
717
1130
|
))}
|
|
@@ -720,6 +1133,32 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
|
|
|
720
1133
|
))}
|
|
721
1134
|
{status && <div hidden data-pagefind-filter={`status:${status}`}></div>}
|
|
722
1135
|
{pageType && <div hidden data-pagefind-filter={`type:${pageType}`}></div>}
|
|
1136
|
+
{/*
|
|
1137
|
+
Multi-source axis filters — so search on a versioned/multi-
|
|
1138
|
+
product site can be scoped to (or faceted by) the current
|
|
1139
|
+
product and version. `product` = namespace, `version` = the
|
|
1140
|
+
version segment. Only emitted when the axis is active.
|
|
1141
|
+
*/}
|
|
1142
|
+
{multiSource?.namespace && (
|
|
1143
|
+
<div hidden data-pagefind-filter={`product:${multiSource.namespace}`}></div>
|
|
1144
|
+
)}
|
|
1145
|
+
{multiSource?.version && (
|
|
1146
|
+
<div hidden data-pagefind-filter={`version:${multiSource.version}`}></div>
|
|
1147
|
+
)}
|
|
1148
|
+
{/*
|
|
1149
|
+
Custom-taxonomy filters. Any taxonomy declared in
|
|
1150
|
+
`dogsbay.config.yml` that isn't one of the five built-ins
|
|
1151
|
+
flows through here, so `difficulty: intermediate` (etc.)
|
|
1152
|
+
becomes a real Pagefind facet checkbox automatically.
|
|
1153
|
+
See plans/beta-launch-followups.md for context.
|
|
1154
|
+
*/}
|
|
1155
|
+
{taxonomies && Object.entries(taxonomies).flatMap(([name, values]) =>
|
|
1156
|
+
Array.isArray(values)
|
|
1157
|
+
? values.map((value) => (
|
|
1158
|
+
<div hidden data-pagefind-filter={`${name}:${value}`}></div>
|
|
1159
|
+
))
|
|
1160
|
+
: []
|
|
1161
|
+
)}
|
|
723
1162
|
|
|
724
1163
|
<div class:list={["mx-auto", wideLayout ? "max-w-7xl" : "max-w-3xl"]}>
|
|
725
1164
|
{/*
|
|
@@ -759,12 +1198,62 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
|
|
|
759
1198
|
{title}
|
|
760
1199
|
</h1>
|
|
761
1200
|
)}
|
|
1201
|
+
{/*
|
|
1202
|
+
Byline — blog chrome only, and only when there is
|
|
1203
|
+
something to say. An absent author means NO byline row,
|
|
1204
|
+
never "Unknown": a placeholder byline is worse than none,
|
|
1205
|
+
because it reads as data rather than as a gap.
|
|
1206
|
+
|
|
1207
|
+
Sits between the H1 and the lede so the reader gets
|
|
1208
|
+
attribution before the summary, which is the order a
|
|
1209
|
+
post is scanned in.
|
|
1210
|
+
*/}
|
|
1211
|
+
{chrome === "blog" && (author?.length || publishedDate || readingMinutes) && (
|
|
1212
|
+
<div
|
|
1213
|
+
class="not-prose mb-6 flex flex-wrap items-center gap-x-2 gap-y-1 text-sm text-muted-foreground"
|
|
1214
|
+
data-post-byline
|
|
1215
|
+
data-pagefind-ignore
|
|
1216
|
+
>
|
|
1217
|
+
{author && author.length > 0 && (
|
|
1218
|
+
<span class="font-medium text-foreground">{author.join(", ")}</span>
|
|
1219
|
+
)}
|
|
1220
|
+
{author && author.length > 0 && publishedDate && (
|
|
1221
|
+
<span aria-hidden="true">·</span>
|
|
1222
|
+
)}
|
|
1223
|
+
{publishedDate && (
|
|
1224
|
+
<time datetime={publishedDate}>{_formatPostDate(publishedDate)}</time>
|
|
1225
|
+
)}
|
|
1226
|
+
{publishedDate && readingMinutes && <span aria-hidden="true">·</span>}
|
|
1227
|
+
{readingMinutes && <span>{readingMinutes} min read</span>}
|
|
1228
|
+
{updatedDate && updatedDate !== publishedDate && (
|
|
1229
|
+
<span>
|
|
1230
|
+
· Updated <time datetime={updatedDate}>{_formatPostDate(updatedDate)}</time>
|
|
1231
|
+
</span>
|
|
1232
|
+
)}
|
|
1233
|
+
</div>
|
|
1234
|
+
)}
|
|
1235
|
+
|
|
762
1236
|
{autoLede && description && (
|
|
763
1237
|
<p class="text-lg text-muted-foreground mb-6">
|
|
764
1238
|
{description}
|
|
765
1239
|
</p>
|
|
766
1240
|
)}
|
|
767
1241
|
|
|
1242
|
+
{/*
|
|
1243
|
+
Hero image. aspect-boxed on purpose — an unsized image
|
|
1244
|
+
above the fold is the classic layout-shift source, and it
|
|
1245
|
+
would shove the prose down after paint.
|
|
1246
|
+
*/}
|
|
1247
|
+
{chrome === "blog" && heroImage && (
|
|
1248
|
+
<img
|
|
1249
|
+
src={heroImage}
|
|
1250
|
+
alt=""
|
|
1251
|
+
class="not-prose mb-8 w-full rounded-lg border border-border object-cover aspect-[2/1]"
|
|
1252
|
+
loading="eager"
|
|
1253
|
+
decoding="async"
|
|
1254
|
+
/>
|
|
1255
|
+
)}
|
|
1256
|
+
|
|
768
1257
|
{hasMetaStrip && (
|
|
769
1258
|
<div
|
|
770
1259
|
class="not-prose mb-6 flex flex-wrap items-center gap-2"
|
|
@@ -793,6 +1282,17 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
|
|
|
793
1282
|
</div>
|
|
794
1283
|
)}
|
|
795
1284
|
|
|
1285
|
+
{tocPlacement.topToc && (
|
|
1286
|
+
<details class="dba-toc-top not-prose mb-6 rounded-md border" data-toc-top>
|
|
1287
|
+
<summary class="cursor-pointer list-none px-3 py-2 text-sm font-medium text-muted-foreground">
|
|
1288
|
+
On this page
|
|
1289
|
+
</summary>
|
|
1290
|
+
<div class="border-t px-3 py-2">
|
|
1291
|
+
<DocsToc headings={headings} title="" />
|
|
1292
|
+
</div>
|
|
1293
|
+
</details>
|
|
1294
|
+
)}
|
|
1295
|
+
|
|
796
1296
|
<slot />
|
|
797
1297
|
|
|
798
1298
|
<DocsFooter
|
|
@@ -800,23 +1300,56 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
|
|
|
800
1300
|
lastUpdated={lastUpdated}
|
|
801
1301
|
prev={prev}
|
|
802
1302
|
next={next}
|
|
1303
|
+
{/*
|
|
1304
|
+
Blog adjacency is chronological, so "Previous/Next"
|
|
1305
|
+
describes the wrong axis. Left is older, right is newer —
|
|
1306
|
+
a timeline runs that way, and so does a series: the
|
|
1307
|
+
right-hand link is the next part.
|
|
1308
|
+
*/}
|
|
1309
|
+
prevLabel={chrome === "blog" ? "Older" : undefined}
|
|
1310
|
+
nextLabel={chrome === "blog" ? "Newer" : undefined}
|
|
803
1311
|
copyright={copyright}
|
|
804
1312
|
llmsLink={llmFooterLink}
|
|
1313
|
+
llmsLinkHref={llmsLinkHrefResolved}
|
|
805
1314
|
/>
|
|
806
1315
|
</div>
|
|
807
1316
|
</main>
|
|
808
1317
|
|
|
809
|
-
{
|
|
1318
|
+
{/* Classic right-hand TOC — only in `toc: rail` mode. */}
|
|
1319
|
+
{tocPlacement.railToc && (
|
|
810
1320
|
<aside class="sticky top-12 hidden h-[calc(100vh-3rem)] w-56 shrink-0 overflow-y-auto border-l p-4 lg:block">
|
|
811
1321
|
<DocsToc headings={headings} />
|
|
812
1322
|
</aside>
|
|
813
1323
|
)}
|
|
1324
|
+
{/* Right-rail plugin region — host for the `right-rail` named slot
|
|
1325
|
+
(e.g. an Ask AI panel). Rendered in every non-rail mode and not
|
|
1326
|
+
gated on headings, so a plugin can dock on heading-less pages.
|
|
1327
|
+
Hidden when nothing fills it (see the empty-rail style below). */}
|
|
1328
|
+
{tocPlacement.regionRail && (
|
|
1329
|
+
<aside
|
|
1330
|
+
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"
|
|
1331
|
+
data-right-rail
|
|
1332
|
+
>
|
|
1333
|
+
<slot name="right-rail" />
|
|
1334
|
+
</aside>
|
|
1335
|
+
)}
|
|
814
1336
|
</div>
|
|
815
1337
|
</SidebarInset>
|
|
816
1338
|
</SidebarProvider>
|
|
817
1339
|
</body>
|
|
818
1340
|
</html>
|
|
819
1341
|
|
|
1342
|
+
<style>
|
|
1343
|
+
/* The right-rail plugin region renders unconditionally in non-rail TOC
|
|
1344
|
+
modes so plugins (e.g. Ask AI) can dock into it. Until something fills
|
|
1345
|
+
the `right-rail` slot it has no element children — hide it so an empty
|
|
1346
|
+
bordered column doesn't show. (:has matches element children only; an
|
|
1347
|
+
unfilled Astro named slot renders none.) */
|
|
1348
|
+
aside[data-right-rail]:not(:has(*)) {
|
|
1349
|
+
display: none;
|
|
1350
|
+
}
|
|
1351
|
+
</style>
|
|
1352
|
+
|
|
820
1353
|
<script>
|
|
821
1354
|
import "@dogsbay/ui/sidebar/sidebar.ts";
|
|
822
1355
|
|