@dogsbay/docs-layout 0.2.0-beta.0

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.
@@ -0,0 +1,884 @@
1
+ ---
2
+ /**
3
+ * Standard documentation page layout.
4
+ *
5
+ * Uses the same sidebar component system as the demo app:
6
+ * collapsible sidebar, dark mode toggle, frosted glass header,
7
+ * TOC sidebar, pagination footer.
8
+ *
9
+ * Usage:
10
+ * <DocsLayout
11
+ * siteName="My Docs"
12
+ * title="Getting Started"
13
+ * nav={navItems}
14
+ * headings={headings}
15
+ * >
16
+ * <article>...</article>
17
+ * </DocsLayout>
18
+ */
19
+ import SidebarProvider from "@dogsbay/ui/sidebar/SidebarProvider.astro";
20
+ import Sidebar from "@dogsbay/ui/sidebar/Sidebar.astro";
21
+ import SidebarHeader from "@dogsbay/ui/sidebar/SidebarHeader.astro";
22
+ import SidebarContent from "@dogsbay/ui/sidebar/SidebarContent.astro";
23
+ import SidebarTrigger from "@dogsbay/ui/sidebar/SidebarTrigger.astro";
24
+ import SidebarInset from "@dogsbay/ui/sidebar/SidebarInset.astro";
25
+ import SidebarRail from "@dogsbay/ui/sidebar/SidebarRail.astro";
26
+ import SidebarGroup from "@dogsbay/ui/sidebar/SidebarGroup.astro";
27
+ import SidebarGroupLabel from "@dogsbay/ui/sidebar/SidebarGroupLabel.astro";
28
+ import SidebarGroupContent from "@dogsbay/ui/sidebar/SidebarGroupContent.astro";
29
+ import SidebarMenu from "@dogsbay/ui/sidebar/SidebarMenu.astro";
30
+ import SidebarMenuItem from "@dogsbay/ui/sidebar/SidebarMenuItem.astro";
31
+ import SidebarMenuButton from "@dogsbay/ui/sidebar/SidebarMenuButton.astro";
32
+ import SidebarSeparator from "@dogsbay/ui/sidebar/SidebarSeparator.astro";
33
+ import SidebarNavTree from "@dogsbay/ui/sidebar/SidebarNavTree.astro";
34
+ import Separator from "@dogsbay/ui/separator/Separator.astro";
35
+ import ThemeToggle from "@dogsbay/ui/theme-toggle/ThemeToggle.astro";
36
+ import DocsToc from "./DocsToc.astro";
37
+ import DocsFooter from "./DocsFooter.astro";
38
+ import SearchDialog from "./SearchDialog.astro";
39
+ import TagList from "./TagList.astro";
40
+ import StatusBadge from "./StatusBadge.astro";
41
+ import TypeBadge from "./TypeBadge.astro";
42
+ import PageActions from "./PageActions.astro";
43
+ import VersionSwitcher from "./VersionSwitcher.astro";
44
+ import LocaleSwitcher from "./LocaleSwitcher.astro";
45
+ import { filterNavByAxis } from "./nav-filter.js";
46
+ import type { LlmProviderName } from "./llm-actions.js";
47
+ import { jsonLdTypeFor, normalizeCustomJsonLd } from "./json-ld.js";
48
+ import { resolveTagKeywords } from "./tag-list-data.js";
49
+
50
+ interface NavItem {
51
+ label: string;
52
+ href?: string;
53
+ icon?: string;
54
+ children?: NavItem[];
55
+ }
56
+
57
+ interface NavGroup {
58
+ label: string;
59
+ items: NavItem[];
60
+ }
61
+
62
+ interface Heading {
63
+ depth: number;
64
+ slug: string;
65
+ text: string;
66
+ }
67
+
68
+ interface PaginationLink {
69
+ label: string;
70
+ href: string;
71
+ }
72
+
73
+ /**
74
+ * Closed palette of tag-chip colors. Mirrors `@dogsbay/types`'s
75
+ * `TagPaletteName`; restated here so this component stays
76
+ * standalone-importable. Keep in sync.
77
+ */
78
+ type TagPaletteName =
79
+ | "blue"
80
+ | "amber"
81
+ | "emerald"
82
+ | "violet"
83
+ | "rose"
84
+ | "slate";
85
+
86
+ interface Props {
87
+ /** Site name shown in header and sidebar logo */
88
+ siteName: string;
89
+ /** Page title for <title> tag */
90
+ title: string;
91
+ /** Home URL — also used as the base for the canonical link if absolute */
92
+ siteUrl?: string;
93
+ /** Navigation tree — flat array of items or grouped sections */
94
+ nav: NavItem[];
95
+ /** Optional grouped nav (label + items). If provided, renders sections with labels. */
96
+ navGroups?: NavGroup[];
97
+ /** Page headings for TOC */
98
+ headings?: Heading[];
99
+ /** Prev page link */
100
+ prev?: PaginationLink;
101
+ /** Next page link */
102
+ next?: PaginationLink;
103
+ /** GitHub/GitLab repo URL */
104
+ repoUrl?: string;
105
+ /** Edit URL for this page */
106
+ editUrl?: string;
107
+ /** Last updated date */
108
+ lastUpdated?: string;
109
+ /** Page description — also used as og:description / twitter:description */
110
+ description?: string;
111
+ /** Site-wide description used as fallback when page `description` is not set */
112
+ siteDescription?: string;
113
+ /** Copyright text (HTML allowed) */
114
+ copyright?: string;
115
+ /** Favicon path (default: "/favicon.ico"). Set to false to disable. */
116
+ favicon?: string | false;
117
+ /** Per-page OG image URL. Overrides defaultOgImage. */
118
+ ogImage?: string;
119
+ /** Site-level default OG image used when ogImage is not set */
120
+ defaultOgImage?: string;
121
+ /** OG content type. Default: "article" (use "website" for landing pages). */
122
+ ogType?: "article" | "website";
123
+ /** Canonical URL for this page. Auto-computed from siteUrl + pathname when absolute siteUrl is provided. */
124
+ canonicalUrl?: string;
125
+ /** Twitter / X handle including the leading "@" */
126
+ twitterHandle?: string;
127
+ /** Theme color hint for browsers (hex string) */
128
+ themeColor?: string;
129
+ /**
130
+ * Emit `<meta name="robots" content="noindex, nofollow">` when
131
+ * true. Tells external search engines (Google, Bing) to skip
132
+ * this page. Has NO effect on in-site Pagefind search — for
133
+ * that, use `excludeFromSearch`. The two are independent: a
134
+ * page can be excluded from external SEs but still appear in
135
+ * Pagefind (e.g. duplicate / old content readers might still
136
+ * want to find when they're already on the site), or vice
137
+ * versa.
138
+ */
139
+ noindex?: boolean;
140
+ /**
141
+ * Exclude this page from in-site Pagefind search results.
142
+ *
143
+ * Two-part wiring required because Pagefind resolves
144
+ * `data-pagefind-body` (declares the indexable region) BEFORE
145
+ * checking for ignore attributes elsewhere — the body-level
146
+ * ignore is dead code when a `data-pagefind-body` element
147
+ * exists deeper in the tree. So when this prop is set, the
148
+ * layout:
149
+ *
150
+ * 1. Adds `data-pagefind-ignore` to `<body>`.
151
+ * 2. **Omits** `data-pagefind-body` from `<main>`. Pagefind
152
+ * falls back to body-scope, sees the ignore, skips the page.
153
+ *
154
+ * The auto-emitted taxonomy index / term routes pass this to
155
+ * keep navigation pages out of search-result clutter — a
156
+ * search for "auth" should return the actual auth tutorial,
157
+ * not the `/tags/concept/auth/` listing.
158
+ */
159
+ excludeFromSearch?: boolean;
160
+ /**
161
+ * Plausible analytics tracking domain. When set, a single deferred
162
+ * <script> tag is emitted in <head>. Cookie-less; no consent banner
163
+ * required for EU users.
164
+ */
165
+ plausibleDomain?: string;
166
+ /**
167
+ * Override Plausible script URL. Default:
168
+ * "https://plausible.io/js/script.js". Use for self-hosted or
169
+ * proxied installations.
170
+ */
171
+ plausibleScriptUrl?: string;
172
+ /**
173
+ * Disable the built-in Pagefind search dialog. Default: false.
174
+ * Set to true on sites that don't generate a Pagefind index.
175
+ */
176
+ hideSearch?: boolean;
177
+ /**
178
+ * Pagefind index path. Default: "/pagefind/". Override for sites with
179
+ * a non-root base path (e.g. "/docs/pagefind/").
180
+ */
181
+ pagefindUrl?: string;
182
+ /**
183
+ * Whether the site emits per-page `.md` mirror endpoints. When true,
184
+ * the page advertises `<link rel="alternate" type="text/markdown">`
185
+ * pointing at the mirror — agents that respect link relations can
186
+ * fetch the markdown variant without content negotiation.
187
+ */
188
+ mdMirror?: boolean;
189
+ /**
190
+ * Page tags from `meta.tags`. Renders a TagList chip strip below the
191
+ * page title when non-empty. Slash-nested values link into the
192
+ * matching taxonomy term page (e.g. `api/rest` → `/tags/api/rest/`).
193
+ */
194
+ tags?: string[];
195
+ /**
196
+ * Path prefix for tag term pages. Default `/tags`. Match the
197
+ * `taxonomies.tags.indexPath` declared in `dogsbay.config.yml`.
198
+ */
199
+ tagsIndexPath?: string;
200
+ /**
201
+ * Per-prefix display config for tag chips, sourced from
202
+ * `taxonomies.tags.prefixes` in `dogsbay.config.yml`. Keyed by
203
+ * the top-level segment of slash-nested tags (`concept`,
204
+ * `difficulty`, etc.). When set, `<TagList>` renders the tag as
205
+ * a two-part `<Label>: <Leaf>` chip in the prefix's palette
206
+ * color. Tags whose prefix has no entry render the flat single-
207
+ * color chip. See plans/tag-display-config.md.
208
+ */
209
+ tagPrefixes?: Record<string, { label?: string; color?: TagPaletteName }>;
210
+ /**
211
+ * Per-tag leaf-label overrides (slug → display string). URLs
212
+ * stay slug-based; only chip text changes. Sourced from
213
+ * `taxonomies.tags.labels`.
214
+ */
215
+ tagLabels?: Record<string, string>;
216
+ /**
217
+ * Page lifecycle status. Renders a StatusBadge for non-stable
218
+ * states. Drafts/deprecated get strong colors.
219
+ */
220
+ status?: "draft" | "preview" | "stable" | "deprecated";
221
+ /**
222
+ * Page type from `meta.type` (open string). Renders a TypeBadge.
223
+ * Diátaxis values get conventional colors; others use a neutral
224
+ * fallback.
225
+ *
226
+ * Also drives Schema.org `@type` selection for the auto-emitted
227
+ * JSON-LD: tutorials and how-tos render as `HowTo`, references
228
+ * as `TechArticle`, courses as `Course`. Pages without a
229
+ * recognised type fall back to `Article`.
230
+ */
231
+ pageType?: string;
232
+ /**
233
+ * Site- or page-specific structured data appended to the
234
+ * `<head>`. Each value is wrapped in its own
235
+ * `<script type="application/ld+json">` block alongside the
236
+ * auto-emitted Article / HowTo / etc. Lets writers layer in
237
+ * `Course`, `Person`, `Organization`, `BreadcrumbList`, etc.
238
+ * without forking the layout.
239
+ *
240
+ * When a page wants to **replace** the auto-emitted JSON-LD,
241
+ * pass `ogType: "website"` (suppresses the Article emit) and
242
+ * provide your own block via `customJsonLd`.
243
+ */
244
+ customJsonLd?: Record<string, unknown> | Record<string, unknown>[];
245
+ /**
246
+ * Audience facets from `meta.audience`. Surfaced as hidden
247
+ * `data-pagefind-filter="audience:<value>"` elements at the top
248
+ * of `<body>` so Pagefind picks them up during body indexing.
249
+ * Not rendered in the meta strip (would crowd the chip area);
250
+ * use a custom layout if you want them visible.
251
+ */
252
+ audience?: string[];
253
+ /**
254
+ * Category segments from `meta.category` (or auto-derived from
255
+ * path). Surfaced as hidden `data-pagefind-filter="category:<value>"`
256
+ * elements in `<body>`.
257
+ */
258
+ category?: string[];
259
+ /**
260
+ * Map of taxonomy name → index path for declared taxonomies.
261
+ * Used to wire links from built-in field badges (TypeBadge,
262
+ * StatusBadge) to their browse destination, when one exists.
263
+ * A field absent from this map renders as a plain badge with no
264
+ * link. Sourced from `taxonomies` declarations in
265
+ * `dogsbay.config.yml` and emitted to `site.json` by
266
+ * `format-astro`.
267
+ */
268
+ taxonomyIndexPaths?: Record<string, string>;
269
+ /**
270
+ * Per-taxonomy display config (`prefixes` + `labels`), keyed
271
+ * by taxonomy name. Forwarded to `<SearchDialog>` so facet
272
+ * checkboxes show human labels instead of slugs. Same data
273
+ * the chip components consume, just shaped per-taxonomy.
274
+ */
275
+ taxonomyDisplay?: Record<string, {
276
+ prefixes?: Record<string, { label?: string; color?: TagPaletteName }>;
277
+ labels?: Record<string, string>;
278
+ }>;
279
+ /**
280
+ * When `true`, render `<h1>{title}</h1>` at the top of `<main>`
281
+ * (above the meta strip) — used when the markdown body doesn't
282
+ * already start with a level-1 heading. format-astro computes
283
+ * this per page via `detectLeadingNodes`. Default `false` so
284
+ * existing consumers keep their current rendering until they
285
+ * opt in. See plans/auto-lede.md.
286
+ */
287
+ autoH1?: boolean;
288
+ /**
289
+ * When `true`, render `<p>{description}</p>` below the auto H1
290
+ * (or above the meta strip when there's no H1) — used when the
291
+ * markdown body doesn't already start with a leading paragraph.
292
+ * Reads `description` from props. Default `false`.
293
+ */
294
+ autoLede?: boolean;
295
+ /**
296
+ * Multi-source axis metadata for the current page. Stamped by
297
+ * the loader when ≥1 axis is active. Consumed by
298
+ * VersionSwitcher to compute the logical key
299
+ * (`<namespace>/<originalSlug>`) used to look up alternates
300
+ * in `switcherMap`.
301
+ *
302
+ * Undefined for pages outside the multi-source pipeline (root
303
+ * landing pages, custom pages added by hand).
304
+ */
305
+ multiSource?: {
306
+ namespace?: string;
307
+ version?: string;
308
+ locale?: string;
309
+ originalSlug: string;
310
+ };
311
+ /**
312
+ * Switcher data emitted by format-astro's `emitSwitcherMap`.
313
+ * The VersionSwitcher renders nothing when `versions.length <
314
+ * 2` (single-version site) or when the current page has no
315
+ * `multiSource.version`. Pass the raw JSON imported from
316
+ * `@/data/switcherMap.json`.
317
+ */
318
+ switcherMap?: {
319
+ versions: Array<{ id: string; label?: string; eol?: boolean; default?: boolean }>;
320
+ byLogicalKey: Record<string, Record<string, string>>;
321
+ };
322
+ /**
323
+ * URL prefix for the switcher's per-version landing fallback
324
+ * (`<basePath>/<version>/`). Defaults to "/docs".
325
+ */
326
+ basePath?: string;
327
+ /**
328
+ * Per-page LLM action UI. When set and `enabled !== false`, renders
329
+ * the PageActions cluster (Copy markdown + Open in Claude/ChatGPT/
330
+ * Perplexity/Gemini dropdown). See plans/llm-page-actions.md.
331
+ *
332
+ * `markdownBody` is the page's markdown source (for the Copy
333
+ * button). `mdUrl` is the absolute URL of the .md mirror endpoint.
334
+ * Both come from format-astro at emit time.
335
+ */
336
+ llmActions?: {
337
+ enabled?: boolean;
338
+ providers?: LlmProviderName[];
339
+ placement?: "header" | "inline" | "both";
340
+ copyButton?: boolean;
341
+ promptTemplate?: string;
342
+ footerLink?: boolean;
343
+ /** Markdown body to copy. */
344
+ markdownBody?: string;
345
+ /** Absolute or relative URL to the page's .md mirror. */
346
+ mdUrl?: string;
347
+ };
348
+ /**
349
+ * When `true`, drop the prose-width column cap (~48rem) and the
350
+ * right-hand TOC sidebar so the slot content can use the full
351
+ * width of the inset. Used by pages that already structure their
352
+ * own internal columns — OpenAPI endpoint pages composing
353
+ * `<ApiLayout>`, generated reference pages with side-by-side
354
+ * description + code panels, etc.
355
+ *
356
+ * format-astro sets this automatically when a page's tree is
357
+ * dominated by an `endpoint` TreeNode; manual consumers can opt
358
+ * in per-page.
359
+ */
360
+ wideLayout?: boolean;
361
+ class?: string;
362
+ }
363
+
364
+ const {
365
+ siteName,
366
+ title,
367
+ siteUrl = "/",
368
+ nav,
369
+ navGroups,
370
+ headings = [],
371
+ prev,
372
+ next,
373
+ repoUrl,
374
+ description,
375
+ siteDescription,
376
+ editUrl,
377
+ lastUpdated,
378
+ copyright,
379
+ favicon = "/favicon.ico",
380
+ ogImage,
381
+ defaultOgImage,
382
+ ogType = "article",
383
+ canonicalUrl,
384
+ twitterHandle,
385
+ themeColor,
386
+ noindex,
387
+ excludeFromSearch,
388
+ plausibleDomain,
389
+ plausibleScriptUrl,
390
+ hideSearch = false,
391
+ pagefindUrl = "/pagefind/",
392
+ mdMirror = false,
393
+ tags,
394
+ tagsIndexPath = "/tags",
395
+ tagPrefixes,
396
+ tagLabels,
397
+ status,
398
+ pageType,
399
+ audience,
400
+ category,
401
+ taxonomyIndexPaths,
402
+ taxonomyDisplay,
403
+ autoH1,
404
+ autoLede,
405
+ llmActions,
406
+ customJsonLd,
407
+ multiSource,
408
+ switcherMap,
409
+ basePath,
410
+ wideLayout = false,
411
+ class: className,
412
+ } = Astro.props;
413
+
414
+ // Resolve LLM action visibility + placement once. The component
415
+ // guards against missing markdownBody / mdUrl internally, but we
416
+ // also gate at the layout level so the slots stay empty when
417
+ // disabled — keeps the markup lean for the common no-config case.
418
+ const llmActionsEnabled =
419
+ !!llmActions
420
+ && llmActions.enabled !== false
421
+ && typeof llmActions.mdUrl === "string"
422
+ && llmActions.mdUrl.length > 0;
423
+ const llmActionsPlacement = llmActions?.placement ?? "header";
424
+ const showLlmActionsHeader =
425
+ llmActionsEnabled
426
+ && (llmActionsPlacement === "header" || llmActionsPlacement === "both");
427
+ const showLlmActionsInline =
428
+ llmActionsEnabled
429
+ && (llmActionsPlacement === "inline" || llmActionsPlacement === "both");
430
+ const llmFooterLink = !!llmActions && llmActions.footerLink !== false;
431
+
432
+ // Compute href targets for the type / status badges. A field is
433
+ // linkable only when (a) the user declared a `taxonomies.<field>`
434
+ // block (so an indexPath flows through `taxonomyIndexPaths`) and
435
+ // (b) the page has a value for it. Otherwise the badge stays
436
+ // unlinked.
437
+ const typeBadgeHref = (pageType && taxonomyIndexPaths?.type)
438
+ ? `${taxonomyIndexPaths.type.replace(/\/$/, "")}/${pageType}/`
439
+ : undefined;
440
+ const statusBadgeHref = (status && status !== "stable" && taxonomyIndexPaths?.status)
441
+ ? `${taxonomyIndexPaths.status.replace(/\/$/, "")}/${status}/`
442
+ : undefined;
443
+
444
+ const hasMetaStrip = (
445
+ (Array.isArray(tags) && tags.length > 0) ||
446
+ (status && status !== "stable") ||
447
+ (typeof pageType === "string" && pageType.length > 0)
448
+ );
449
+
450
+ const currentPath = Astro.url.pathname.replace(/\/$/, "") || "/";
451
+
452
+ // SEO computation
453
+ const metaDescription = description ?? siteDescription;
454
+ const metaOgImage = ogImage ?? defaultOgImage;
455
+ const isAbsoluteSiteUrl = /^https?:\/\//.test(siteUrl);
456
+ const computedCanonical = canonicalUrl
457
+ ?? (isAbsoluteSiteUrl
458
+ ? siteUrl.replace(/\/$/, "") + Astro.url.pathname
459
+ : undefined);
460
+
461
+ // Markdown mirror — append `.md` to the current path for the alternate link
462
+ const mdMirrorHref = mdMirror
463
+ ? (Astro.url.pathname.replace(/\/$/, "") || "") + ".md"
464
+ : undefined;
465
+
466
+ // Tag keywords for HTML meta + JSON-LD. Slug-based identifiers
467
+ // (`difficulty/1`, `concept/a11y`) mean nothing to a search-engine
468
+ // indexer or social-card preview; the human label is the unit
469
+ // machines should consume. The resolver applies `tagLabels`
470
+ // overrides when set, falls back to the leaf segment otherwise.
471
+ // Empty list when no tags — meta tags + JSON-LD silently elide.
472
+ const tagKeywords = resolveTagKeywords(tags, tagLabels);
473
+
474
+ // JSON-LD block. Shipped when ogType is "article" (the DocsLayout
475
+ // default) and at least one keyword is set, so empty-frontmatter
476
+ // pages don't ship a near-empty JSON blob. Schema.org's `keywords`
477
+ // is a comma-separated string by convention.
478
+ //
479
+ // `@type` is selected from `pageType` (sourced from `meta.type`):
480
+ // tutorial / how-to → HowTo; reference → TechArticle; course →
481
+ // Course; everything else → Article. Matches the conventions
482
+ // search engines key off for educational / tutorial / reference
483
+ // SERP rendering. See `json-ld.ts` for the full mapping table.
484
+ const articleJsonLd = (ogType === "article" && tagKeywords.length > 0)
485
+ ? {
486
+ "@context": "https://schema.org",
487
+ "@type": jsonLdTypeFor(pageType),
488
+ headline: title,
489
+ keywords: tagKeywords.join(", "),
490
+ ...(metaDescription ? { description: metaDescription } : {}),
491
+ ...(metaOgImage ? { image: metaOgImage } : {}),
492
+ ...(computedCanonical ? { url: computedCanonical } : {}),
493
+ }
494
+ : undefined;
495
+
496
+ // `customJsonLd` accepts either a single object or an array.
497
+ // Normalize to an array so we can iterate uniformly. Empty array
498
+ // when not set so the template can `.map` safely.
499
+ const customJsonLdBlocks = normalizeCustomJsonLd(customJsonLd);
500
+
501
+ // Default icon for the site logo
502
+ const siteIcon = '<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"><path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H19a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6.5a1 1 0 0 1 0-5H20"/></svg>';
503
+ ---
504
+
505
+ <!doctype html>
506
+ <html lang="en">
507
+ <head>
508
+ <meta charset="UTF-8" />
509
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
510
+ <title>{title} | {siteName}</title>
511
+
512
+ {metaDescription && <meta name="description" content={metaDescription} />}
513
+ {favicon !== false && <link rel="icon" href={favicon} />}
514
+ {themeColor && <meta name="theme-color" content={themeColor} />}
515
+ {/* External search engine directive — orthogonal to in-site
516
+ Pagefind exclusion. */}
517
+ {noindex && <meta name="robots" content="noindex, nofollow" />}
518
+
519
+ {/* In-site Pagefind exclusion is wired via two coordinated
520
+ attributes on <body> and <main> below — see the prop docs
521
+ on `excludeFromSearch` for why both halves are needed. No
522
+ meta-tag signal here: <meta name="pagefind-exclude"> is
523
+ not in Pagefind's documented surface (the `data-pagefind-*`
524
+ attributes are), so emitting it would be cargo-culting. */}
525
+
526
+ {/* Pagefind facet filters live inside <main data-pagefind-body>
527
+ below — keeps them inside the indexed scope while excluding
528
+ sidebar / header / TOC text from per-page excerpts. See main. */}
529
+
530
+ {computedCanonical && <link rel="canonical" href={computedCanonical} />}
531
+ {mdMirrorHref && <link rel="alternate" type="text/markdown" href={mdMirrorHref} />}
532
+
533
+ {/* Open Graph */}
534
+ <meta property="og:type" content={ogType} />
535
+ <meta property="og:title" content={title} />
536
+ {metaDescription && <meta property="og:description" content={metaDescription} />}
537
+ <meta property="og:site_name" content={siteName} />
538
+ {computedCanonical && <meta property="og:url" content={computedCanonical} />}
539
+ {metaOgImage && <meta property="og:image" content={metaOgImage} />}
540
+ {/* OpenGraph article tags — one per resolved keyword. Used by
541
+ FB / LinkedIn / news aggregators to surface topic context.
542
+ Skipped when ogType is not "article" or no tags are set. */}
543
+ {ogType === "article" && tagKeywords.map((keyword) => (
544
+ <meta property="article:tag" content={keyword} />
545
+ ))}
546
+
547
+ {/* JSON-LD primary block — Article / HowTo / TechArticle /
548
+ Course depending on `pageType`. Emits keywords as a
549
+ Schema.org keywords string; Google reads this for topical
550
+ categorization in rich results. */}
551
+ {articleJsonLd && (
552
+ <script type="application/ld+json" set:html={JSON.stringify(articleJsonLd)} />
553
+ )}
554
+
555
+ {/* Per-page / per-site custom structured data. Layered on
556
+ top of the auto-emitted block so writers can describe
557
+ Course / Person / Organization / BreadcrumbList without
558
+ forking the layout. */}
559
+ {customJsonLdBlocks.map((block) => (
560
+ <script type="application/ld+json" set:html={JSON.stringify(block)} />
561
+ ))}
562
+
563
+ {/* Twitter / X */}
564
+ <meta name="twitter:card" content={metaOgImage ? "summary_large_image" : "summary"} />
565
+ <meta name="twitter:title" content={title} />
566
+ {metaDescription && <meta name="twitter:description" content={metaDescription} />}
567
+ {metaOgImage && <meta name="twitter:image" content={metaOgImage} />}
568
+ {twitterHandle && <meta name="twitter:site" content={twitterHandle} />}
569
+
570
+ {plausibleDomain && (
571
+ <script
572
+ is:inline
573
+ defer
574
+ data-domain={plausibleDomain}
575
+ src={plausibleScriptUrl ?? "https://plausible.io/js/script.js"}
576
+ ></script>
577
+ )}
578
+
579
+ <script is:inline>
580
+ if (localStorage.getItem("theme") === "dark") {
581
+ document.documentElement.classList.add("dark");
582
+ }
583
+ </script>
584
+ <slot name="head" />
585
+ </head>
586
+ <body
587
+ class="bg-background text-foreground antialiased"
588
+ data-pagefind-ignore={excludeFromSearch ? "" : undefined}
589
+ >
590
+ <SidebarProvider>
591
+ <Sidebar collapsible="icon">
592
+ <SidebarHeader>
593
+ <SidebarMenu>
594
+ <SidebarMenuItem>
595
+ <SidebarMenuButton size="lg" href={siteUrl} isActive={currentPath === siteUrl || currentPath === "/"}>
596
+ <div class="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
597
+ <Fragment set:html={siteIcon} />
598
+ </div>
599
+ <div class="grid flex-1 text-left text-sm leading-tight">
600
+ <span class="truncate font-semibold">{siteName}</span>
601
+ <span class="truncate text-xs text-sidebar-foreground/70">Documentation</span>
602
+ </div>
603
+ </SidebarMenuButton>
604
+ </SidebarMenuItem>
605
+ </SidebarMenu>
606
+ </SidebarHeader>
607
+
608
+ <SidebarSeparator />
609
+
610
+ <SidebarContent>
611
+ {navGroups ? (
612
+ navGroups.map(group => (
613
+ <SidebarGroup>
614
+ <SidebarGroupLabel>{group.label}</SidebarGroupLabel>
615
+ <SidebarGroupContent>
616
+ <SidebarNavTree
617
+ items={filterNavByAxis(group.items, {
618
+ basePath: basePath ?? "/docs",
619
+ version: multiSource?.version,
620
+ locale: multiSource?.locale,
621
+ })}
622
+ currentPath={currentPath}
623
+ />
624
+ </SidebarGroupContent>
625
+ </SidebarGroup>
626
+ ))
627
+ ) : (
628
+ <SidebarGroup>
629
+ <SidebarGroupContent>
630
+ <SidebarNavTree
631
+ items={filterNavByAxis(nav, {
632
+ basePath: basePath ?? "/docs",
633
+ version: multiSource?.version,
634
+ locale: multiSource?.locale,
635
+ })}
636
+ currentPath={currentPath}
637
+ />
638
+ </SidebarGroupContent>
639
+ </SidebarGroup>
640
+ )}
641
+ </SidebarContent>
642
+
643
+ <SidebarRail />
644
+ </Sidebar>
645
+
646
+ <SidebarInset>
647
+ <header class="sticky top-0 z-40 flex h-12 shrink-0 items-center gap-2 border-b bg-background/95 px-4 backdrop-blur-sm supports-[backdrop-filter]:bg-background/60">
648
+ <SidebarTrigger class="-ml-1" />
649
+ <Separator orientation="vertical" class="mr-2 h-4" />
650
+ <span class="text-sm text-muted-foreground" data-page-title>{title}</span>
651
+ <div class="ml-auto flex items-center gap-2">
652
+ <slot name="header" />
653
+ {switcherMap && multiSource && (
654
+ <LocaleSwitcher
655
+ switcherMap={switcherMap}
656
+ multiSource={multiSource}
657
+ basePath={basePath ?? "/docs"}
658
+ />
659
+ )}
660
+ {switcherMap && multiSource && (
661
+ <VersionSwitcher
662
+ switcherMap={switcherMap}
663
+ multiSource={multiSource}
664
+ basePath={basePath ?? "/docs"}
665
+ />
666
+ )}
667
+ {showLlmActionsHeader && (
668
+ <PageActions
669
+ markdownBody={llmActions!.markdownBody}
670
+ mdUrl={llmActions!.mdUrl!}
671
+ providers={llmActions!.providers}
672
+ copyButton={llmActions!.copyButton}
673
+ promptTemplate={llmActions!.promptTemplate}
674
+ />
675
+ )}
676
+ {!hideSearch && (
677
+ <SearchDialog
678
+ pagefindUrl={pagefindUrl}
679
+ taxonomyDisplay={taxonomyDisplay}
680
+ />
681
+ )}
682
+ {repoUrl && (
683
+ <a href={repoUrl} target="_blank" rel="noopener" class="rounded-md p-2 text-muted-foreground hover:bg-accent hover:text-foreground" aria-label="Source repository">
684
+ <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
685
+ </a>
686
+ )}
687
+ <ThemeToggle />
688
+ </div>
689
+ </header>
690
+
691
+ <div class:list={["flex min-w-0", className]}>
692
+ <main
693
+ class="min-w-0 flex-1 px-6 py-8 lg:px-12"
694
+ data-pagefind-body={excludeFromSearch ? undefined : ""}
695
+ >
696
+ {/*
697
+ Pagefind facet filter elements. Placed inside the
698
+ `data-pagefind-body` scope so Pagefind discovers
699
+ them while indexing. (When `excludeFromSearch` is
700
+ true the body element is omitted, but so is the
701
+ entire indexing of this page — these elements are
702
+ effectively dead too, which is correct for excluded
703
+ pages.) Moved here from the page-level <body>
704
+ after we discovered every page's excerpt was
705
+ just sidebar nav text (see DocsLayout history). The
706
+ data-pagefind-body attribute on <main> tells Pagefind
707
+ to ignore everything outside this element (sidebar,
708
+ header, TOC, footer), so per-page excerpts come from
709
+ the actual page content. Filter elements live in here
710
+ now to stay inside that scope.
711
+ */}
712
+ {Array.isArray(tags) && tags.map((tag) => (
713
+ <div hidden data-pagefind-filter={`tag:${tag}`}></div>
714
+ ))}
715
+ {Array.isArray(audience) && audience.map((value) => (
716
+ <div hidden data-pagefind-filter={`audience:${value}`}></div>
717
+ ))}
718
+ {Array.isArray(category) && category.map((value) => (
719
+ <div hidden data-pagefind-filter={`category:${value}`}></div>
720
+ ))}
721
+ {status && <div hidden data-pagefind-filter={`status:${status}`}></div>}
722
+ {pageType && <div hidden data-pagefind-filter={`type:${pageType}`}></div>}
723
+
724
+ <div class:list={["mx-auto", wideLayout ? "max-w-7xl" : "max-w-3xl"]}>
725
+ {/*
726
+ Inline page-actions cluster — sits below breadcrumb
727
+ / above auto-H1 so it reads as "actions for this
728
+ page" rather than "actions for the site". Header
729
+ placement is configured separately; both can be
730
+ active for sites that want sticky-header + inline.
731
+ See plans/llm-page-actions.md.
732
+ */}
733
+ {showLlmActionsInline && (
734
+ <div class="mb-4 flex justify-end" data-page-actions-inline>
735
+ <PageActions
736
+ markdownBody={llmActions!.markdownBody}
737
+ mdUrl={llmActions!.mdUrl!}
738
+ providers={llmActions!.providers}
739
+ copyButton={llmActions!.copyButton}
740
+ promptTemplate={llmActions!.promptTemplate}
741
+ />
742
+ </div>
743
+ )}
744
+
745
+ {/*
746
+ Auto-rendered H1 + lede paragraph. format-astro
747
+ computes whether to inject these per page via
748
+ `detectLeadingNodes(page.tree)` — true when the
749
+ markdown body doesn't already provide them. Sites
750
+ with markdown-first authoring (`# Title\nLede.`)
751
+ see no change. See plans/auto-lede.md.
752
+
753
+ Order: H1 → lede → meta strip → page body. Reader
754
+ gets context (title, summary) before classification
755
+ (chips), then content.
756
+ */}
757
+ {autoH1 && (
758
+ <h1 class="text-3xl font-bold tracking-tight mb-2">
759
+ {title}
760
+ </h1>
761
+ )}
762
+ {autoLede && description && (
763
+ <p class="text-lg text-muted-foreground mb-6">
764
+ {description}
765
+ </p>
766
+ )}
767
+
768
+ {hasMetaStrip && (
769
+ <div
770
+ class="not-prose mb-6 flex flex-wrap items-center gap-2"
771
+ data-page-meta-strip
772
+ data-pagefind-ignore
773
+ >
774
+ {/*
775
+ data-pagefind-ignore: chip text ("How-to",
776
+ "Concept: Accessibility", etc.) is decorative —
777
+ the same data lives in `data-pagefind-filter`
778
+ elements above, structured for facets. Without
779
+ this attribute, every page's search-result
780
+ excerpt picked up "how to Concept:Accessibility"
781
+ as its leading text, drowning out actual prose.
782
+ */}
783
+ {pageType && <TypeBadge type={pageType} href={typeBadgeHref} />}
784
+ {status && <StatusBadge status={status} href={statusBadgeHref} />}
785
+ {tags && tags.length > 0 && (
786
+ <TagList
787
+ tags={tags}
788
+ indexPath={tagsIndexPath}
789
+ prefixes={tagPrefixes}
790
+ labels={tagLabels}
791
+ />
792
+ )}
793
+ </div>
794
+ )}
795
+
796
+ <slot />
797
+
798
+ <DocsFooter
799
+ editUrl={editUrl}
800
+ lastUpdated={lastUpdated}
801
+ prev={prev}
802
+ next={next}
803
+ copyright={copyright}
804
+ llmsLink={llmFooterLink}
805
+ />
806
+ </div>
807
+ </main>
808
+
809
+ {headings.length > 0 && !wideLayout && (
810
+ <aside class="sticky top-12 hidden h-[calc(100vh-3rem)] w-56 shrink-0 overflow-y-auto border-l p-4 lg:block">
811
+ <DocsToc headings={headings} />
812
+ </aside>
813
+ )}
814
+ </div>
815
+ </SidebarInset>
816
+ </SidebarProvider>
817
+ </body>
818
+ </html>
819
+
820
+ <script>
821
+ import "@dogsbay/ui/sidebar/sidebar.ts";
822
+
823
+ function updateActiveNav() {
824
+ const path = window.location.pathname.replace(/\/$/, "") || "/";
825
+
826
+ document.querySelectorAll("[data-nav-href]").forEach((el) => {
827
+ const href = el.getAttribute("data-nav-href");
828
+ const isActive = href === path;
829
+ if (isActive) {
830
+ el.setAttribute("data-active", "");
831
+ el.classList.add("bg-sidebar-accent", "font-medium", "text-sidebar-accent-foreground");
832
+ } else {
833
+ el.removeAttribute("data-active");
834
+ el.classList.remove("bg-sidebar-accent", "font-medium", "text-sidebar-accent-foreground");
835
+ }
836
+ });
837
+
838
+ // Open ancestor details for active item
839
+ const activeLink = document.querySelector(`[data-nav-href="${path}"]`);
840
+ if (activeLink) {
841
+ let parent = activeLink.parentElement;
842
+ while (parent) {
843
+ if (parent.tagName === "DETAILS" && !parent.hasAttribute("open")) {
844
+ parent.setAttribute("open", "");
845
+ }
846
+ parent = parent.parentElement;
847
+ }
848
+ }
849
+
850
+ // Update page title in header
851
+ const titleEl = document.querySelector("[data-page-title]");
852
+ if (titleEl) {
853
+ titleEl.textContent = document.title.split(" | ")[0];
854
+ }
855
+ }
856
+
857
+ // Handle branch items with href — clicking label navigates, chevron toggles
858
+ function setupBranchLinks() {
859
+ document.querySelectorAll("summary[data-nav-href]").forEach((summary) => {
860
+ summary.addEventListener("click", (e) => {
861
+ const target = e.target as HTMLElement;
862
+ if (target.closest("[data-chevron]")) return;
863
+
864
+ const href = summary.getAttribute("data-nav-href");
865
+ if (!href) return;
866
+ const path = window.location.pathname.replace(/\/$/, "") || "/";
867
+ if (path === href) return;
868
+
869
+ e.preventDefault();
870
+ const details = summary.closest("details");
871
+ if (details) details.open = true;
872
+ window.location.href = href;
873
+ });
874
+ });
875
+ }
876
+
877
+ setupBranchLinks();
878
+ updateActiveNav();
879
+
880
+ document.addEventListener("astro:after-swap", () => {
881
+ setupBranchLinks();
882
+ updateActiveNav();
883
+ });
884
+ </script>