@dogsbay/docs-layout 0.2.0-beta.115 → 0.2.0-beta.116

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.115",
3
+ "version": "0.2.0-beta.116",
4
4
  "description": "Standard documentation layout components for Dogsbay",
5
5
  "type": "module",
6
6
  "exports": {
@@ -31,8 +31,9 @@
31
31
  "./json-ld": "./src/json-ld.ts"
32
32
  },
33
33
  "dependencies": {
34
- "@dogsbay/primitives": "0.2.0-beta.115",
35
- "@dogsbay/ui": "0.2.0-beta.115"
34
+ "@dogsbay/primitives": "0.2.0-beta.116",
35
+ "@dogsbay/ui": "0.2.0-beta.116",
36
+ "@dogsbay/site-files": "0.2.0-beta.116"
36
37
  },
37
38
  "devDependencies": {
38
39
  "happy-dom": "^20.10.6",
@@ -45,7 +45,7 @@ import TypeBadge from "./TypeBadge.astro";
45
45
  import PageActions from "./PageActions.astro";
46
46
  import VersionSwitcher from "./VersionSwitcher.astro";
47
47
  import LocaleSwitcher from "./LocaleSwitcher.astro";
48
- import { filterNavByAxis } from "./nav-filter.js";
48
+ import { filterNavByAxis, rootVersionFilter } from "./nav-filter.js";
49
49
  import type { LlmProviderName } from "./llm-actions.js";
50
50
  import { jsonLdTypeFor, normalizeCustomJsonLd, buildArticleJsonLd, buildWebSiteJsonLd } from "./json-ld.js";
51
51
  import { resolveTagKeywords } from "./tag-list-data.js";
@@ -89,6 +89,8 @@ type TagPaletteName =
89
89
  interface Props {
90
90
  /** Site name shown in header and sidebar logo */
91
91
  siteName: string;
92
+ /** The line under the site name in the sidebar header (`site.tagline`). Default "Documentation". */
93
+ siteTagline?: string;
92
94
  /** Page title for <title> tag */
93
95
  title: string;
94
96
  /** Home URL — also used as the base for the canonical link if absolute */
@@ -131,6 +133,10 @@ interface Props {
131
133
  ogType?: "article" | "website";
132
134
  /** Canonical URL for this page. Auto-computed from siteUrl + pathname when absolute siteUrl is provided. */
133
135
  canonicalUrl?: string;
136
+ /** `hreflang` alternates for this page's copies in other languages (absolute URLs), written after the canonical link. */
137
+ alternates?: { hreflang: string; href: string }[];
138
+ /** JSON-LD `BreadcrumbList` for this page, written after its own JSON-LD block. */
139
+ breadcrumbJsonLd?: Record<string, unknown>;
134
140
  /** Twitter / X handle including the leading "@" */
135
141
  twitterHandle?: string;
136
142
  /** Theme color hint for browsers (hex string) */
@@ -237,6 +243,12 @@ interface Props {
237
243
  * `taxonomies.tags.labels`.
238
244
  */
239
245
  tagLabels?: Record<string, string>;
246
+ /**
247
+ * Color chips whose prefix has no color automatically (a stable
248
+ * palette color per first segment). Sourced from
249
+ * `taxonomies.tags.autoColor`.
250
+ */
251
+ tagAutoColor?: boolean;
240
252
  /**
241
253
  * Page lifecycle status. Renders a StatusBadge for non-stable
242
254
  * states. Drafts/deprecated get strong colors.
@@ -357,7 +369,7 @@ interface Props {
357
369
  * `@/data/switcherMap.json`.
358
370
  */
359
371
  switcherMap?: {
360
- versions: Array<{ id: string; label?: string; eol?: boolean; default?: boolean }>;
372
+ versions: Array<{ id: string; label?: string; eol?: boolean; default?: boolean; root?: boolean }>;
361
373
  byLogicalKey: Record<string, Record<string, string>>;
362
374
  };
363
375
  /**
@@ -473,6 +485,7 @@ interface Props {
473
485
  const {
474
486
  poweredBy = false,
475
487
  siteName,
488
+ siteTagline,
476
489
  title,
477
490
  siteUrl = "/",
478
491
  nav,
@@ -492,6 +505,8 @@ const {
492
505
  defaultOgImage,
493
506
  ogType = "article",
494
507
  canonicalUrl,
508
+ alternates = [],
509
+ breadcrumbJsonLd,
495
510
  twitterHandle,
496
511
  themeColor,
497
512
  noindex,
@@ -513,6 +528,7 @@ const {
513
528
  tagsIndexPath = "/tags",
514
529
  tagPrefixes,
515
530
  tagLabels,
531
+ tagAutoColor = false,
516
532
  status,
517
533
  pageType,
518
534
  audience,
@@ -539,6 +555,10 @@ const {
539
555
  class: className,
540
556
  } = Astro.props;
541
557
 
558
+ // The version served at the site root (`path: ""`), if any: the sidebar
559
+ // keeps its pages by leaving out the other versions' folders.
560
+ const navRootVersion = rootVersionFilter(switcherMap?.versions);
561
+
542
562
  // Link icons — classified in CSS by href shape. `data-link-icons`
543
563
  // lists which kinds are active; the glyphs ride in CSS custom props
544
564
  // (single-quoted so they're valid `content:` string values).
@@ -770,11 +790,12 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
770
790
  `--db-link-icon-*` custom props (the glyphs) on <body>. Links are
771
791
  classified purely by href shape, so it covers both structured
772
792
  links and raw-HTML anchors from imported content. Scoped to
773
- `.docs-prose` so nav/header/footer links are untouched.
793
+ `.docs-prose` so nav/header/footer links are untouched; buttons
794
+ (`<a data-variant>`, from Button.astro) carry no icon.
774
795
  */
775
- body[data-link-icons~="external"] .docs-prose a[href^="http://" i]::after,
776
- body[data-link-icons~="external"] .docs-prose a[href^="https://" i]::after,
777
- body[data-link-icons~="external"] .docs-prose a[href^="//"]::after {
796
+ body[data-link-icons~="external"] .docs-prose a[href^="http://" i]:not([data-variant])::after,
797
+ body[data-link-icons~="external"] .docs-prose a[href^="https://" i]:not([data-variant])::after,
798
+ body[data-link-icons~="external"] .docs-prose a[href^="//"]:not([data-variant])::after {
778
799
  content: var(--db-link-icon-external, "");
779
800
  display: inline-block;
780
801
  margin-inline-start: 0.15em;
@@ -785,9 +806,9 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
785
806
  }
786
807
  body[data-link-icons~="internal"]
787
808
  .docs-prose
788
- a[href^="/"]:not([href^="//"])::after,
789
- body[data-link-icons~="internal"] .docs-prose a[href^="./"]::after,
790
- body[data-link-icons~="internal"] .docs-prose a[href^="../"]::after {
809
+ a[href^="/"]:not([href^="//"]):not([data-variant])::after,
810
+ body[data-link-icons~="internal"] .docs-prose a[href^="./"]:not([data-variant])::after,
811
+ body[data-link-icons~="internal"] .docs-prose a[href^="../"]:not([data-variant])::after {
791
812
  content: var(--db-link-icon-internal, "");
792
813
  display: inline-block;
793
814
  margin-inline-start: 0.15em;
@@ -828,6 +849,7 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
828
849
  sidebar / header / TOC text from per-page excerpts. See main. */}
829
850
 
830
851
  {computedCanonical && <link rel="canonical" href={computedCanonical} />}
852
+ {alternates.map((a) => <link rel="alternate" hreflang={a.hreflang} href={a.href} />)}
831
853
  {mdMirrorHref && <link rel="alternate" type="text/markdown" href={mdMirrorHref} />}
832
854
  {/* Programmatic llms.txt discovery — agents that follow head
833
855
  link rels get the per-mount llms.txt without parsing HTML.
@@ -875,7 +897,10 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
875
897
  Schema.org keywords string; Google reads this for topical
876
898
  categorization in rich results. */}
877
899
  {articleJsonLd && (
878
- <script type="application/ld+json" set:html={JSON.stringify(articleJsonLd)} />
900
+ <script type="application/ld+json" set:html={JSON.stringify(articleJsonLd).replace(/</g, "\\u003c")} />
901
+ )}
902
+ {breadcrumbJsonLd && (
903
+ <script type="application/ld+json" set:html={JSON.stringify(breadcrumbJsonLd).replace(/</g, "\\u003c")} />
879
904
  )}
880
905
 
881
906
  {/* Per-page / per-site custom structured data. Layered on
@@ -964,7 +989,7 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
964
989
  </div>
965
990
  <div class="grid flex-1 text-left text-sm leading-tight">
966
991
  <span class="truncate font-semibold">{siteName}</span>
967
- <span class="truncate text-xs text-sidebar-foreground/70">Documentation</span>
992
+ <span class="truncate text-xs text-sidebar-foreground/70">{siteTagline || "Documentation"}</span>
968
993
  </div>
969
994
  </SidebarMenuButton>
970
995
  </SidebarMenuItem>
@@ -989,6 +1014,8 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
989
1014
  namespace={multiSource?.namespace}
990
1015
  version={multiSource?.version}
991
1016
  locale={multiSource?.locale}
1017
+ rootVersion={navRootVersion.rootVersion}
1018
+ versionSegments={navRootVersion.versionSegments}
992
1019
  />
993
1020
  </SidebarGroupContent>
994
1021
  </SidebarGroup>
@@ -1003,6 +1030,7 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
1003
1030
  namespace: multiSource?.namespace,
1004
1031
  version: multiSource?.version,
1005
1032
  locale: multiSource?.locale,
1033
+ ...navRootVersion,
1006
1034
  })}
1007
1035
  currentPath={currentPath}
1008
1036
  />
@@ -1018,6 +1046,7 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
1018
1046
  namespace: multiSource?.namespace,
1019
1047
  version: multiSource?.version,
1020
1048
  locale: multiSource?.locale,
1049
+ ...navRootVersion,
1021
1050
  })}
1022
1051
  currentPath={currentPath}
1023
1052
  />
@@ -1292,6 +1321,7 @@ const siteIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
1292
1321
  indexPath={tagsIndexPath}
1293
1322
  prefixes={tagPrefixes}
1294
1323
  labels={tagLabels}
1324
+ autoColor={tagAutoColor}
1295
1325
  />
1296
1326
  )}
1297
1327
  </div>
@@ -43,9 +43,13 @@ interface Props {
43
43
  version?: string;
44
44
  /** Current source's locale axis value, if multi-locale site. */
45
45
  locale?: string;
46
+ /** The version served at the site root (`path: ""`), if any. */
47
+ rootVersion?: string;
48
+ /** The other versions' URL segments (left out of the root version's nav). */
49
+ versionSegments?: string[];
46
50
  }
47
51
 
48
- const { currentPath, basePath = "", namespace, version, locale } = Astro.props;
52
+ const { currentPath, basePath = "", namespace, version, locale, rootVersion, versionSegments } = Astro.props;
49
53
  const navUrl = `${basePath}/_dogsbay/nav.json`;
50
54
  ---
51
55
 
@@ -65,6 +69,8 @@ const navUrl = `${basePath}/_dogsbay/nav.json`;
65
69
  data-namespace={namespace ?? ""}
66
70
  data-version={version ?? ""}
67
71
  data-locale={locale ?? ""}
72
+ data-root-version={rootVersion ?? ""}
73
+ data-version-segments={(versionSegments ?? []).join(" ")}
68
74
  aria-busy="true"
69
75
  aria-label="Documentation navigation"
70
76
  >
package/src/TagList.astro CHANGED
@@ -48,6 +48,11 @@ interface Props {
48
48
  * text. URL still uses the slug.
49
49
  */
50
50
  labels?: Record<string, string>;
51
+ /**
52
+ * Give chips whose prefix has no color a stable palette color,
53
+ * hashed from the tag's first segment. Default false.
54
+ */
55
+ autoColor?: boolean;
51
56
  /** Optional aria-label override; default "Tags". */
52
57
  label?: string;
53
58
  class?: string;
@@ -58,11 +63,12 @@ const {
58
63
  indexPath = "/tags",
59
64
  prefixes,
60
65
  labels,
66
+ autoColor = false,
61
67
  label: ariaLabel = "Tags",
62
68
  class: className,
63
69
  } = Astro.props;
64
70
 
65
- const chips = buildChips(tags, { indexPath, prefixes, labels });
71
+ const chips = buildChips(tags, { indexPath, prefixes, labels, autoColor });
66
72
 
67
73
  /**
68
74
  * Closed palette → Tailwind class strings. Each entry is one
@@ -44,7 +44,7 @@ const currentLabel = currentRow?.entry.label ?? currentRow?.entry.id ?? "Version
44
44
  </summary>
45
45
  <ul class="absolute right-0 z-50 mt-1 min-w-[10rem] rounded-md border border-border bg-popover p-1 text-sm shadow-md">
46
46
  {rows.map((row) => {
47
- const href = row.url ?? fallbackLandingUrl(basePath, row.entry.id);
47
+ const href = row.url ?? fallbackLandingUrl(basePath, row.entry.id, row.entry.root === true);
48
48
  const isFallback = row.url === null && !row.isCurrent;
49
49
  return (
50
50
  <li>
@@ -379,6 +379,8 @@ export async function hydrateDocsNav(): Promise<void> {
379
379
  const namespace = root.dataset.namespace || undefined;
380
380
  const version = root.dataset.version || undefined;
381
381
  const locale = root.dataset.locale || undefined;
382
+ const rootVersion = root.dataset.rootVersion || undefined;
383
+ const versionSegments = (root.dataset.versionSegments || "").split(" ").filter(Boolean);
382
384
 
383
385
  try {
384
386
  const nav = await fetchNav(navUrl);
@@ -392,6 +394,7 @@ export async function hydrateDocsNav(): Promise<void> {
392
394
  namespace: namespace || undefined,
393
395
  version: version || undefined,
394
396
  locale: locale || undefined,
397
+ ...(rootVersion ? { rootVersion, versionSegments } : {}),
395
398
  });
396
399
  renderTree(filtered, current, root);
397
400
  } catch (err) {
package/src/json-ld.ts CHANGED
@@ -1,167 +1,6 @@
1
1
  /**
2
- * JSON-LD helpers — pure functions consumed by DocsLayout to
3
- * decide which Schema.org `@type` to emit and to normalize the
4
- * `customJsonLd` prop shape.
5
- *
6
- * The 100daysofdocs SEO audit flagged that DocsLayout was emitting
7
- * `Article` for every page regardless of intent — tutorials and
8
- * how-to pages should render as `HowTo` for SERP rich results.
9
- * This module owns the type selection so it's testable without
10
- * spinning up an Astro renderer.
2
+ * JSON-LD helpers for DocsLayout. They live in `@dogsbay/site-files`,
3
+ * which dogsbay-ssg uses too, so both pipelines emit the same blocks.
11
4
  */
12
-
13
- /**
14
- * Map a `meta.type` value (the open-string `pageType` prop on
15
- * DocsLayout) to a Schema.org `@type`.
16
- *
17
- * The mapping intentionally accepts a few common spelling
18
- * variants (`"how-to"` / `"howto"`, `"reference"` / `"ref"` /
19
- * `"api"`) so writers don't have to learn one canonical spelling
20
- * to get the right structured data. Anything else falls back to
21
- * `Article`, which is Google's "general docs page" assumption.
22
- */
23
- export function jsonLdTypeFor(pageType: string | undefined): string {
24
- switch (pageType) {
25
- case "tutorial":
26
- case "how-to":
27
- case "howto":
28
- return "HowTo";
29
- case "reference":
30
- case "ref":
31
- case "api":
32
- return "TechArticle";
33
- case "course":
34
- return "Course";
35
- default:
36
- return "Article";
37
- }
38
- }
39
-
40
- /**
41
- * Normalize the `customJsonLd` prop into an array. DocsLayout
42
- * accepts either a single object (most common case — one extra
43
- * Course / Organization block) or an array (sites that layer
44
- * several blocks per page).
45
- *
46
- * Returns an empty array for `undefined` so the template can
47
- * iterate uniformly without conditional rendering.
48
- */
49
- export function normalizeCustomJsonLd(
50
- raw: Record<string, unknown> | Record<string, unknown>[] | undefined,
51
- ): Record<string, unknown>[] {
52
- if (!raw) return [];
53
- if (Array.isArray(raw)) return raw;
54
- return [raw];
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
- };
104
- // Only when there are any. An empty `keywords: ""` asserts the page
105
- // has no topics, which is a different claim from staying silent.
106
- if (keywords.length > 0) block.keywords = keywords.join(", ");
107
-
108
- if (type === "Article" || type === "TechArticle") {
109
- block.headline = title;
110
- }
111
-
112
- if (description) block.description = description;
113
- if (image) block.image = image;
114
- if (url) block.url = url;
115
-
116
- if (type === "HowTo") {
117
- let stepHeadings = (headings ?? []).filter((h) => h.depth === 2);
118
- if (stepHeadings.length === 0) {
119
- stepHeadings = (headings ?? []).filter((h) => h.depth === 3);
120
- }
121
- block.step = stepHeadings.map((h) => ({
122
- "@type": "HowToStep",
123
- name: h.text,
124
- url: url ? `${url}#${h.slug}` : `#${h.slug}`,
125
- }));
126
- }
127
-
128
- if (type === "Course") {
129
- block.provider = { "@type": "Organization", name: siteName };
130
- if (!block.description) block.description = title;
131
- }
132
-
133
- return block;
134
- }
135
-
136
- /**
137
- * Identity structured data for a page that is not an article — a
138
- * homepage, a landing page, a section index.
139
- *
140
- * Exists because the article block is gated on `ogType === "article"`,
141
- * so those pages emitted NO structured data at all. An external agent
142
- * audit reported "No JSON-LD structured data found on homepage", which
143
- * was accurate: the one page most likely to be an agent's entry point
144
- * was the one describing itself the least.
145
- *
146
- * `WebSite` rather than `Organization` or `SoftwareApplication` on
147
- * purpose. Those are claims about what the site's OWNER is, and
148
- * guessing wrong is worse than saying less — a docs site for a product
149
- * is not itself the product. Owners wanting a stronger identity type
150
- * add it through `customJsonLd`, which layers on top of this.
151
- */
152
- export function buildWebSiteJsonLd(opts: {
153
- siteName: string;
154
- title: string;
155
- description?: string;
156
- url?: string;
157
- }): Record<string, unknown> {
158
- const block: Record<string, unknown> = {
159
- "@context": "https://schema.org",
160
- "@type": "WebSite",
161
- name: opts.siteName,
162
- };
163
- if (opts.title && opts.title !== opts.siteName) block.headline = opts.title;
164
- if (opts.description) block.description = opts.description;
165
- if (opts.url) block.url = opts.url;
166
- return block;
167
- }
5
+ export { jsonLdTypeFor, normalizeCustomJsonLd, buildArticleJsonLd, buildWebSiteJsonLd } from "@dogsbay/site-files";
6
+ export type { BuildArticleJsonLdOptions } from "@dogsbay/site-files";
package/src/nav-filter.ts CHANGED
@@ -29,6 +29,27 @@ export interface NavFilter {
29
29
  locale?: string;
30
30
  /** Current page's version segment (innermost, next to the page), if any. */
31
31
  version?: string;
32
+ /**
33
+ * The version served at the site root (`versions[].path: ""`): its pages
34
+ * have no version segment. When `version` is this one, the bucket is the
35
+ * namespace/locale prefix minus the other versions' folders
36
+ * (`versionSegments`) — the longest matching prefix wins.
37
+ */
38
+ rootVersion?: string;
39
+ /** The other versions' URL segments, left out of the root version's bucket. */
40
+ versionSegments?: string[];
41
+ }
42
+
43
+ /**
44
+ * The root-version fields of a `NavFilter`, from the switcherMap header
45
+ * (`versions[].root`). Empty when no version is served at the root.
46
+ */
47
+ export function rootVersionFilter(
48
+ versions: Array<{ id: string; root?: boolean }> | undefined,
49
+ ): Pick<NavFilter, "rootVersion" | "versionSegments"> {
50
+ const root = versions?.find((v) => v.root)?.id;
51
+ if (!root) return {};
52
+ return { rootVersion: root, versionSegments: versions!.map((v) => v.id).filter((id) => id !== root) };
32
53
  }
33
54
 
34
55
  /**
@@ -40,26 +61,33 @@ export interface NavFilter {
40
61
  export function filterNavByAxis(items: NavItem[], filter: NavFilter): NavItem[] {
41
62
  // Canonical order: namespace → locale → version. Only the axes the
42
63
  // current page actually carries contribute to the match prefix.
43
- const segs = [filter.namespace, filter.locale, filter.version].filter(
64
+ const atRoot = filter.rootVersion !== undefined && filter.version === filter.rootVersion;
65
+ const segs = [filter.namespace, filter.locale, atRoot ? undefined : filter.version].filter(
44
66
  (s): s is string => s !== undefined && s !== "",
45
67
  );
46
- if (segs.length === 0) return items;
68
+ // The root version's bucket leaves out the other versions' folders under its prefix.
69
+ const others = atRoot ? (filter.versionSegments ?? []).filter((v) => v && v !== filter.rootVersion) : [];
70
+ if (segs.length === 0 && others.length === 0) return items;
47
71
 
48
72
  const base = filter.basePath.replace(/\/$/, "");
49
- const prefix = `${base}/${segs.join("/")}/`;
50
- const prefixNoSlash = prefix.replace(/\/$/, "");
73
+ const prefix = `${base}/${segs.join("/")}${segs.length ? "/" : ""}`;
74
+ const prefixNoSlash = segs.length ? prefix.replace(/\/$/, "") : base || "/";
75
+ const excluded = others.map((v) => `${prefix}${v}`);
51
76
 
52
- return items.flatMap((item) => filterOne(item, prefix, prefixNoSlash));
77
+ return items.flatMap((item) => filterOne(item, prefix, prefixNoSlash, excluded));
53
78
  }
54
79
 
55
- function filterOne(item: NavItem, prefix: string, prefixNoSlash: string): NavItem[] {
80
+ function filterOne(item: NavItem, prefix: string, prefixNoSlash: string, excluded: string[]): NavItem[] {
56
81
  if (item.children && item.children.length > 0) {
57
- const kept = item.children.flatMap((c) => filterOne(c, prefix, prefixNoSlash));
82
+ const kept = item.children.flatMap((c) => filterOne(c, prefix, prefixNoSlash, excluded));
58
83
  if (kept.length === 0) return [];
59
84
  return [{ ...item, children: kept }];
60
85
  }
61
86
  if (item.href !== undefined) {
62
- return hrefMatchesPrefix(item.href, prefix, prefixNoSlash) ? [item] : [];
87
+ return hrefMatchesPrefix(item.href, prefix, prefixNoSlash) &&
88
+ !excluded.some((x) => hrefMatchesPrefix(item.href!, `${x}/`, x))
89
+ ? [item]
90
+ : [];
63
91
  }
64
92
  // Childless AND href-less: no navigation, no bucket membership. In a
65
93
  // filtered view it must be dropped.
package/src/switcher.ts CHANGED
@@ -20,6 +20,12 @@ export interface AxisEntry {
20
20
  */
21
21
  hidden?: boolean;
22
22
  default?: boolean;
23
+ /**
24
+ * Served at the site root (version axis only; `versions[].path: ""` on the
25
+ * default version): its pages have no version segment, so its landing
26
+ * page is `<basePath>/`, and its per-page URLs come from the variants.
27
+ */
28
+ root?: boolean;
23
29
  }
24
30
 
25
31
  export interface SwitcherVariant {
@@ -206,14 +212,16 @@ export function shouldRenderSwitcher(
206
212
 
207
213
  /**
208
214
  * Fallback URL for a row whose target variant doesn't exist —
209
- * lands on the axis's "landing page" (`<basePath>/<id>/`).
215
+ * lands on the axis's "landing page" (`<basePath>/<id>/`, or
216
+ * `<basePath>/` for the version served at the root).
210
217
  * Lets the switcher always be clickable even when a page is
211
218
  * untranslated for some axis combo.
212
219
  */
213
220
  export function fallbackLandingUrl(
214
221
  basePath: string,
215
222
  axisEntryId: string,
223
+ atRoot = false,
216
224
  ): string {
217
225
  const trimmed = basePath.replace(/\/$/, "");
218
- return `${trimmed}/${axisEntryId}/`;
226
+ return atRoot ? `${trimmed}/` : `${trimmed}/${axisEntryId}/`;
219
227
  }
@@ -33,6 +33,29 @@ export interface BuildChipsOptions {
33
33
  * text. URL still uses the slug.
34
34
  */
35
35
  labels?: Record<string, string>;
36
+ /**
37
+ * Color chips whose prefix has no `color` with a palette color
38
+ * hashed from the first segment (`autoTagColor`). Default false.
39
+ */
40
+ autoColor?: boolean;
41
+ }
42
+
43
+ /** Palette order for automatic colors. */
44
+ const AUTO_PALETTE: readonly TagPaletteName[] = ["blue", "amber", "emerald", "violet", "rose", "slate"];
45
+
46
+ /**
47
+ * A stable palette color for a tag segment: FNV-1a hash of the
48
+ * lowercased segment, modulo the palette. The same segment gets the
49
+ * same color on every page and every build; with more distinct tags
50
+ * than colors, colors repeat.
51
+ */
52
+ export function autoTagColor(segment: string): TagPaletteName {
53
+ let hash = 0x811c9dc5;
54
+ for (const ch of segment.toLowerCase()) {
55
+ hash ^= ch.codePointAt(0)!;
56
+ hash = Math.imul(hash, 0x01000193) >>> 0;
57
+ }
58
+ return AUTO_PALETTE[hash % AUTO_PALETTE.length];
36
59
  }
37
60
 
38
61
  export interface ChipModel {
@@ -164,7 +187,7 @@ export function buildChips(
164
187
  options: BuildChipsOptions,
165
188
  ): ChipModel[] {
166
189
  const safe = filterTags(tags);
167
- const { indexPath, prefixes, labels } = options;
190
+ const { indexPath, prefixes, labels, autoColor = false } = options;
168
191
 
169
192
  return safe.map((tag): ChipModel => {
170
193
  const href = `${indexPath}/${tag}/`;
@@ -182,7 +205,7 @@ export function buildChips(
182
205
  href,
183
206
  prefixLabel: null,
184
207
  leafLabel: overrideLeaf ?? tag,
185
- color: null,
208
+ color: autoColor ? autoTagColor(firstSegment) : null,
186
209
  };
187
210
  }
188
211
 
@@ -196,7 +219,7 @@ export function buildChips(
196
219
  href,
197
220
  prefixLabel: prefixEntry.label ?? null,
198
221
  leafLabel: leafLabel.length > 0 ? leafLabel : tag,
199
- color: prefixEntry.color ?? null,
222
+ color: prefixEntry.color ?? (autoColor ? autoTagColor(firstSegment) : null),
200
223
  };
201
224
  });
202
225
  }
@@ -31,6 +31,15 @@ export interface AxisRedirectConfig {
31
31
  defaultVersion?: string;
32
32
  /** Full set of declared version ids. Empty/undefined → axis inactive. */
33
33
  knownVersions?: string[];
34
+ /**
35
+ * The version served at the site root (`versions[].path: ""`, the
36
+ * default version). A URL with no version segment is one of its pages,
37
+ * so the version is never injected; a URL starting with another known
38
+ * version's segment is that version's (the longest matching prefix
39
+ * wins). Only the locale redirect remains. format-astro emits no version
40
+ * config at all in that case; this keeps the helper right when given one.
41
+ */
42
+ rootVersion?: string;
34
43
  /** Default locale id; required when locale axis is active. */
35
44
  defaultLocale?: string;
36
45
  /** Full set of declared locale ids. Empty/undefined → axis inactive. */
@@ -126,7 +135,8 @@ export function shouldRedirectToDefaultVersion(
126
135
  if (
127
136
  versionAxisActive &&
128
137
  cursor < segments.length &&
129
- knownVersions.has(segments[cursor])
138
+ knownVersions.has(segments[cursor]) &&
139
+ segments[cursor] !== config.rootVersion
130
140
  ) {
131
141
  presentVersion = segments[cursor];
132
142
  cursor += 1;
@@ -134,13 +144,16 @@ export function shouldRedirectToDefaultVersion(
134
144
 
135
145
  // What's the rest of the path beyond the recognised axis prefixes?
136
146
  const tailSegments = segments.slice(cursor);
147
+ // With a root version, a URL without a version segment is already complete.
148
+ const versionMissing =
149
+ versionAxisActive && presentVersion === undefined && !config.rootVersion;
137
150
  if (tailSegments.length === 0) {
138
151
  // The path is JUST a known axis prefix (e.g. /docs/en/ or
139
152
  // /docs/v1/). Treat as the axis's landing page; no redirect
140
153
  // needed unless other axes are MISSING from the URL too.
141
154
  if (
142
155
  (localeAxisActive && presentLocale === undefined) ||
143
- (versionAxisActive && presentVersion === undefined)
156
+ versionMissing
144
157
  ) {
145
158
  // Some axis still missing — fall through to redirect.
146
159
  } else {
@@ -150,7 +163,7 @@ export function shouldRedirectToDefaultVersion(
150
163
 
151
164
  // If all required-active axes are present, no redirect needed.
152
165
  const localeNeeded = localeAxisActive && presentLocale === undefined;
153
- const versionNeeded = versionAxisActive && presentVersion === undefined;
166
+ const versionNeeded = versionMissing;
154
167
  if (!localeNeeded && !versionNeeded) return null;
155
168
 
156
169
  // Compose the redirect target with missing segments injected
@@ -159,7 +172,7 @@ export function shouldRedirectToDefaultVersion(
159
172
  if (localeAxisActive) {
160
173
  out.push(presentLocale ?? config.defaultLocale!);
161
174
  }
162
- if (versionAxisActive) {
175
+ if (versionAxisActive && (presentVersion !== undefined || !config.rootVersion)) {
163
176
  out.push(presentVersion ?? config.defaultVersion!);
164
177
  }
165
178
  out.push(...tailSegments);