@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 +4 -3
- package/src/DocsLayout.astro +41 -11
- package/src/DocsNavClient.astro +7 -1
- package/src/TagList.astro +7 -1
- package/src/VersionSwitcher.astro +1 -1
- package/src/docs-nav-client.ts +3 -0
- package/src/json-ld.ts +4 -165
- package/src/nav-filter.ts +36 -8
- package/src/switcher.ts +10 -2
- package/src/tag-list-data.ts +26 -3
- package/src/version-redirect.ts +17 -4
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.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.
|
|
35
|
-
"@dogsbay/ui": "0.2.0-beta.
|
|
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",
|
package/src/DocsLayout.astro
CHANGED
|
@@ -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>
|
package/src/DocsNavClient.astro
CHANGED
|
@@ -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>
|
package/src/docs-nav-client.ts
CHANGED
|
@@ -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
|
|
3
|
-
*
|
|
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
|
|
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
|
-
|
|
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)
|
|
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
|
}
|
package/src/tag-list-data.ts
CHANGED
|
@@ -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
|
}
|
package/src/version-redirect.ts
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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);
|