@dogsbay/docs-layout 0.2.0-beta.8 → 0.2.0-beta.80

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ }