@dogsbay/docs-layout 0.2.0-beta.4 → 0.2.0-beta.40

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dogsbay/docs-layout",
3
- "version": "0.2.0-beta.4",
3
+ "version": "0.2.0-beta.40",
4
4
  "description": "Standard documentation layout components for Dogsbay",
5
5
  "type": "module",
6
6
  "exports": {
@@ -29,8 +29,8 @@
29
29
  "./json-ld": "./src/json-ld.ts"
30
30
  },
31
31
  "dependencies": {
32
- "@dogsbay/ui": "0.2.0-beta.4",
33
- "@dogsbay/primitives": "0.2.0-beta.4"
32
+ "@dogsbay/ui": "0.2.0-beta.40",
33
+ "@dogsbay/primitives": "0.2.0-beta.40"
34
34
  },
35
35
  "devDependencies": {
36
36
  "vitest": "^3.0.0"
@@ -112,7 +112,13 @@ interface Props {
112
112
  siteDescription?: string;
113
113
  /** Copyright text (HTML allowed) */
114
114
  copyright?: string;
115
- /** Favicon path (default: "/favicon.ico"). Set to false to disable. */
115
+ /**
116
+ * Favicon path. No host-root default — that 404s on subpath
117
+ * deploys. The format-astro emitter passes a combined-prefix
118
+ * path (e.g. `/<repo>/favicon.ico`) computed from site.url +
119
+ * basePath. Pass an empty string or `false` to disable. When
120
+ * undefined, no `<link rel="icon">` is emitted.
121
+ */
116
122
  favicon?: string | false;
117
123
  /** Per-page OG image URL. Overrides defaultOgImage. */
118
124
  ogImage?: string;
@@ -127,16 +133,25 @@ interface Props {
127
133
  /** Theme color hint for browsers (hex string) */
128
134
  themeColor?: string;
129
135
  /**
130
- * Emit `<meta name="robots" content="noindex, nofollow">` when
136
+ * Emit `noindex` in the `<meta name="robots">` directive when
131
137
  * 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
+ * indexing this page. Independent of `nofollow` common pattern
139
+ * for tag / index pages is noindex + follow (don't list this
140
+ * page in results, but do crawl through to the real content).
141
+ *
142
+ * Has NO effect on in-site Pagefind search for that, use
143
+ * `excludeFromSearch`. The two are independent: a page can be
144
+ * excluded from external SEs but still appear in Pagefind (e.g.
145
+ * duplicate / old content readers might still want to find when
146
+ * they're already on the site), or vice versa.
138
147
  */
139
148
  noindex?: boolean;
149
+ /**
150
+ * Emit `nofollow` in the `<meta name="robots">` directive when
151
+ * true. Tells crawlers not to follow outbound links from this
152
+ * page. Independent of `noindex`. Default false.
153
+ */
154
+ nofollow?: boolean;
140
155
  /**
141
156
  * Exclude this page from in-site Pagefind search results.
142
157
  *
@@ -256,6 +271,23 @@ interface Props {
256
271
  * elements in `<body>`.
257
272
  */
258
273
  category?: string[];
274
+ /**
275
+ * Custom-taxonomy values from `meta.taxonomies` — anything declared
276
+ * in `taxonomies:` config that isn't one of the five hardcoded
277
+ * built-ins (`tags`, `category`, `audience`, `type`, `status`).
278
+ *
279
+ * Each entry becomes one `<div data-pagefind-filter="<name>:<value>">`
280
+ * inside the indexed body, so the search dialog grows a checkbox
281
+ * group for every custom taxonomy automatically. No extra config
282
+ * needed beyond declaring the taxonomy in `dogsbay.config.yml` —
283
+ * the search dialog discovers facets at index time and renders
284
+ * whatever Pagefind reports.
285
+ *
286
+ * Display labels for the checkboxes flow through
287
+ * `taxonomyDisplay[<name>]` (same prefix/label config that drives
288
+ * chip rendering elsewhere). When unset, raw slugs are shown.
289
+ */
290
+ taxonomies?: Record<string, string[]>;
259
291
  /**
260
292
  * Map of taxonomy name → index path for declared taxonomies.
261
293
  * Used to wire links from built-in field badges (TypeBadge,
@@ -376,7 +408,7 @@ const {
376
408
  editUrl,
377
409
  lastUpdated,
378
410
  copyright,
379
- favicon = "/favicon.ico",
411
+ favicon,
380
412
  ogImage,
381
413
  defaultOgImage,
382
414
  ogType = "article",
@@ -384,11 +416,18 @@ const {
384
416
  twitterHandle,
385
417
  themeColor,
386
418
  noindex,
419
+ nofollow,
387
420
  excludeFromSearch,
388
421
  plausibleDomain,
389
422
  plausibleScriptUrl,
390
423
  hideSearch = false,
391
- pagefindUrl = "/pagefind/",
424
+ // No default for pagefindUrl host-root absolute paths break on
425
+ // subpath-mounted deploys (GH Pages project pages, multi-mount
426
+ // Cloudflare). format-astro's emitter always passes the
427
+ // combined-prefix-aware URL; manual instantiation must too.
428
+ // Undefined here propagates to SearchDialog where the JS loader
429
+ // throws on first open instead of silently 404'ing the bundle.
430
+ pagefindUrl,
392
431
  mdMirror = false,
393
432
  tags,
394
433
  tagsIndexPath = "/tags",
@@ -398,6 +437,7 @@ const {
398
437
  pageType,
399
438
  audience,
400
439
  category,
440
+ taxonomies,
401
441
  taxonomyIndexPaths,
402
442
  taxonomyDisplay,
403
443
  autoH1,
@@ -428,6 +468,16 @@ const showLlmActionsInline =
428
468
  llmActionsEnabled
429
469
  && (llmActionsPlacement === "inline" || llmActionsPlacement === "both");
430
470
  const llmFooterLink = !!llmActions && llmActions.footerLink !== false;
471
+ // Per-mount llms.txt URL — Dogsbay emits `<basePath>/llms.txt`
472
+ // (sitemap-index pattern), so the footer link must be basePath-
473
+ // prefixed too. Falls back to `/llms.txt` when basePath is empty
474
+ // or unset, matching the platform's host-root single-site case.
475
+ const llmsLinkHrefResolved = (() => {
476
+ const bp = (basePath ?? "").replace(/\/+$/, "");
477
+ if (!bp) return "/llms.txt";
478
+ const prefix = bp.startsWith("/") ? bp : `/${bp}`;
479
+ return `${prefix}/llms.txt`;
480
+ })();
431
481
 
432
482
  // Compute href targets for the type / status badges. A field is
433
483
  // linkable only when (a) the user declared a `taxonomies.<field>`
@@ -453,9 +503,25 @@ const currentPath = Astro.url.pathname.replace(/\/$/, "") || "/";
453
503
  const metaDescription = description ?? siteDescription;
454
504
  const metaOgImage = ogImage ?? defaultOgImage;
455
505
  const isAbsoluteSiteUrl = /^https?:\/\//.test(siteUrl);
506
+ // Compose canonical from ORIGIN + pathname. siteUrl may carry a
507
+ // path component (the urlBase that drives Astro's `base` — see
508
+ // plans/astro-base-from-site-url.md), and Astro.url.pathname
509
+ // already includes that prefix. Naively concatenating siteUrl +
510
+ // pathname double-counts the urlBase (e.g. .../repo/repo/page).
511
+ // Strip path off siteUrl by reparsing as a URL.
512
+ let canonicalOrigin: string | undefined;
513
+ if (isAbsoluteSiteUrl) {
514
+ try {
515
+ const u = new URL(siteUrl);
516
+ canonicalOrigin = `${u.protocol}//${u.host}`;
517
+ } catch {
518
+ // Malformed siteUrl — fall back to the original (no path) behavior.
519
+ canonicalOrigin = siteUrl.replace(/\/$/, "");
520
+ }
521
+ }
456
522
  const computedCanonical = canonicalUrl
457
- ?? (isAbsoluteSiteUrl
458
- ? siteUrl.replace(/\/$/, "") + Astro.url.pathname
523
+ ?? (canonicalOrigin
524
+ ? canonicalOrigin + Astro.url.pathname
459
525
  : undefined);
460
526
 
461
527
  // Markdown mirror — append `.md` to the current path for the alternate link
@@ -510,11 +576,22 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
510
576
  <title>{title} | {siteName}</title>
511
577
 
512
578
  {metaDescription && <meta name="description" content={metaDescription} />}
513
- {favicon !== false && <link rel="icon" href={favicon} />}
579
+ {favicon && <link rel="icon" href={favicon} />}
514
580
  {themeColor && <meta name="theme-color" content={themeColor} />}
515
581
  {/* External search engine directive — orthogonal to in-site
516
- Pagefind exclusion. */}
517
- {noindex && <meta name="robots" content="noindex, nofollow" />}
582
+ Pagefind exclusion. `noindex` + `nofollow` are independent
583
+ bits per the meta-robots spec; emit only the directives that
584
+ are set. Combining them when both are set keeps the tag
585
+ compact (`<meta name="robots" content="noindex, nofollow">`)
586
+ instead of emitting two tags. */}
587
+ {(noindex || nofollow) && (
588
+ <meta
589
+ name="robots"
590
+ content={[noindex && "noindex", nofollow && "nofollow"]
591
+ .filter(Boolean)
592
+ .join(", ")}
593
+ />
594
+ )}
518
595
 
519
596
  {/* In-site Pagefind exclusion is wired via two coordinated
520
597
  attributes on <body> and <main> below — see the prop docs
@@ -529,6 +606,12 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
529
606
 
530
607
  {computedCanonical && <link rel="canonical" href={computedCanonical} />}
531
608
  {mdMirrorHref && <link rel="alternate" type="text/markdown" href={mdMirrorHref} />}
609
+ {/* Programmatic llms.txt discovery — agents that follow head
610
+ link rels get the per-mount llms.txt without parsing HTML.
611
+ Mirrors the existing _headers Link rel="describedby" used by
612
+ Cloudflare Pages / Workers. */}
613
+ <link rel="alternate" type="text/plain" title="llms.txt" href={llmsLinkHrefResolved} />
614
+ <link rel="describedby" type="text/plain" href={llmsLinkHrefResolved} />
532
615
 
533
616
  {/* Open Graph */}
534
617
  <meta property="og:type" content={ogType} />
@@ -592,7 +675,7 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
592
675
  <SidebarHeader>
593
676
  <SidebarMenu>
594
677
  <SidebarMenuItem>
595
- <SidebarMenuButton size="lg" href={siteUrl} isActive={currentPath === siteUrl || currentPath === "/"}>
678
+ <SidebarMenuButton size="lg" href={basePath || "/"} isActive={currentPath === basePath || currentPath === "/"}>
596
679
  <div class="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
597
680
  <Fragment set:html={siteIcon} />
598
681
  </div>
@@ -709,9 +792,30 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
709
792
  the actual page content. Filter elements live in here
710
793
  now to stay inside that scope.
711
794
  */}
712
- {Array.isArray(tags) && tags.map((tag) => (
713
- <div hidden data-pagefind-filter={`tag:${tag}`}></div>
714
- ))}
795
+ {/*
796
+ Slash-nested tags whose prefix is declared in
797
+ `taxonomies.tags.prefixes` emit as per-prefix filter
798
+ divs so each prefix becomes its own Pagefind facet
799
+ column (Difficulty, Topic, Persona, …) instead of
800
+ pooling under a single "Tag" column.
801
+
802
+ Plain tags and tags whose prefix isn't declared fall
803
+ back to the pooled `tag:` filter — backward-compatible
804
+ for sites that haven't declared prefixes.
805
+
806
+ See plans/per-prefix-search-facets.md.
807
+ */}
808
+ {Array.isArray(tags) && tags.map((tag) => {
809
+ const slash = tag.indexOf("/");
810
+ if (slash > 0) {
811
+ const prefix = tag.slice(0, slash);
812
+ const leaf = tag.slice(slash + 1);
813
+ if (tagPrefixes && tagPrefixes[prefix]) {
814
+ return <div hidden data-pagefind-filter={`${prefix}:${leaf}`}></div>;
815
+ }
816
+ }
817
+ return <div hidden data-pagefind-filter={`tag:${tag}`}></div>;
818
+ })}
715
819
  {Array.isArray(audience) && audience.map((value) => (
716
820
  <div hidden data-pagefind-filter={`audience:${value}`}></div>
717
821
  ))}
@@ -720,6 +824,20 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
720
824
  ))}
721
825
  {status && <div hidden data-pagefind-filter={`status:${status}`}></div>}
722
826
  {pageType && <div hidden data-pagefind-filter={`type:${pageType}`}></div>}
827
+ {/*
828
+ Custom-taxonomy filters. Any taxonomy declared in
829
+ `dogsbay.config.yml` that isn't one of the five built-ins
830
+ flows through here, so `difficulty: intermediate` (etc.)
831
+ becomes a real Pagefind facet checkbox automatically.
832
+ See plans/beta-launch-followups.md for context.
833
+ */}
834
+ {taxonomies && Object.entries(taxonomies).flatMap(([name, values]) =>
835
+ Array.isArray(values)
836
+ ? values.map((value) => (
837
+ <div hidden data-pagefind-filter={`${name}:${value}`}></div>
838
+ ))
839
+ : []
840
+ )}
723
841
 
724
842
  <div class:list={["mx-auto", wideLayout ? "max-w-7xl" : "max-w-3xl"]}>
725
843
  {/*
@@ -802,6 +920,7 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
802
920
  next={next}
803
921
  copyright={copyright}
804
922
  llmsLink={llmFooterLink}
923
+ llmsLinkHref={llmsLinkHrefResolved}
805
924
  />
806
925
  </div>
807
926
  </main>
@@ -28,8 +28,13 @@ import type { TaxonomyDisplay } from "@dogsbay/types";
28
28
 
29
29
  interface Props {
30
30
  /**
31
- * Path prefix where Pagefind's index lives. Default: "/pagefind/".
32
- * Override when a site uses a non-root base path (e.g. "/docs/pagefind/").
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 = "/pagefind/",
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
- const url = (dialog!.dataset.pagefindUrl ?? "/pagefind/") + "pagefind.js";
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;
@@ -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 — clickable parent paths.
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 out: { label: string; href: string }[] = [
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: `${data.indexPath}/${term.fullPath.slice(0, i + 1).join("/")}/`,
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
- <a href={c.href} class="hover:text-foreground hover:underline">{c.label}</a>
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
  ))}
@@ -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 ?? []);