@dogsbay/docs-layout 0.2.0-beta.0

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,62 @@
1
+ ---
2
+ /**
3
+ * Recursive navigation tree renderer.
4
+ * Accepts any nav structure: { label, href?, children? }
5
+ * Used by all importers (MkDocs, AsciiDoc, Docusaurus, etc.)
6
+ */
7
+
8
+ interface NavItem {
9
+ label: string;
10
+ href?: string;
11
+ children?: NavItem[];
12
+ }
13
+
14
+ interface Props {
15
+ items: NavItem[];
16
+ currentPath: string;
17
+ depth?: number;
18
+ }
19
+
20
+ const { items, currentPath, depth = 0 } = Astro.props;
21
+
22
+ function hasActive(item: NavItem, path: string): boolean {
23
+ if (item.href && path === item.href) return true;
24
+ return item.children?.some(c => hasActive(c, path)) ?? false;
25
+ }
26
+ ---
27
+
28
+ <ul class:list={[depth === 0 ? "space-y-1" : "mt-1 space-y-0.5 pl-3 border-l border-border"]}>
29
+ {items.map(item => (
30
+ <li>
31
+ {item.children && item.children.length > 0 ? (
32
+ <details open={hasActive(item, currentPath)}>
33
+ <summary class:list={[
34
+ "flex cursor-pointer list-none items-center gap-1 rounded-md px-2 py-1.5 text-sm transition-colors hover:bg-accent [&::-webkit-details-marker]:hidden",
35
+ depth === 0 ? "font-semibold" : "font-medium",
36
+ ]}>
37
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="shrink-0 transition-transform [[open]>&]:rotate-90"><polyline points="9 18 15 12 9 6"/></svg>
38
+ {item.href ? (
39
+ <a href={item.href} class="flex-1 text-foreground no-underline">{item.label}</a>
40
+ ) : (
41
+ <span class="flex-1">{item.label}</span>
42
+ )}
43
+ </summary>
44
+ <Astro.self items={item.children} currentPath={currentPath} depth={depth + 1} />
45
+ </details>
46
+ ) : item.href ? (
47
+ <a
48
+ href={item.href}
49
+ class:list={[
50
+ "block rounded-md px-2 py-1.5 text-sm no-underline transition-colors hover:bg-accent",
51
+ currentPath === item.href ? "bg-accent font-medium text-foreground" : "text-muted-foreground",
52
+ ]}
53
+ >{item.label}</a>
54
+ ) : (
55
+ <span class:list={[
56
+ "block px-2 py-1.5 text-sm",
57
+ depth === 0 ? "font-semibold text-foreground" : "text-muted-foreground",
58
+ ]}>{item.label}</span>
59
+ )}
60
+ </li>
61
+ ))}
62
+ </ul>
@@ -0,0 +1,35 @@
1
+ ---
2
+ /**
3
+ * Documentation sidebar wrapper.
4
+ * Fixed on desktop, toggleable overlay on mobile.
5
+ * Contains the nav tree (DocsNav) and optional slot for extras.
6
+ */
7
+ import DocsNav from "./DocsNav.astro";
8
+
9
+ interface NavItem {
10
+ label: string;
11
+ href?: string;
12
+ children?: NavItem[];
13
+ }
14
+
15
+ interface Props {
16
+ items: NavItem[];
17
+ currentPath: string;
18
+ class?: string;
19
+ }
20
+
21
+ const { items, currentPath, class: className } = Astro.props;
22
+ ---
23
+
24
+ <aside
25
+ data-docs-sidebar
26
+ class:list={[
27
+ "fixed inset-y-14 left-0 z-30 hidden w-64 shrink-0 overflow-y-auto border-r bg-background p-4",
28
+ "lg:sticky lg:top-14 lg:block lg:h-[calc(100vh-3.5rem)]",
29
+ className,
30
+ ]}
31
+ >
32
+ <slot name="before-nav" />
33
+ <DocsNav items={items} currentPath={currentPath} />
34
+ <slot name="after-nav" />
35
+ </aside>
@@ -0,0 +1,50 @@
1
+ ---
2
+ /**
3
+ * Table of contents sidebar.
4
+ * Renders heading links, optionally with scroll tracking (client JS).
5
+ */
6
+ import "./toc-kind.css";
7
+
8
+ interface Heading {
9
+ depth: number;
10
+ slug: string;
11
+ text: string;
12
+ kind?: string;
13
+ }
14
+
15
+ interface Props {
16
+ headings: Heading[];
17
+ minDepth?: number;
18
+ maxDepth?: number;
19
+ title?: string;
20
+ class?: string;
21
+ }
22
+
23
+ const {
24
+ headings,
25
+ minDepth = 2,
26
+ maxDepth = 3,
27
+ title = "On this page",
28
+ class: className,
29
+ } = Astro.props;
30
+
31
+ const filtered = headings.filter(h => h.depth >= minDepth && h.depth <= maxDepth);
32
+ ---
33
+
34
+ {filtered.length > 0 && (
35
+ <nav class:list={["text-sm", className]} aria-label="Table of contents">
36
+ <div class="text-xs font-semibold uppercase text-muted-foreground">{title}</div>
37
+ <ul class="mt-2 space-y-1">
38
+ {filtered.map(h => (
39
+ <li>
40
+ <a
41
+ href={`#${h.slug}`}
42
+ class="block py-0.5 text-muted-foreground transition-colors hover:text-foreground"
43
+ style={`padding-left: ${(h.depth - minDepth) * 0.75}rem`}
44
+ data-toc-link={h.slug}
45
+ >{h.kind && <span class={`toc-kind toc-kind-${h.kind}`}>{h.kind}</span>}{h.text}</a>
46
+ </li>
47
+ ))}
48
+ </ul>
49
+ </nav>
50
+ )}
@@ -0,0 +1,87 @@
1
+ ---
2
+ /**
3
+ * LocaleSwitcher — dropdown letting readers switch between
4
+ * declared locales of a docs page. Mirror of VersionSwitcher;
5
+ * shared decision logic in `./switcher.ts`.
6
+ *
7
+ * When the current page has no equivalent in the target locale,
8
+ * the dropdown link falls back to the locale's landing page
9
+ * (`<basePath>/<locale>/`). The default-locale redirect (PR 5c)
10
+ * + missing-translation fallback further smooth the UX.
11
+ */
12
+ import {
13
+ type SwitcherMap,
14
+ type MultiSourceMeta,
15
+ buildSwitcherRows,
16
+ fallbackLandingUrl,
17
+ shouldRenderSwitcher,
18
+ } from "./switcher.js";
19
+
20
+ interface Props {
21
+ switcherMap: SwitcherMap;
22
+ multiSource?: MultiSourceMeta;
23
+ basePath?: string;
24
+ }
25
+
26
+ const { switcherMap, multiSource, basePath = "/docs" } = Astro.props;
27
+ const visible = shouldRenderSwitcher("locale", switcherMap, multiSource);
28
+ const rows = visible
29
+ ? buildSwitcherRows({ axis: "locale", switcherMap, multiSource: multiSource! })
30
+ : [];
31
+ const currentRow = rows.find((r) => r.isCurrent);
32
+ const currentLabel = currentRow?.entry.label ?? currentRow?.entry.id ?? "Locale";
33
+ ---
34
+
35
+ {visible && (
36
+ <details class="locale-switcher relative">
37
+ <summary
38
+ class="flex cursor-pointer items-center gap-1 rounded-md border border-border px-3 py-1.5 text-sm hover:bg-accent"
39
+ aria-label="Switch language"
40
+ >
41
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
42
+ <span class="font-medium">{currentLabel}</span>
43
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="ml-1 transition-transform"><polyline points="6 9 12 15 18 9"/></svg>
44
+ </summary>
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
+ {rows.map((row) => {
47
+ const href = row.url ?? fallbackLandingUrl(basePath, row.entry.id);
48
+ const isFallback = row.url === null && !row.isCurrent;
49
+ return (
50
+ <li>
51
+ <a
52
+ href={href}
53
+ hreflang={row.entry.id}
54
+ aria-current={row.isCurrent ? "true" : undefined}
55
+ class:list={[
56
+ "flex items-center gap-2 rounded-sm px-2 py-1.5 no-underline",
57
+ row.isCurrent
58
+ ? "bg-accent font-medium text-foreground"
59
+ : "text-foreground hover:bg-accent",
60
+ ]}
61
+ >
62
+ <span class="flex-1">{row.entry.label ?? row.entry.id}</span>
63
+ {row.entry.default && !row.isCurrent && (
64
+ <span class="text-[10px] text-muted-foreground">default</span>
65
+ )}
66
+ {isFallback && (
67
+ <span title="Page not translated" class="text-[10px] text-muted-foreground">→</span>
68
+ )}
69
+ </a>
70
+ </li>
71
+ );
72
+ })}
73
+ </ul>
74
+ </details>
75
+ )}
76
+
77
+ <style>
78
+ .locale-switcher > summary {
79
+ list-style: none;
80
+ }
81
+ .locale-switcher > summary::-webkit-details-marker {
82
+ display: none;
83
+ }
84
+ .locale-switcher[open] > summary > svg:last-child {
85
+ transform: rotate(180deg);
86
+ }
87
+ </style>
@@ -0,0 +1,281 @@
1
+ ---
2
+ /**
3
+ * Per-page LLM action cluster.
4
+ *
5
+ * Single dropdown trigger ("Copy or open ▾") containing every
6
+ * action labeled in plain text. The cluster is excluded from
7
+ * Pagefind indexing so its labels don't pollute search excerpts.
8
+ *
9
+ * Layout decision: one labeled dropdown beats a copy icon + a
10
+ * separate "Open in" dropdown. An icon-only copy button is
11
+ * ambiguous; a single dropdown with everything labeled is
12
+ * discoverable in one click. Costs a second click for the most
13
+ * common action (Copy) but the discoverability win is worth it.
14
+ *
15
+ * Items in order:
16
+ * 1. Copy markdown — clipboard write (button-like menu item)
17
+ * 2. Open markdown — navigates to .md mirror
18
+ * 3. Open in <Provider> ×N — provider deep links
19
+ *
20
+ * Provider deep links are URL-only — the provider's LLM fetches
21
+ * the .md mirror directly. See plans/llm-page-actions.md.
22
+ *
23
+ * Composes existing primitives from `@dogsbay/ui`:
24
+ * - DropdownMenu* — accessible dropdown with keyboard nav
25
+ *
26
+ * Items carry `data-part="item"` so the existing dropdown JS
27
+ * picks them up for arrow-key navigation. Provider items are
28
+ * real <a> tags (cmd/middle-click works natively); the Copy
29
+ * item is a <button> so it doesn't navigate.
30
+ */
31
+ import DropdownMenu from "@dogsbay/ui/dropdown-menu/DropdownMenu.astro";
32
+ import DropdownMenuTrigger from "@dogsbay/ui/dropdown-menu/DropdownMenuTrigger.astro";
33
+ import DropdownMenuContent from "@dogsbay/ui/dropdown-menu/DropdownMenuContent.astro";
34
+ import DropdownMenuSeparator from "@dogsbay/ui/dropdown-menu/DropdownMenuSeparator.astro";
35
+ import {
36
+ DEFAULT_PROMPT_TEMPLATE,
37
+ providerDeepLink,
38
+ resolveProviders,
39
+ type LlmProviderName,
40
+ } from "./llm-actions.js";
41
+
42
+ interface Props {
43
+ /**
44
+ * Plain-text markdown body of the current page. Embedded into the
45
+ * Copy menu item via `data-copy-text`. When omitted, the Copy
46
+ * item is hidden.
47
+ */
48
+ markdownBody?: string;
49
+ /**
50
+ * Path or URL to the .md mirror of the current page. Used for
51
+ * the "Open markdown" item and as the `{url}` substitution in
52
+ * the provider prompt template. Should be absolute when a
53
+ * `siteUrl` is configured (so providers can fetch); falls back
54
+ * to a relative path when not — the build-time warning catches
55
+ * the misconfiguration.
56
+ */
57
+ mdUrl: string;
58
+ /** Provider list, in render order. */
59
+ providers?: LlmProviderName[];
60
+ /**
61
+ * Show the "Copy markdown" item in the dropdown. Default: true.
62
+ * Disable for sites that want only the navigation / open-in
63
+ * actions. The standalone copy-icon-button is gone — copy now
64
+ * lives inside the dropdown alongside the other actions.
65
+ */
66
+ copyButton?: boolean;
67
+ /** Prompt template; `{url}` is replaced by `mdUrl`. */
68
+ promptTemplate?: string;
69
+ /** Optional class for layout-side styling. */
70
+ class?: string;
71
+ }
72
+
73
+ const {
74
+ markdownBody,
75
+ mdUrl,
76
+ providers,
77
+ copyButton = true,
78
+ promptTemplate = DEFAULT_PROMPT_TEMPLATE,
79
+ class: className,
80
+ } = Astro.props;
81
+
82
+ const resolvedProviders = resolveProviders(providers);
83
+ const showCopyItem = copyButton && typeof markdownBody === "string" && markdownBody.length > 0;
84
+ const showOpenItem = mdUrl.length > 0;
85
+ const hasProviders = resolvedProviders.length > 0;
86
+ const showCluster = showCopyItem || showOpenItem || hasProviders;
87
+
88
+ // Tailwind classes shared by every menu item — styled to look
89
+ // like DropdownMenuItem without going through the <div
90
+ // role="menuitem"> component (we want real <a> / <button> tags
91
+ // so cmd-click + native button semantics work).
92
+ const itemClass =
93
+ "relative flex w-full cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm text-foreground outline-none focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground no-underline";
94
+ ---
95
+
96
+ {showCluster && (
97
+ <div
98
+ class:list={["flex items-center", className]}
99
+ data-component="page-actions"
100
+ data-pagefind-ignore
101
+ >
102
+ <DropdownMenu>
103
+ <DropdownMenuTrigger
104
+ class="inline-flex h-8 items-center gap-1 rounded-md border border-border bg-background px-3 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
105
+ aria-label="Copy or open page actions"
106
+ >
107
+ <span>Copy or open</span>
108
+ <svg
109
+ xmlns="http://www.w3.org/2000/svg"
110
+ width="14"
111
+ height="14"
112
+ viewBox="0 0 24 24"
113
+ fill="none"
114
+ stroke="currentColor"
115
+ stroke-width="2"
116
+ stroke-linecap="round"
117
+ stroke-linejoin="round"
118
+ aria-hidden="true"
119
+ >
120
+ <polyline points="6 9 12 15 18 9"></polyline>
121
+ </svg>
122
+ </DropdownMenuTrigger>
123
+ <DropdownMenuContent align="end" class="min-w-[12rem]">
124
+ {showCopyItem && (
125
+ <button
126
+ type="button"
127
+ class={itemClass}
128
+ data-part="item"
129
+ role="menuitem"
130
+ data-action="copy-markdown"
131
+ data-copy-text={markdownBody!}
132
+ >
133
+ <svg
134
+ xmlns="http://www.w3.org/2000/svg"
135
+ width="14"
136
+ height="14"
137
+ viewBox="0 0 24 24"
138
+ fill="none"
139
+ stroke="currentColor"
140
+ stroke-width="2"
141
+ stroke-linecap="round"
142
+ stroke-linejoin="round"
143
+ data-copy-icon
144
+ aria-hidden="true"
145
+ >
146
+ <rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect>
147
+ <path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"></path>
148
+ </svg>
149
+ <svg
150
+ xmlns="http://www.w3.org/2000/svg"
151
+ width="14"
152
+ height="14"
153
+ viewBox="0 0 24 24"
154
+ fill="none"
155
+ stroke="currentColor"
156
+ stroke-width="2"
157
+ stroke-linecap="round"
158
+ stroke-linejoin="round"
159
+ data-check-icon
160
+ class="hidden"
161
+ aria-hidden="true"
162
+ >
163
+ <polyline points="20 6 9 17 4 12"></polyline>
164
+ </svg>
165
+ <span data-copy-label>Copy markdown</span>
166
+ </button>
167
+ )}
168
+ {showOpenItem && (
169
+ <a
170
+ href={mdUrl}
171
+ class={itemClass}
172
+ data-part="item"
173
+ role="menuitem"
174
+ data-action="open-markdown"
175
+ >
176
+ <svg
177
+ xmlns="http://www.w3.org/2000/svg"
178
+ width="14"
179
+ height="14"
180
+ viewBox="0 0 24 24"
181
+ fill="none"
182
+ stroke="currentColor"
183
+ stroke-width="2"
184
+ stroke-linecap="round"
185
+ stroke-linejoin="round"
186
+ aria-hidden="true"
187
+ >
188
+ <path d="M14 3h7v7"></path>
189
+ <path d="M10 14L21 3"></path>
190
+ <path d="M21 14v5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5"></path>
191
+ </svg>
192
+ <span>Open markdown</span>
193
+ </a>
194
+ )}
195
+ {hasProviders && (showCopyItem || showOpenItem) && <DropdownMenuSeparator />}
196
+ {resolvedProviders.map((provider) => (
197
+ <a
198
+ href={providerDeepLink(provider.name, mdUrl, promptTemplate)}
199
+ class={itemClass}
200
+ data-part="item"
201
+ role="menuitem"
202
+ data-provider={provider.name}
203
+ target="_blank"
204
+ rel="noopener noreferrer"
205
+ aria-label={`${provider.label} — opens in new tab`}
206
+ >
207
+ <span>{provider.label}</span>
208
+ <svg
209
+ xmlns="http://www.w3.org/2000/svg"
210
+ width="12"
211
+ height="12"
212
+ viewBox="0 0 24 24"
213
+ fill="none"
214
+ stroke="currentColor"
215
+ stroke-width="2"
216
+ stroke-linecap="round"
217
+ stroke-linejoin="round"
218
+ class="ml-auto opacity-60"
219
+ aria-hidden="true"
220
+ >
221
+ <path d="M7 17L17 7"></path>
222
+ <polyline points="7 7 17 7 17 17"></polyline>
223
+ </svg>
224
+ </a>
225
+ ))}
226
+ </DropdownMenuContent>
227
+ </DropdownMenu>
228
+ </div>
229
+ )}
230
+
231
+ <script>
232
+ import "@dogsbay/ui/dropdown-menu/dropdown-menu.ts";
233
+
234
+ /**
235
+ * Wire up the in-dropdown "Copy markdown" item. Clipboard write
236
+ * with checkmark feedback for two seconds; same UX as the
237
+ * standalone CopyButton component, inlined so we don't need a
238
+ * separate component just for one menu item.
239
+ */
240
+ function setupPageActionsCopy(): void {
241
+ document.querySelectorAll<HTMLElement>(
242
+ "[data-component='page-actions'] [data-action='copy-markdown']",
243
+ ).forEach((btn) => {
244
+ // @ts-expect-error — guard against double-binding on
245
+ // astro:after-swap re-runs.
246
+ if (btn.__copyBound) return;
247
+ // @ts-expect-error
248
+ btn.__copyBound = true;
249
+
250
+ btn.addEventListener("click", async (e) => {
251
+ e.preventDefault();
252
+ const text = btn.getAttribute("data-copy-text") ?? "";
253
+ try {
254
+ await navigator.clipboard.writeText(text);
255
+ } catch {
256
+ // No clipboard access (insecure context, denied permission, etc.)
257
+ return;
258
+ }
259
+ const copyIcon = btn.querySelector<HTMLElement>("[data-copy-icon]");
260
+ const checkIcon = btn.querySelector<HTMLElement>("[data-check-icon]");
261
+ const label = btn.querySelector<HTMLElement>("[data-copy-label]");
262
+ const original = label?.textContent ?? "Copy markdown";
263
+ if (copyIcon && checkIcon) {
264
+ copyIcon.classList.add("hidden");
265
+ checkIcon.classList.remove("hidden");
266
+ }
267
+ if (label) label.textContent = "Copied!";
268
+ setTimeout(() => {
269
+ if (copyIcon && checkIcon) {
270
+ copyIcon.classList.remove("hidden");
271
+ checkIcon.classList.add("hidden");
272
+ }
273
+ if (label) label.textContent = original;
274
+ }, 2000);
275
+ });
276
+ });
277
+ }
278
+
279
+ setupPageActionsCopy();
280
+ document.addEventListener("astro:after-swap", setupPageActionsCopy);
281
+ </script>