@dogsbay/docs-layout 0.2.0-beta.7 → 0.2.0-beta.71
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 +4 -4
- package/src/DocsLayout.astro +182 -30
- package/src/DocsNavClient.astro +89 -0
- package/src/SearchDialog.astro +24 -4
- package/src/TaxonomyTerm.astro +15 -4
- package/src/docs-nav-client.ts +265 -0
- package/src/json-ld.ts +77 -0
- package/src/version-redirect.ts +23 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dogsbay/docs-layout",
|
|
3
|
-
"version": "0.2.0-beta.
|
|
3
|
+
"version": "0.2.0-beta.71",
|
|
4
4
|
"description": "Standard documentation layout components for Dogsbay",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -29,14 +29,14 @@
|
|
|
29
29
|
"./json-ld": "./src/json-ld.ts"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@dogsbay/ui": "0.2.0-beta.
|
|
33
|
-
"@dogsbay/primitives": "0.2.0-beta.
|
|
32
|
+
"@dogsbay/ui": "0.2.0-beta.71",
|
|
33
|
+
"@dogsbay/primitives": "0.2.0-beta.71"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
36
|
"vitest": "^3.0.0"
|
|
37
37
|
},
|
|
38
38
|
"peerDependencies": {
|
|
39
|
-
"astro": "^5.0.0 || ^6.0.0"
|
|
39
|
+
"astro": "^5.0.0 || ^6.0.0 || ^7.0.0"
|
|
40
40
|
},
|
|
41
41
|
"files": [
|
|
42
42
|
"src",
|
package/src/DocsLayout.astro
CHANGED
|
@@ -31,6 +31,7 @@ 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";
|
|
@@ -44,7 +45,7 @@ import VersionSwitcher from "./VersionSwitcher.astro";
|
|
|
44
45
|
import LocaleSwitcher from "./LocaleSwitcher.astro";
|
|
45
46
|
import { filterNavByAxis } from "./nav-filter.js";
|
|
46
47
|
import type { LlmProviderName } from "./llm-actions.js";
|
|
47
|
-
import { jsonLdTypeFor, normalizeCustomJsonLd } from "./json-ld.js";
|
|
48
|
+
import { jsonLdTypeFor, normalizeCustomJsonLd, buildArticleJsonLd } from "./json-ld.js";
|
|
48
49
|
import { resolveTagKeywords } from "./tag-list-data.js";
|
|
49
50
|
|
|
50
51
|
interface NavItem {
|
|
@@ -112,7 +113,13 @@ interface Props {
|
|
|
112
113
|
siteDescription?: string;
|
|
113
114
|
/** Copyright text (HTML allowed) */
|
|
114
115
|
copyright?: string;
|
|
115
|
-
/**
|
|
116
|
+
/**
|
|
117
|
+
* Favicon path. No host-root default — that 404s on subpath
|
|
118
|
+
* deploys. The format-astro emitter passes a combined-prefix
|
|
119
|
+
* path (e.g. `/<repo>/favicon.ico`) computed from site.url +
|
|
120
|
+
* basePath. Pass an empty string or `false` to disable. When
|
|
121
|
+
* undefined, no `<link rel="icon">` is emitted.
|
|
122
|
+
*/
|
|
116
123
|
favicon?: string | false;
|
|
117
124
|
/** Per-page OG image URL. Overrides defaultOgImage. */
|
|
118
125
|
ogImage?: string;
|
|
@@ -127,16 +134,25 @@ interface Props {
|
|
|
127
134
|
/** Theme color hint for browsers (hex string) */
|
|
128
135
|
themeColor?: string;
|
|
129
136
|
/**
|
|
130
|
-
* Emit `<meta name="robots"
|
|
137
|
+
* Emit `noindex` in the `<meta name="robots">` directive when
|
|
131
138
|
* true. Tells external search engines (Google, Bing) to skip
|
|
132
|
-
* this page.
|
|
133
|
-
*
|
|
134
|
-
* page
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
139
|
+
* indexing this page. Independent of `nofollow` — common pattern
|
|
140
|
+
* for tag / index pages is noindex + follow (don't list this
|
|
141
|
+
* page in results, but do crawl through to the real content).
|
|
142
|
+
*
|
|
143
|
+
* Has NO effect on in-site Pagefind search — for that, use
|
|
144
|
+
* `excludeFromSearch`. The two are independent: a page can be
|
|
145
|
+
* excluded from external SEs but still appear in Pagefind (e.g.
|
|
146
|
+
* duplicate / old content readers might still want to find when
|
|
147
|
+
* they're already on the site), or vice versa.
|
|
138
148
|
*/
|
|
139
149
|
noindex?: boolean;
|
|
150
|
+
/**
|
|
151
|
+
* Emit `nofollow` in the `<meta name="robots">` directive when
|
|
152
|
+
* true. Tells crawlers not to follow outbound links from this
|
|
153
|
+
* page. Independent of `noindex`. Default false.
|
|
154
|
+
*/
|
|
155
|
+
nofollow?: boolean;
|
|
140
156
|
/**
|
|
141
157
|
* Exclude this page from in-site Pagefind search results.
|
|
142
158
|
*
|
|
@@ -256,6 +272,23 @@ interface Props {
|
|
|
256
272
|
* elements in `<body>`.
|
|
257
273
|
*/
|
|
258
274
|
category?: string[];
|
|
275
|
+
/**
|
|
276
|
+
* Custom-taxonomy values from `meta.taxonomies` — anything declared
|
|
277
|
+
* in `taxonomies:` config that isn't one of the five hardcoded
|
|
278
|
+
* built-ins (`tags`, `category`, `audience`, `type`, `status`).
|
|
279
|
+
*
|
|
280
|
+
* Each entry becomes one `<div data-pagefind-filter="<name>:<value>">`
|
|
281
|
+
* inside the indexed body, so the search dialog grows a checkbox
|
|
282
|
+
* group for every custom taxonomy automatically. No extra config
|
|
283
|
+
* needed beyond declaring the taxonomy in `dogsbay.config.yml` —
|
|
284
|
+
* the search dialog discovers facets at index time and renders
|
|
285
|
+
* whatever Pagefind reports.
|
|
286
|
+
*
|
|
287
|
+
* Display labels for the checkboxes flow through
|
|
288
|
+
* `taxonomyDisplay[<name>]` (same prefix/label config that drives
|
|
289
|
+
* chip rendering elsewhere). When unset, raw slugs are shown.
|
|
290
|
+
*/
|
|
291
|
+
taxonomies?: Record<string, string[]>;
|
|
259
292
|
/**
|
|
260
293
|
* Map of taxonomy name → index path for declared taxonomies.
|
|
261
294
|
* Used to wire links from built-in field badges (TypeBadge,
|
|
@@ -324,6 +357,19 @@ interface Props {
|
|
|
324
357
|
* (`<basePath>/<version>/`). Defaults to "/docs".
|
|
325
358
|
*/
|
|
326
359
|
basePath?: string;
|
|
360
|
+
/**
|
|
361
|
+
* Sidebar navigation render mode.
|
|
362
|
+
*
|
|
363
|
+
* - `"client"` (default): emit the small `<DocsNavClient />` placeholder;
|
|
364
|
+
* the tree is hydrated from `/_dogsbay/nav.json` once per session.
|
|
365
|
+
* Page HTML shrinks dramatically at scale (~50 KB vs ~1.2 MB on a
|
|
366
|
+
* 2k-page site). No-JS users see a sitemap fallback link.
|
|
367
|
+
* - `"ssr-full"`: render the full nav tree into every page's HTML.
|
|
368
|
+
* Best for very small sites or strict no-JS / SEO contexts.
|
|
369
|
+
*
|
|
370
|
+
* See plans/client-rendered-nav.md.
|
|
371
|
+
*/
|
|
372
|
+
navMode?: "client" | "ssr-full";
|
|
327
373
|
/**
|
|
328
374
|
* Per-page LLM action UI. When set and `enabled !== false`, renders
|
|
329
375
|
* the PageActions cluster (Copy markdown + Open in Claude/ChatGPT/
|
|
@@ -376,7 +422,7 @@ const {
|
|
|
376
422
|
editUrl,
|
|
377
423
|
lastUpdated,
|
|
378
424
|
copyright,
|
|
379
|
-
favicon
|
|
425
|
+
favicon,
|
|
380
426
|
ogImage,
|
|
381
427
|
defaultOgImage,
|
|
382
428
|
ogType = "article",
|
|
@@ -384,11 +430,18 @@ const {
|
|
|
384
430
|
twitterHandle,
|
|
385
431
|
themeColor,
|
|
386
432
|
noindex,
|
|
433
|
+
nofollow,
|
|
387
434
|
excludeFromSearch,
|
|
388
435
|
plausibleDomain,
|
|
389
436
|
plausibleScriptUrl,
|
|
390
437
|
hideSearch = false,
|
|
391
|
-
pagefindUrl
|
|
438
|
+
// No default for pagefindUrl — host-root absolute paths break on
|
|
439
|
+
// subpath-mounted deploys (GH Pages project pages, multi-mount
|
|
440
|
+
// Cloudflare). format-astro's emitter always passes the
|
|
441
|
+
// combined-prefix-aware URL; manual instantiation must too.
|
|
442
|
+
// Undefined here propagates to SearchDialog where the JS loader
|
|
443
|
+
// throws on first open instead of silently 404'ing the bundle.
|
|
444
|
+
pagefindUrl,
|
|
392
445
|
mdMirror = false,
|
|
393
446
|
tags,
|
|
394
447
|
tagsIndexPath = "/tags",
|
|
@@ -398,6 +451,7 @@ const {
|
|
|
398
451
|
pageType,
|
|
399
452
|
audience,
|
|
400
453
|
category,
|
|
454
|
+
taxonomies,
|
|
401
455
|
taxonomyIndexPaths,
|
|
402
456
|
taxonomyDisplay,
|
|
403
457
|
autoH1,
|
|
@@ -407,6 +461,7 @@ const {
|
|
|
407
461
|
multiSource,
|
|
408
462
|
switcherMap,
|
|
409
463
|
basePath,
|
|
464
|
+
navMode = "client",
|
|
410
465
|
wideLayout = false,
|
|
411
466
|
class: className,
|
|
412
467
|
} = Astro.props;
|
|
@@ -428,6 +483,16 @@ const showLlmActionsInline =
|
|
|
428
483
|
llmActionsEnabled
|
|
429
484
|
&& (llmActionsPlacement === "inline" || llmActionsPlacement === "both");
|
|
430
485
|
const llmFooterLink = !!llmActions && llmActions.footerLink !== false;
|
|
486
|
+
// Per-mount llms.txt URL — Dogsbay emits `<basePath>/llms.txt`
|
|
487
|
+
// (sitemap-index pattern), so the footer link must be basePath-
|
|
488
|
+
// prefixed too. Falls back to `/llms.txt` when basePath is empty
|
|
489
|
+
// or unset, matching the platform's host-root single-site case.
|
|
490
|
+
const llmsLinkHrefResolved = (() => {
|
|
491
|
+
const bp = (basePath ?? "").replace(/\/+$/, "");
|
|
492
|
+
if (!bp) return "/llms.txt";
|
|
493
|
+
const prefix = bp.startsWith("/") ? bp : `/${bp}`;
|
|
494
|
+
return `${prefix}/llms.txt`;
|
|
495
|
+
})();
|
|
431
496
|
|
|
432
497
|
// Compute href targets for the type / status badges. A field is
|
|
433
498
|
// linkable only when (a) the user declared a `taxonomies.<field>`
|
|
@@ -453,9 +518,25 @@ const currentPath = Astro.url.pathname.replace(/\/$/, "") || "/";
|
|
|
453
518
|
const metaDescription = description ?? siteDescription;
|
|
454
519
|
const metaOgImage = ogImage ?? defaultOgImage;
|
|
455
520
|
const isAbsoluteSiteUrl = /^https?:\/\//.test(siteUrl);
|
|
521
|
+
// Compose canonical from ORIGIN + pathname. siteUrl may carry a
|
|
522
|
+
// path component (the urlBase that drives Astro's `base` — see
|
|
523
|
+
// plans/astro-base-from-site-url.md), and Astro.url.pathname
|
|
524
|
+
// already includes that prefix. Naively concatenating siteUrl +
|
|
525
|
+
// pathname double-counts the urlBase (e.g. .../repo/repo/page).
|
|
526
|
+
// Strip path off siteUrl by reparsing as a URL.
|
|
527
|
+
let canonicalOrigin: string | undefined;
|
|
528
|
+
if (isAbsoluteSiteUrl) {
|
|
529
|
+
try {
|
|
530
|
+
const u = new URL(siteUrl);
|
|
531
|
+
canonicalOrigin = `${u.protocol}//${u.host}`;
|
|
532
|
+
} catch {
|
|
533
|
+
// Malformed siteUrl — fall back to the original (no path) behavior.
|
|
534
|
+
canonicalOrigin = siteUrl.replace(/\/$/, "");
|
|
535
|
+
}
|
|
536
|
+
}
|
|
456
537
|
const computedCanonical = canonicalUrl
|
|
457
|
-
?? (
|
|
458
|
-
?
|
|
538
|
+
?? (canonicalOrigin
|
|
539
|
+
? canonicalOrigin + Astro.url.pathname
|
|
459
540
|
: undefined);
|
|
460
541
|
|
|
461
542
|
// Markdown mirror — append `.md` to the current path for the alternate link
|
|
@@ -482,15 +563,16 @@ const tagKeywords = resolveTagKeywords(tags, tagLabels);
|
|
|
482
563
|
// search engines key off for educational / tutorial / reference
|
|
483
564
|
// SERP rendering. See `json-ld.ts` for the full mapping table.
|
|
484
565
|
const articleJsonLd = (ogType === "article" && tagKeywords.length > 0)
|
|
485
|
-
? {
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
keywords: tagKeywords
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
566
|
+
? buildArticleJsonLd({
|
|
567
|
+
type: jsonLdTypeFor(pageType),
|
|
568
|
+
title,
|
|
569
|
+
siteName,
|
|
570
|
+
keywords: tagKeywords,
|
|
571
|
+
description: metaDescription,
|
|
572
|
+
image: metaOgImage,
|
|
573
|
+
url: computedCanonical,
|
|
574
|
+
headings,
|
|
575
|
+
})
|
|
494
576
|
: undefined;
|
|
495
577
|
|
|
496
578
|
// `customJsonLd` accepts either a single object or an array.
|
|
@@ -510,11 +592,22 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
|
|
|
510
592
|
<title>{title} | {siteName}</title>
|
|
511
593
|
|
|
512
594
|
{metaDescription && <meta name="description" content={metaDescription} />}
|
|
513
|
-
{favicon
|
|
595
|
+
{favicon && <link rel="icon" href={favicon} />}
|
|
514
596
|
{themeColor && <meta name="theme-color" content={themeColor} />}
|
|
515
597
|
{/* External search engine directive — orthogonal to in-site
|
|
516
|
-
Pagefind exclusion.
|
|
517
|
-
|
|
598
|
+
Pagefind exclusion. `noindex` + `nofollow` are independent
|
|
599
|
+
bits per the meta-robots spec; emit only the directives that
|
|
600
|
+
are set. Combining them when both are set keeps the tag
|
|
601
|
+
compact (`<meta name="robots" content="noindex, nofollow">`)
|
|
602
|
+
instead of emitting two tags. */}
|
|
603
|
+
{(noindex || nofollow) && (
|
|
604
|
+
<meta
|
|
605
|
+
name="robots"
|
|
606
|
+
content={[noindex && "noindex", nofollow && "nofollow"]
|
|
607
|
+
.filter(Boolean)
|
|
608
|
+
.join(", ")}
|
|
609
|
+
/>
|
|
610
|
+
)}
|
|
518
611
|
|
|
519
612
|
{/* In-site Pagefind exclusion is wired via two coordinated
|
|
520
613
|
attributes on <body> and <main> below — see the prop docs
|
|
@@ -529,6 +622,12 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
|
|
|
529
622
|
|
|
530
623
|
{computedCanonical && <link rel="canonical" href={computedCanonical} />}
|
|
531
624
|
{mdMirrorHref && <link rel="alternate" type="text/markdown" href={mdMirrorHref} />}
|
|
625
|
+
{/* Programmatic llms.txt discovery — agents that follow head
|
|
626
|
+
link rels get the per-mount llms.txt without parsing HTML.
|
|
627
|
+
Mirrors the existing _headers Link rel="describedby" used by
|
|
628
|
+
Cloudflare Pages / Workers. */}
|
|
629
|
+
<link rel="alternate" type="text/plain" title="llms.txt" href={llmsLinkHrefResolved} />
|
|
630
|
+
<link rel="describedby" type="text/plain" href={llmsLinkHrefResolved} />
|
|
532
631
|
|
|
533
632
|
{/* Open Graph */}
|
|
534
633
|
<meta property="og:type" content={ogType} />
|
|
@@ -592,7 +691,7 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
|
|
|
592
691
|
<SidebarHeader>
|
|
593
692
|
<SidebarMenu>
|
|
594
693
|
<SidebarMenuItem>
|
|
595
|
-
<SidebarMenuButton size="lg" href={
|
|
694
|
+
<SidebarMenuButton size="lg" href={basePath || "/"} isActive={currentPath === basePath || currentPath === "/"}>
|
|
596
695
|
<div class="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
|
|
597
696
|
<Fragment set:html={siteIcon} />
|
|
598
697
|
</div>
|
|
@@ -608,7 +707,24 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
|
|
|
608
707
|
<SidebarSeparator />
|
|
609
708
|
|
|
610
709
|
<SidebarContent>
|
|
611
|
-
{
|
|
710
|
+
{navMode === "client" ? (
|
|
711
|
+
// Client-render mode: emit one DocsNavClient placeholder
|
|
712
|
+
// inside a single SidebarGroup, regardless of navGroups.
|
|
713
|
+
// The group-label shape doesn't apply when the tree is
|
|
714
|
+
// hydrated by JS — multi-group nav is reconstructed
|
|
715
|
+
// client-side from the same /_dogsbay/nav.json shape.
|
|
716
|
+
// See plans/client-rendered-nav.md.
|
|
717
|
+
<SidebarGroup>
|
|
718
|
+
<SidebarGroupContent>
|
|
719
|
+
<DocsNavClient
|
|
720
|
+
currentPath={currentPath}
|
|
721
|
+
basePath={basePath ?? ""}
|
|
722
|
+
version={multiSource?.version}
|
|
723
|
+
locale={multiSource?.locale}
|
|
724
|
+
/>
|
|
725
|
+
</SidebarGroupContent>
|
|
726
|
+
</SidebarGroup>
|
|
727
|
+
) : navGroups ? (
|
|
612
728
|
navGroups.map(group => (
|
|
613
729
|
<SidebarGroup>
|
|
614
730
|
<SidebarGroupLabel>{group.label}</SidebarGroupLabel>
|
|
@@ -709,9 +825,30 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
|
|
|
709
825
|
the actual page content. Filter elements live in here
|
|
710
826
|
now to stay inside that scope.
|
|
711
827
|
*/}
|
|
712
|
-
{
|
|
713
|
-
|
|
714
|
-
|
|
828
|
+
{/*
|
|
829
|
+
Slash-nested tags whose prefix is declared in
|
|
830
|
+
`taxonomies.tags.prefixes` emit as per-prefix filter
|
|
831
|
+
divs so each prefix becomes its own Pagefind facet
|
|
832
|
+
column (Difficulty, Topic, Persona, …) instead of
|
|
833
|
+
pooling under a single "Tag" column.
|
|
834
|
+
|
|
835
|
+
Plain tags and tags whose prefix isn't declared fall
|
|
836
|
+
back to the pooled `tag:` filter — backward-compatible
|
|
837
|
+
for sites that haven't declared prefixes.
|
|
838
|
+
|
|
839
|
+
See plans/per-prefix-search-facets.md.
|
|
840
|
+
*/}
|
|
841
|
+
{Array.isArray(tags) && tags.map((tag) => {
|
|
842
|
+
const slash = tag.indexOf("/");
|
|
843
|
+
if (slash > 0) {
|
|
844
|
+
const prefix = tag.slice(0, slash);
|
|
845
|
+
const leaf = tag.slice(slash + 1);
|
|
846
|
+
if (tagPrefixes && tagPrefixes[prefix]) {
|
|
847
|
+
return <div hidden data-pagefind-filter={`${prefix}:${leaf}`}></div>;
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
return <div hidden data-pagefind-filter={`tag:${tag}`}></div>;
|
|
851
|
+
})}
|
|
715
852
|
{Array.isArray(audience) && audience.map((value) => (
|
|
716
853
|
<div hidden data-pagefind-filter={`audience:${value}`}></div>
|
|
717
854
|
))}
|
|
@@ -720,6 +857,20 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
|
|
|
720
857
|
))}
|
|
721
858
|
{status && <div hidden data-pagefind-filter={`status:${status}`}></div>}
|
|
722
859
|
{pageType && <div hidden data-pagefind-filter={`type:${pageType}`}></div>}
|
|
860
|
+
{/*
|
|
861
|
+
Custom-taxonomy filters. Any taxonomy declared in
|
|
862
|
+
`dogsbay.config.yml` that isn't one of the five built-ins
|
|
863
|
+
flows through here, so `difficulty: intermediate` (etc.)
|
|
864
|
+
becomes a real Pagefind facet checkbox automatically.
|
|
865
|
+
See plans/beta-launch-followups.md for context.
|
|
866
|
+
*/}
|
|
867
|
+
{taxonomies && Object.entries(taxonomies).flatMap(([name, values]) =>
|
|
868
|
+
Array.isArray(values)
|
|
869
|
+
? values.map((value) => (
|
|
870
|
+
<div hidden data-pagefind-filter={`${name}:${value}`}></div>
|
|
871
|
+
))
|
|
872
|
+
: []
|
|
873
|
+
)}
|
|
723
874
|
|
|
724
875
|
<div class:list={["mx-auto", wideLayout ? "max-w-7xl" : "max-w-3xl"]}>
|
|
725
876
|
{/*
|
|
@@ -802,6 +953,7 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
|
|
|
802
953
|
next={next}
|
|
803
954
|
copyright={copyright}
|
|
804
955
|
llmsLink={llmFooterLink}
|
|
956
|
+
llmsLinkHref={llmsLinkHrefResolved}
|
|
805
957
|
/>
|
|
806
958
|
</div>
|
|
807
959
|
</main>
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Client-rendered drop-in replacement for SidebarNavTree.
|
|
4
|
+
*
|
|
5
|
+
* Emits an empty placeholder with the metadata the hydration script
|
|
6
|
+
* needs (nav-url to fetch, current-path to highlight, basePath for
|
|
7
|
+
* version/locale filtering). The actual tree DOM is rendered by
|
|
8
|
+
* `docs-nav-client.ts` after a single fetch of `nav.json` (cached
|
|
9
|
+
* once per session — subsequent navigations re-highlight without
|
|
10
|
+
* re-fetching, courtesy of view transitions).
|
|
11
|
+
*
|
|
12
|
+
* The placeholder shows a few skeleton rows so the layout doesn't
|
|
13
|
+
* shift when the real tree pops in. Skeleton uses the same width as
|
|
14
|
+
* the sidebar so visual jump is minimal even on a slow connection.
|
|
15
|
+
*
|
|
16
|
+
* Trade-offs vs SidebarNavTree:
|
|
17
|
+
* - HTML per page: ~200 bytes (this placeholder + a tiny script
|
|
18
|
+
* tag) vs ~600 KB+ for the SSR tree at scale.
|
|
19
|
+
* - No-JS users see only the skeleton + the `<noscript>` fallback
|
|
20
|
+
* link. A `sitemap.xml` link covers no-JS navigation.
|
|
21
|
+
* - First paint waits for the JS bundle + the JSON fetch. On a 4G
|
|
22
|
+
* connection that's typically <200 ms; the skeleton fills the
|
|
23
|
+
* space until then.
|
|
24
|
+
*
|
|
25
|
+
* See plans/client-rendered-nav.md.
|
|
26
|
+
*/
|
|
27
|
+
interface Props {
|
|
28
|
+
/** Current page's URL pathname; the script uses it to mark the active item. */
|
|
29
|
+
currentPath: string;
|
|
30
|
+
/**
|
|
31
|
+
* URL prefix the host serves under (combined `urlBase` + `basePath`).
|
|
32
|
+
* The script joins this with `/_dogsbay/nav.json` to locate the
|
|
33
|
+
* fetchable nav tree; also threaded to the version/locale filter
|
|
34
|
+
* so multi-axis sites work the same as SSR.
|
|
35
|
+
*/
|
|
36
|
+
basePath?: string;
|
|
37
|
+
/** Current source's version axis value, if multi-version site. */
|
|
38
|
+
version?: string;
|
|
39
|
+
/** Current source's locale axis value, if multi-locale site. */
|
|
40
|
+
locale?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const { currentPath, basePath = "", version, locale } = Astro.props;
|
|
44
|
+
const navUrl = `${basePath}/_dogsbay/nav.json`;
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
<div
|
|
48
|
+
id="docs-nav-root"
|
|
49
|
+
data-nav-url={navUrl}
|
|
50
|
+
data-current-path={currentPath}
|
|
51
|
+
data-base-path={basePath}
|
|
52
|
+
data-version={version ?? ""}
|
|
53
|
+
data-locale={locale ?? ""}
|
|
54
|
+
aria-busy="true"
|
|
55
|
+
aria-label="Documentation navigation"
|
|
56
|
+
>
|
|
57
|
+
<ul class="flex min-w-0 flex-col" data-sidebar="nav-tree" data-level="0">
|
|
58
|
+
{[0, 1, 2, 3, 4, 5].map((i) => (
|
|
59
|
+
<li>
|
|
60
|
+
<div
|
|
61
|
+
class="flex h-8 w-full items-center gap-2 rounded-md px-2"
|
|
62
|
+
aria-hidden="true"
|
|
63
|
+
>
|
|
64
|
+
<div class="h-2 w-2 rounded-full bg-sidebar-foreground/10" />
|
|
65
|
+
<div
|
|
66
|
+
class="h-2 rounded bg-sidebar-foreground/10"
|
|
67
|
+
style={`width: ${50 + ((i * 17) % 35)}%;`}
|
|
68
|
+
/>
|
|
69
|
+
</div>
|
|
70
|
+
</li>
|
|
71
|
+
))}
|
|
72
|
+
</ul>
|
|
73
|
+
<noscript>
|
|
74
|
+
<p class="px-2 py-1.5 text-sm text-sidebar-foreground/70">
|
|
75
|
+
JavaScript is required to render the sidebar. Use the
|
|
76
|
+
<a href={`${basePath}/sitemap.xml`} class="underline">sitemap</a>
|
|
77
|
+
to browse all pages.
|
|
78
|
+
</p>
|
|
79
|
+
</noscript>
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
<script>
|
|
83
|
+
// Single import — Astro/Vite bundles this into one shared chunk
|
|
84
|
+
// referenced from every page that uses DocsNavClient. Browsers
|
|
85
|
+
// cache the chunk, so the nav script ships once per session
|
|
86
|
+
// regardless of how many pages the user visits.
|
|
87
|
+
import { hydrateDocsNav } from "./docs-nav-client.ts";
|
|
88
|
+
hydrateDocsNav();
|
|
89
|
+
</script>
|
package/src/SearchDialog.astro
CHANGED
|
@@ -28,8 +28,13 @@ import type { TaxonomyDisplay } from "@dogsbay/types";
|
|
|
28
28
|
|
|
29
29
|
interface Props {
|
|
30
30
|
/**
|
|
31
|
-
* Path
|
|
32
|
-
*
|
|
31
|
+
* Path where Pagefind's index lives, e.g. `/pagefind/` or
|
|
32
|
+
* `/<repo>/pagefind/` for subpath-mounted deploys. NO DEFAULT —
|
|
33
|
+
* a host-root default would silently 404 on subpath deploys
|
|
34
|
+
* (GH Pages project pages, multi-mount Cloudflare). The
|
|
35
|
+
* format-astro emitter passes the combined-prefix-aware URL;
|
|
36
|
+
* manual instantiations must do the same. When undefined, the
|
|
37
|
+
* search dialog throws on first open with a clear console error.
|
|
33
38
|
*/
|
|
34
39
|
pagefindUrl?: string;
|
|
35
40
|
/** Placeholder text for the search input */
|
|
@@ -44,7 +49,7 @@ interface Props {
|
|
|
44
49
|
}
|
|
45
50
|
|
|
46
51
|
const {
|
|
47
|
-
pagefindUrl
|
|
52
|
+
pagefindUrl,
|
|
48
53
|
placeholder = "Search docs...",
|
|
49
54
|
taxonomyDisplay,
|
|
50
55
|
} = Astro.props;
|
|
@@ -218,7 +223,22 @@ const {
|
|
|
218
223
|
async function ensurePagefindLoaded() {
|
|
219
224
|
if (pagefind) return;
|
|
220
225
|
if (loadingPagefind) return loadingPagefind;
|
|
221
|
-
|
|
226
|
+
// pagefindUrl is required — a "/pagefind/" fallback would
|
|
227
|
+
// silently 404 on subpath-mounted deploys (GH Pages project
|
|
228
|
+
// pages, multi-mount Cloudflare). The emitter always passes
|
|
229
|
+
// a combined-prefix-aware value via data-pagefind-url. If it's
|
|
230
|
+
// missing the page wasn't built through format-astro and the
|
|
231
|
+
// caller forgot to pass it.
|
|
232
|
+
const dataUrl = dialog!.dataset.pagefindUrl;
|
|
233
|
+
if (!dataUrl) {
|
|
234
|
+
console.error(
|
|
235
|
+
"[dogsbay] SearchDialog: pagefindUrl prop missing. " +
|
|
236
|
+
"Pass the combined-prefix path (e.g. '/<base>/pagefind/') " +
|
|
237
|
+
"from your DocsLayout instantiation.",
|
|
238
|
+
);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
const url = dataUrl + "pagefind.js";
|
|
222
242
|
loadingPagefind = (async () => {
|
|
223
243
|
try {
|
|
224
244
|
const mod = (await import(/* @vite-ignore */ url)) as PagefindModule;
|
package/src/TaxonomyTerm.astro
CHANGED
|
@@ -93,15 +93,22 @@ function segmentLabel(depth: number): string {
|
|
|
93
93
|
|
|
94
94
|
const titleSegments = term.fullPath.map((_, i) => segmentLabel(i));
|
|
95
95
|
|
|
96
|
-
// Breadcrumb segments —
|
|
96
|
+
// Breadcrumb segments — only link to prefixes that have their own
|
|
97
|
+
// term page. When a taxonomy is non-hierarchical, only full-path
|
|
98
|
+
// terms exist in `data.terms`; linking to `/tags/<prefix>/` for an
|
|
99
|
+
// intermediate segment would 404. Render those as plain text instead.
|
|
97
100
|
function breadcrumbs() {
|
|
98
|
-
const
|
|
101
|
+
const termKeys = new Set(data.terms.map((t) => t.fullPath.join("/")));
|
|
102
|
+
const out: { label: string; href?: string }[] = [
|
|
99
103
|
{ label: displayName, href: `${data.indexPath}/` },
|
|
100
104
|
];
|
|
101
105
|
for (let i = 0; i < term.fullPath.length - 1; i++) {
|
|
106
|
+
const prefix = term.fullPath.slice(0, i + 1).join("/");
|
|
102
107
|
out.push({
|
|
103
108
|
label: segmentLabel(i),
|
|
104
|
-
href:
|
|
109
|
+
href: termKeys.has(prefix)
|
|
110
|
+
? `${data.indexPath}/${prefix}/`
|
|
111
|
+
: undefined,
|
|
105
112
|
});
|
|
106
113
|
}
|
|
107
114
|
return out;
|
|
@@ -120,7 +127,11 @@ const crumbs = breadcrumbs();
|
|
|
120
127
|
<nav class="mb-3 text-sm text-muted-foreground" aria-label="Breadcrumbs">
|
|
121
128
|
{crumbs.map((c, i) => (
|
|
122
129
|
<span>
|
|
123
|
-
|
|
130
|
+
{c.href ? (
|
|
131
|
+
<a href={c.href} class="hover:text-foreground hover:underline">{c.label}</a>
|
|
132
|
+
) : (
|
|
133
|
+
<span>{c.label}</span>
|
|
134
|
+
)}
|
|
124
135
|
{i < crumbs.length - 1 && <span class="mx-1.5">/</span>}
|
|
125
136
|
</span>
|
|
126
137
|
))}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side sidebar nav hydration.
|
|
3
|
+
*
|
|
4
|
+
* Fetches `/_dogsbay/nav.json` once per session, renders the tree DOM
|
|
5
|
+
* to mirror `@dogsbay/ui/sidebar/SidebarNavTree.astro`'s structure,
|
|
6
|
+
* and re-highlights the current page on each Astro view-transition
|
|
7
|
+
* page-load.
|
|
8
|
+
*
|
|
9
|
+
* Design constraints:
|
|
10
|
+
* - DOM output matches SidebarNavTree exactly (same tags, classes,
|
|
11
|
+
* data-attributes) so Tailwind's compiled CSS styles us correctly
|
|
12
|
+
* and any sidebar-system selectors (e.g. `data-sidebar="nav-tree"`
|
|
13
|
+
* hooks) keep working.
|
|
14
|
+
* - Filter by `version` / `locale` matches `nav-filter.ts`'s SSR
|
|
15
|
+
* filter so a multi-axis site renders the same nav as `ssr-full`
|
|
16
|
+
* mode (without the multiplicative HTML cost).
|
|
17
|
+
* - One fetch per session. The `nav.json` URL is served with
|
|
18
|
+
* `Cache-Control: immutable` by Astro's static build (it lives
|
|
19
|
+
* under `public/_dogsbay/`), so the browser cache holds it across
|
|
20
|
+
* navigations.
|
|
21
|
+
*
|
|
22
|
+
* See plans/client-rendered-nav.md.
|
|
23
|
+
*/
|
|
24
|
+
import { filterNavByAxis } from "./nav-filter.js";
|
|
25
|
+
|
|
26
|
+
interface NavItem {
|
|
27
|
+
label: string;
|
|
28
|
+
href?: string;
|
|
29
|
+
icon?: string;
|
|
30
|
+
children?: NavItem[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Module-level cache so multiple page loads in the same SPA session
|
|
35
|
+
* share the same fetched nav data. Cleared by re-loads (full page
|
|
36
|
+
* navigations) but Astro's view transitions re-execute the script
|
|
37
|
+
* module without reloading the page — so on view-transition the
|
|
38
|
+
* cached promise is reused and no extra fetch fires.
|
|
39
|
+
*/
|
|
40
|
+
let navPromise: Promise<NavItem[]> | null = null;
|
|
41
|
+
|
|
42
|
+
function fetchNav(url: string): Promise<NavItem[]> {
|
|
43
|
+
if (!navPromise) {
|
|
44
|
+
navPromise = fetch(url, { credentials: "same-origin" }).then((r) => {
|
|
45
|
+
if (!r.ok) throw new Error(`nav.json fetch failed: ${r.status}`);
|
|
46
|
+
return r.json() as Promise<NavItem[]>;
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
return navPromise;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function normalize(path: string): string {
|
|
53
|
+
return path.replace(/\/$/, "") || "/";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function hasActiveDescendant(item: NavItem, current: string): boolean {
|
|
57
|
+
if (item.href && normalize(item.href) === current) return true;
|
|
58
|
+
return item.children?.some((c) => hasActiveDescendant(c, current)) ?? false;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Render a single nav item into a `<li>` DOM node. Matches
|
|
63
|
+
* SidebarNavTree's per-level class set exactly so styling stays in
|
|
64
|
+
* sync. Padding is computed from `level` the same way (`8 + level*12`
|
|
65
|
+
* pixels) so indentation lines up across the same render.
|
|
66
|
+
*/
|
|
67
|
+
function renderItem(item: NavItem, current: string, level: number): HTMLLIElement {
|
|
68
|
+
const li = document.createElement("li");
|
|
69
|
+
li.dataset.sidebar = "nav-tree-item";
|
|
70
|
+
|
|
71
|
+
const active = item.href ? normalize(item.href) === current : false;
|
|
72
|
+
const hasChildren = !!item.children && item.children.length > 0;
|
|
73
|
+
const padLeft = `${8 + level * 12}px`;
|
|
74
|
+
const heightClass = level === 0 ? "h-8" : "h-7";
|
|
75
|
+
|
|
76
|
+
if (hasChildren) {
|
|
77
|
+
const details = document.createElement("details");
|
|
78
|
+
if (active || hasActiveDescendant(item, current)) details.open = true;
|
|
79
|
+
|
|
80
|
+
const summary = document.createElement("summary");
|
|
81
|
+
summary.className = [
|
|
82
|
+
"flex w-full min-w-0 cursor-pointer items-center gap-2 rounded-md text-sm text-sidebar-foreground outline-none [list-style:none] ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&::-webkit-details-marker]:hidden",
|
|
83
|
+
heightClass,
|
|
84
|
+
active ? "bg-sidebar-accent font-medium text-sidebar-accent-foreground" : "",
|
|
85
|
+
]
|
|
86
|
+
.filter(Boolean)
|
|
87
|
+
.join(" ");
|
|
88
|
+
summary.style.paddingLeft = padLeft;
|
|
89
|
+
if (item.href) summary.dataset.navHref = item.href;
|
|
90
|
+
if (active) summary.dataset.active = "true";
|
|
91
|
+
|
|
92
|
+
// Chevron SVG — matches SidebarNavTree's rotation-on-open via CSS
|
|
93
|
+
// (`details[open] > summary [data-chevron] { transform: rotate(90deg); }`).
|
|
94
|
+
summary.innerHTML =
|
|
95
|
+
'<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" class="size-4 shrink-0 transition-transform duration-200" data-chevron aria-hidden="true"><polyline points="9 18 15 12 9 6"/></svg>';
|
|
96
|
+
|
|
97
|
+
if (item.icon) {
|
|
98
|
+
const iconSpan = document.createElement("span");
|
|
99
|
+
iconSpan.className = "shrink-0 [&>svg]:size-4";
|
|
100
|
+
iconSpan.innerHTML = item.icon;
|
|
101
|
+
summary.appendChild(iconSpan);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const label = document.createElement("span");
|
|
105
|
+
label.className = "truncate";
|
|
106
|
+
label.textContent = item.label;
|
|
107
|
+
summary.appendChild(label);
|
|
108
|
+
|
|
109
|
+
details.appendChild(summary);
|
|
110
|
+
|
|
111
|
+
// Recurse — nested `<ul>` mirrors SidebarNavTree's `<Astro.self>`.
|
|
112
|
+
const childUl = document.createElement("ul");
|
|
113
|
+
childUl.className = "flex min-w-0 flex-col";
|
|
114
|
+
childUl.dataset.sidebar = "nav-tree";
|
|
115
|
+
childUl.dataset.level = String(level + 1);
|
|
116
|
+
for (const child of item.children!) {
|
|
117
|
+
childUl.appendChild(renderItem(child, current, level + 1));
|
|
118
|
+
}
|
|
119
|
+
details.appendChild(childUl);
|
|
120
|
+
|
|
121
|
+
li.appendChild(details);
|
|
122
|
+
} else {
|
|
123
|
+
const a = document.createElement("a");
|
|
124
|
+
a.href = item.href || "#";
|
|
125
|
+
a.className = [
|
|
126
|
+
"flex w-full min-w-0 items-center gap-2 rounded-md text-sm text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2",
|
|
127
|
+
heightClass,
|
|
128
|
+
active ? "bg-sidebar-accent font-medium text-sidebar-accent-foreground" : "",
|
|
129
|
+
]
|
|
130
|
+
.filter(Boolean)
|
|
131
|
+
.join(" ");
|
|
132
|
+
a.style.paddingLeft = padLeft;
|
|
133
|
+
if (active) a.dataset.active = "true";
|
|
134
|
+
if (item.href) a.dataset.navHref = item.href;
|
|
135
|
+
|
|
136
|
+
// Spacer to align leaf text with branch text (which has a chevron).
|
|
137
|
+
const spacer = document.createElement("span");
|
|
138
|
+
spacer.className = "size-4 shrink-0";
|
|
139
|
+
a.appendChild(spacer);
|
|
140
|
+
|
|
141
|
+
if (item.icon) {
|
|
142
|
+
const iconSpan = document.createElement("span");
|
|
143
|
+
iconSpan.className = "shrink-0 [&>svg]:size-4";
|
|
144
|
+
iconSpan.innerHTML = item.icon;
|
|
145
|
+
a.appendChild(iconSpan);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const label = document.createElement("span");
|
|
149
|
+
label.className = "truncate";
|
|
150
|
+
label.textContent = item.label;
|
|
151
|
+
a.appendChild(label);
|
|
152
|
+
|
|
153
|
+
li.appendChild(a);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return li;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function renderTree(items: NavItem[], current: string, root: HTMLElement): void {
|
|
160
|
+
const ul = document.createElement("ul");
|
|
161
|
+
ul.className =
|
|
162
|
+
"flex min-w-0 flex-col w-full group-data-[collapsible=icon]:hidden";
|
|
163
|
+
ul.dataset.sidebar = "nav-tree";
|
|
164
|
+
ul.dataset.level = "0";
|
|
165
|
+
for (const item of items) {
|
|
166
|
+
ul.appendChild(renderItem(item, current, 0));
|
|
167
|
+
}
|
|
168
|
+
// Replace skeleton in one DOM op so there's no flash of partial state.
|
|
169
|
+
root.replaceChildren(ul);
|
|
170
|
+
root.removeAttribute("aria-busy");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Re-highlight the active item without rebuilding the whole tree.
|
|
175
|
+
* Used on `astro:page-load` when view transitions land on a new
|
|
176
|
+
* route — we re-toggle the `data-active` attribute and re-apply the
|
|
177
|
+
* active classes, then expand the new active branch's ancestors.
|
|
178
|
+
*
|
|
179
|
+
* Cheaper than a full re-render (no fetch, no DOM rebuild). For
|
|
180
|
+
* the wider hydration loop we still re-render when a brand-new nav
|
|
181
|
+
* structure is needed (e.g. switching versions), but path-only
|
|
182
|
+
* navigation just re-highlights.
|
|
183
|
+
*/
|
|
184
|
+
function rehighlight(root: HTMLElement, current: string): void {
|
|
185
|
+
const ACTIVE_CLASSES = [
|
|
186
|
+
"bg-sidebar-accent",
|
|
187
|
+
"font-medium",
|
|
188
|
+
"text-sidebar-accent-foreground",
|
|
189
|
+
];
|
|
190
|
+
const items = root.querySelectorAll<HTMLElement>("[data-nav-href]");
|
|
191
|
+
for (const el of Array.from(items)) {
|
|
192
|
+
const href = el.dataset.navHref;
|
|
193
|
+
const active = !!href && normalize(href) === current;
|
|
194
|
+
if (active) {
|
|
195
|
+
el.dataset.active = "true";
|
|
196
|
+
el.classList.add(...ACTIVE_CLASSES);
|
|
197
|
+
} else {
|
|
198
|
+
delete el.dataset.active;
|
|
199
|
+
el.classList.remove(...ACTIVE_CLASSES);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
// Expand ancestors of the new active item.
|
|
203
|
+
const active = root.querySelector<HTMLElement>('[data-active="true"]');
|
|
204
|
+
if (active) {
|
|
205
|
+
let parent: HTMLElement | null = active.parentElement;
|
|
206
|
+
while (parent) {
|
|
207
|
+
if (parent.tagName === "DETAILS") {
|
|
208
|
+
(parent as HTMLDetailsElement).open = true;
|
|
209
|
+
}
|
|
210
|
+
parent = parent.parentElement;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Public entry point. Called once from `<DocsNavClient />`'s inline
|
|
217
|
+
* script tag. Idempotent — subsequent calls (e.g. from view
|
|
218
|
+
* transitions) are no-ops once the tree has been rendered; they just
|
|
219
|
+
* re-highlight against the new pathname.
|
|
220
|
+
*/
|
|
221
|
+
export async function hydrateDocsNav(): Promise<void> {
|
|
222
|
+
const root = document.getElementById("docs-nav-root");
|
|
223
|
+
if (!root) return;
|
|
224
|
+
|
|
225
|
+
const navUrl = root.dataset.navUrl;
|
|
226
|
+
if (!navUrl) {
|
|
227
|
+
console.warn("docs-nav: missing data-nav-url");
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
const current = normalize(root.dataset.currentPath || location.pathname);
|
|
231
|
+
const basePath = root.dataset.basePath || "";
|
|
232
|
+
const version = root.dataset.version || undefined;
|
|
233
|
+
const locale = root.dataset.locale || undefined;
|
|
234
|
+
|
|
235
|
+
try {
|
|
236
|
+
const nav = await fetchNav(navUrl);
|
|
237
|
+
const filtered = filterNavByAxis(nav, {
|
|
238
|
+
basePath: basePath || "/docs",
|
|
239
|
+
version: version || undefined,
|
|
240
|
+
locale: locale || undefined,
|
|
241
|
+
});
|
|
242
|
+
renderTree(filtered, current, root);
|
|
243
|
+
} catch (err) {
|
|
244
|
+
console.error("docs-nav: hydration failed", err);
|
|
245
|
+
root.setAttribute("aria-busy", "false");
|
|
246
|
+
// Keep skeleton so layout doesn't collapse on failure; a real
|
|
247
|
+
// user will reload or follow the noscript link.
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Re-run on view transitions. astro:page-load fires both on initial
|
|
252
|
+
// load and after each ClientRouter transition; the module-level
|
|
253
|
+
// `navPromise` cache makes the post-transition path a fast
|
|
254
|
+
// highlight-only pass without re-fetching.
|
|
255
|
+
document.addEventListener("astro:page-load", () => {
|
|
256
|
+
const root = document.getElementById("docs-nav-root");
|
|
257
|
+
if (!root) return;
|
|
258
|
+
// If tree is already rendered (no aria-busy), just re-highlight.
|
|
259
|
+
if (root.getAttribute("aria-busy") === null) {
|
|
260
|
+
const current = normalize(root.dataset.currentPath || location.pathname);
|
|
261
|
+
rehighlight(root, current);
|
|
262
|
+
} else {
|
|
263
|
+
void hydrateDocsNav();
|
|
264
|
+
}
|
|
265
|
+
});
|
package/src/json-ld.ts
CHANGED
|
@@ -53,3 +53,80 @@ export function normalizeCustomJsonLd(
|
|
|
53
53
|
if (Array.isArray(raw)) return raw;
|
|
54
54
|
return [raw];
|
|
55
55
|
}
|
|
56
|
+
|
|
57
|
+
export interface BuildArticleJsonLdOptions {
|
|
58
|
+
/** Already-resolved Schema.org `@type` (output of jsonLdTypeFor). */
|
|
59
|
+
type: string;
|
|
60
|
+
title: string;
|
|
61
|
+
/** Site name — used as the `provider` Organization for Course. */
|
|
62
|
+
siteName: string;
|
|
63
|
+
keywords: string[];
|
|
64
|
+
description?: string;
|
|
65
|
+
image?: string;
|
|
66
|
+
url?: string;
|
|
67
|
+
/**
|
|
68
|
+
* Page headings — used to synthesize `step[]` for HowTo. Each
|
|
69
|
+
* H2 (or H3 if no H2s exist) becomes a HowToStep with a deep
|
|
70
|
+
* link to the heading id.
|
|
71
|
+
*/
|
|
72
|
+
headings?: ReadonlyArray<{ depth: number; slug: string; text: string }>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Build the JSON-LD payload for the page's primary structured-data
|
|
77
|
+
* block, shaped per `@type`. The earlier implementation emitted
|
|
78
|
+
* Article-shaped fields (`headline`) regardless of @type, which
|
|
79
|
+
* meant HowTo / Course pages failed Google's Rich Results
|
|
80
|
+
* requirements (HowTo needs `name` + `step`, Course needs `name` +
|
|
81
|
+
* `description` + `provider`). See
|
|
82
|
+
* `packages/cli/src/audit/rules/seo/json-ld-required-fields.ts`
|
|
83
|
+
* for the validator that catches this.
|
|
84
|
+
*
|
|
85
|
+
* Schema.org's `name` is a Thing-level field accepted by every
|
|
86
|
+
* @type, so we always emit it. `headline` is added on top for
|
|
87
|
+
* Article-family types (Article, TechArticle) because Google's
|
|
88
|
+
* Rich Results validator specifically requires it there. HowTo
|
|
89
|
+
* gets a synthesized `step[]` from the page's H2 headings (each
|
|
90
|
+
* H2 = one procedure step); Course gets a `provider` Organization
|
|
91
|
+
* built from `siteName`.
|
|
92
|
+
*/
|
|
93
|
+
export function buildArticleJsonLd(
|
|
94
|
+
opts: BuildArticleJsonLdOptions,
|
|
95
|
+
): Record<string, unknown> {
|
|
96
|
+
const { type, title, siteName, keywords, description, image, url, headings } =
|
|
97
|
+
opts;
|
|
98
|
+
|
|
99
|
+
const block: Record<string, unknown> = {
|
|
100
|
+
"@context": "https://schema.org",
|
|
101
|
+
"@type": type,
|
|
102
|
+
name: title,
|
|
103
|
+
keywords: keywords.join(", "),
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
if (type === "Article" || type === "TechArticle") {
|
|
107
|
+
block.headline = title;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (description) block.description = description;
|
|
111
|
+
if (image) block.image = image;
|
|
112
|
+
if (url) block.url = url;
|
|
113
|
+
|
|
114
|
+
if (type === "HowTo") {
|
|
115
|
+
let stepHeadings = (headings ?? []).filter((h) => h.depth === 2);
|
|
116
|
+
if (stepHeadings.length === 0) {
|
|
117
|
+
stepHeadings = (headings ?? []).filter((h) => h.depth === 3);
|
|
118
|
+
}
|
|
119
|
+
block.step = stepHeadings.map((h) => ({
|
|
120
|
+
"@type": "HowToStep",
|
|
121
|
+
name: h.text,
|
|
122
|
+
url: url ? `${url}#${h.slug}` : `#${h.slug}`,
|
|
123
|
+
}));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (type === "Course") {
|
|
127
|
+
block.provider = { "@type": "Organization", name: siteName };
|
|
128
|
+
if (!block.description) block.description = title;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return block;
|
|
132
|
+
}
|
package/src/version-redirect.ts
CHANGED
|
@@ -35,6 +35,20 @@ export interface AxisRedirectConfig {
|
|
|
35
35
|
defaultLocale?: string;
|
|
36
36
|
/** Full set of declared locale ids. Empty/undefined → axis inactive. */
|
|
37
37
|
knownLocales?: string[];
|
|
38
|
+
/**
|
|
39
|
+
* First-segment names that aren't locale/version-axis-prefixable
|
|
40
|
+
* — e.g. taxonomy index paths like `tags`, `by-type`, `by-status`.
|
|
41
|
+
* Taxonomy routes emit a single global namespace shared across
|
|
42
|
+
* all locales / versions (one `/tags/` for the whole site, not
|
|
43
|
+
* one per locale), so the axis-redirect helper must skip them.
|
|
44
|
+
* Without this skip, chip hrefs to `/<basePath>/tags/...` would
|
|
45
|
+
* 302 to `/<basePath>/<defaultLocale>/tags/...` which 404s.
|
|
46
|
+
*
|
|
47
|
+
* Each entry is the first URL segment after basePath
|
|
48
|
+
* (no leading slash). Sourced from declared
|
|
49
|
+
* `taxonomies.<name>.indexPath` in `dogsbay.config.yml`.
|
|
50
|
+
*/
|
|
51
|
+
globalPrefixes?: string[];
|
|
38
52
|
}
|
|
39
53
|
|
|
40
54
|
/**
|
|
@@ -88,6 +102,15 @@ export function shouldRedirectToDefaultVersion(
|
|
|
88
102
|
// Skip Astro / Pagefind asset paths.
|
|
89
103
|
if (segments[0].startsWith("_") || segments[0] === "pagefind") return null;
|
|
90
104
|
|
|
105
|
+
// Skip global-namespace prefixes (taxonomy index paths and similar
|
|
106
|
+
// routes that don't live under per-locale / per-version trees).
|
|
107
|
+
// Without this, chip hrefs to `/docs/tags/concept/rag/` get
|
|
108
|
+
// redirected to `/docs/<defaultLocale>/tags/concept/rag/` which
|
|
109
|
+
// 404s — the taxonomy routes are emitted once at the unprefixed
|
|
110
|
+
// path. See plans/beta-launch-followups.md.
|
|
111
|
+
const globalPrefixes = config.globalPrefixes ?? [];
|
|
112
|
+
if (globalPrefixes.includes(segments[0])) return null;
|
|
113
|
+
|
|
91
114
|
// Greedy axis detection — locale outermost, version next.
|
|
92
115
|
const knownLocales = new Set(config.knownLocales ?? []);
|
|
93
116
|
const knownVersions = new Set(config.knownVersions ?? []);
|