@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,529 @@
1
+ ---
2
+ /**
3
+ * Search dialog wired to Pagefind.
4
+ *
5
+ * Loads `/{pagefindUrl}/pagefind.js` lazily on first open. No JavaScript ships
6
+ * to the page until the user actually opens search (Cmd+K / Ctrl+K / `/`),
7
+ * so the cost is paid only by users who search.
8
+ *
9
+ * Pagefind itself is generated by the post-build step
10
+ * `pagefind --site dist` declared in the project's package.json.
11
+ *
12
+ * Faceted filtering: when the indexed Pagefind data has filters
13
+ * (`<div data-pagefind-filter="...">` elements emitted by DocsLayout
14
+ * for tagged pages), the dialog grows a checkbox column on the left
15
+ * for narrowing results by tag / type / status / audience / category.
16
+ * Active filters mirror to URL params so a filtered search is
17
+ * shareable. When the corpus has no filters, the dialog renders the
18
+ * pre-facets layout — single column, byte-identical to the v1.
19
+ *
20
+ * Display labels for facet checkboxes flow from
21
+ * `siteConfig.taxonomyDisplay` — the same map that drives chip
22
+ * rendering, so "Concept: Accessibility" appears in both places.
23
+ *
24
+ * Styling: uses Dogsbay theme tokens (popover / accent / muted-foreground).
25
+ * Drop-in dark/light mode via the existing theme.
26
+ */
27
+ import type { TaxonomyDisplay } from "@dogsbay/types";
28
+
29
+ interface Props {
30
+ /**
31
+ * Path prefix where Pagefind's index lives. Default: "/pagefind/".
32
+ * Override when a site uses a non-root base path (e.g. "/docs/pagefind/").
33
+ */
34
+ pagefindUrl?: string;
35
+ /** Placeholder text for the search input */
36
+ placeholder?: string;
37
+ /**
38
+ * Per-taxonomy display config (`prefixes` + `labels`). When set,
39
+ * facet checkboxes render with human labels ("Concept:
40
+ * Accessibility") instead of raw slugs (`concept/a11y`). Fall
41
+ * back to slugs when undefined.
42
+ */
43
+ taxonomyDisplay?: Record<string, TaxonomyDisplay>;
44
+ }
45
+
46
+ const {
47
+ pagefindUrl = "/pagefind/",
48
+ placeholder = "Search docs...",
49
+ taxonomyDisplay,
50
+ } = Astro.props;
51
+ ---
52
+
53
+ <dialog
54
+ data-search-dialog
55
+ data-pagefind-url={pagefindUrl}
56
+ data-taxonomy-display={taxonomyDisplay ? JSON.stringify(taxonomyDisplay) : ""}
57
+ class="fixed left-1/2 top-[10vh] z-50 w-[calc(100vw-2rem)] max-w-4xl -translate-x-1/2 rounded-xl border border-border bg-popover p-0 text-popover-foreground shadow-2xl backdrop:bg-black/40 backdrop:backdrop-blur-sm"
58
+ >
59
+ <form method="dialog" class="flex flex-col">
60
+ <div class="flex items-center gap-2 border-b border-border px-4">
61
+ <svg
62
+ xmlns="http://www.w3.org/2000/svg"
63
+ width="16"
64
+ height="16"
65
+ viewBox="0 0 24 24"
66
+ fill="none"
67
+ stroke="currentColor"
68
+ stroke-width="2"
69
+ stroke-linecap="round"
70
+ stroke-linejoin="round"
71
+ class="size-4 shrink-0 text-muted-foreground"
72
+ aria-hidden="true"
73
+ >
74
+ <circle cx="11" cy="11" r="8" />
75
+ <path d="m21 21-4.3-4.3" />
76
+ </svg>
77
+
78
+ <input
79
+ type="search"
80
+ data-search-input
81
+ placeholder={placeholder}
82
+ autocomplete="off"
83
+ spellcheck="false"
84
+ class="flex h-12 w-full rounded-md bg-transparent text-sm outline-none placeholder:text-muted-foreground"
85
+ aria-label="Search documentation"
86
+ />
87
+
88
+ <kbd class="rounded border border-border px-1.5 py-0.5 font-mono text-xs text-muted-foreground">
89
+ Esc
90
+ </kbd>
91
+ </div>
92
+
93
+ {/*
94
+ Two-pane layout: facets column + results. The facets column
95
+ hides when no filters are present in the index (single-column
96
+ mode falls back to v1 behavior). Mobile: facets collapse into
97
+ a disclosure above the results.
98
+ */}
99
+ <div data-search-body class="flex max-h-[60vh]">
100
+ <aside
101
+ data-search-facets
102
+ class="hidden w-56 shrink-0 overflow-y-auto border-r border-border p-3 text-sm"
103
+ aria-label="Filter results"
104
+ >
105
+ {/* Populated at runtime once Pagefind reports filter list */}
106
+ </aside>
107
+
108
+ <div
109
+ data-search-results
110
+ class="flex-1 overflow-y-auto p-2 text-sm"
111
+ aria-live="polite"
112
+ >
113
+ <div data-search-empty class="px-3 py-8 text-center text-muted-foreground">
114
+ Start typing to search...
115
+ </div>
116
+ </div>
117
+ </div>
118
+ </form>
119
+ </dialog>
120
+
121
+ <button
122
+ type="button"
123
+ data-search-trigger
124
+ class="inline-flex h-9 items-center gap-2 rounded-md border border-border bg-background px-3 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
125
+ aria-label="Open search"
126
+ >
127
+ <svg
128
+ xmlns="http://www.w3.org/2000/svg"
129
+ width="14"
130
+ height="14"
131
+ viewBox="0 0 24 24"
132
+ fill="none"
133
+ stroke="currentColor"
134
+ stroke-width="2"
135
+ stroke-linecap="round"
136
+ stroke-linejoin="round"
137
+ aria-hidden="true"
138
+ >
139
+ <circle cx="11" cy="11" r="8" />
140
+ <path d="m21 21-4.3-4.3" />
141
+ </svg>
142
+ <span class="hidden sm:inline">Search</span>
143
+ <kbd class="hidden rounded border border-border px-1.5 py-0.5 font-mono text-[10px] sm:inline">
144
+ ⌘K
145
+ </kbd>
146
+ </button>
147
+
148
+ <script>
149
+ /**
150
+ * Pagefind search wiring.
151
+ *
152
+ * The pagefind module is loaded the first time the dialog opens. Subsequent
153
+ * opens reuse it. Nothing is loaded for users who never search.
154
+ */
155
+ import {
156
+ shapeFacets,
157
+ resolveFacetLabel,
158
+ resolveFacetTitle,
159
+ filterStateToUrlParams,
160
+ parseFiltersFromUrl,
161
+ filterStateToPagefindFilters,
162
+ toggleFilter,
163
+ countActiveFilters,
164
+ type FacetMap,
165
+ type FilterState,
166
+ type TaxonomyDisplayMap,
167
+ } from "./search-facets.js";
168
+
169
+ type PagefindResult = {
170
+ id: string;
171
+ data(): Promise<PagefindResultData>;
172
+ };
173
+ type PagefindResultData = {
174
+ url: string;
175
+ raw_url: string;
176
+ excerpt: string;
177
+ meta: { title?: string };
178
+ sub_results?: Array<{ title: string; url: string; excerpt: string }>;
179
+ };
180
+ type PagefindModule = {
181
+ search(
182
+ query: string,
183
+ options?: { filters?: Record<string, string[]> },
184
+ ): Promise<{ results: PagefindResult[] }>;
185
+ filters(): Promise<Record<string, Record<string, number>>>;
186
+ };
187
+
188
+ const dialog = document.querySelector<HTMLDialogElement>("[data-search-dialog]");
189
+ const trigger = document.querySelector<HTMLButtonElement>("[data-search-trigger]");
190
+ const input = dialog?.querySelector<HTMLInputElement>("[data-search-input]");
191
+ const resultsBox = dialog?.querySelector<HTMLDivElement>("[data-search-results]");
192
+ const emptyState = dialog?.querySelector<HTMLDivElement>("[data-search-empty]");
193
+ const facetsBox = dialog?.querySelector<HTMLElement>("[data-search-facets]");
194
+
195
+ if (!dialog || !trigger || !input || !resultsBox || !facetsBox) {
196
+ // Component not present on this page — bail.
197
+ } else {
198
+ let pagefind: PagefindModule | null = null;
199
+ let loadingPagefind: Promise<void> | null = null;
200
+ let activeQueryToken = 0;
201
+
202
+ // Active filter state. Updated by checkbox toggles, the
203
+ // URL-restore step on dialog open, and "Clear all".
204
+ let filters: FilterState = {};
205
+ let availableFacets: FacetMap = {};
206
+
207
+ // Display config baked into a data attribute by the Astro
208
+ // template — parsed lazily.
209
+ const taxonomyDisplay: TaxonomyDisplayMap = (() => {
210
+ try {
211
+ const raw = dialog!.dataset.taxonomyDisplay;
212
+ return raw ? (JSON.parse(raw) as TaxonomyDisplayMap) : {};
213
+ } catch {
214
+ return {};
215
+ }
216
+ })();
217
+
218
+ async function ensurePagefindLoaded() {
219
+ if (pagefind) return;
220
+ if (loadingPagefind) return loadingPagefind;
221
+ const url = (dialog!.dataset.pagefindUrl ?? "/pagefind/") + "pagefind.js";
222
+ loadingPagefind = (async () => {
223
+ try {
224
+ const mod = (await import(/* @vite-ignore */ url)) as PagefindModule;
225
+ pagefind = mod;
226
+ // Discover facets once, on first load. Pagefind builds
227
+ // this from the body-level data-pagefind-filter elements.
228
+ if (typeof mod.filters === "function") {
229
+ const raw = await mod.filters();
230
+ availableFacets = shapeFacets(raw);
231
+ renderFacets();
232
+ }
233
+ } catch (err) {
234
+ console.warn("[dogsbay] failed to load Pagefind:", err);
235
+ }
236
+ })();
237
+ return loadingPagefind;
238
+ }
239
+
240
+ function escapeHtml(s: string): string {
241
+ return s
242
+ .replace(/&/g, "&amp;")
243
+ .replace(/</g, "&lt;")
244
+ .replace(/>/g, "&gt;");
245
+ }
246
+
247
+ /**
248
+ * Append a text fragment (`#:~:text=...`) so the browser highlights
249
+ * matched terms on arrival. If the URL already has a fragment (e.g.
250
+ * `#section-id` from Pagefind sub-results), append using `&text=` per
251
+ * the spec. Falls back to no fragment if the query is empty.
252
+ *
253
+ * Browser support: Chrome, Edge, Firefox 131+, Safari 18.2+.
254
+ * Where unsupported, the fragment is harmless — page just doesn't
255
+ * highlight.
256
+ */
257
+ function withTextFragment(url: string, query: string): string {
258
+ const trimmed = query.trim();
259
+ if (!trimmed) return url;
260
+ // Pagefind tokenizes the query; for highlighting we want whole-phrase
261
+ // matches plus individual terms. Browsers can take multiple text=
262
+ // fragments separated by `&`.
263
+ const terms = trimmed
264
+ .split(/\s+/)
265
+ .filter((t) => t.length >= 2) // skip 1-char filler tokens
266
+ .slice(0, 5) // cap at 5 terms (URL length)
267
+ .map((t) => encodeURIComponent(t));
268
+ if (terms.length === 0) return url;
269
+ const fragment = "text=" + terms.join("&text=");
270
+ const sep = url.includes("#") ? "&" : "#:~:";
271
+ return url + sep + fragment;
272
+ }
273
+
274
+ function renderResults(results: Array<PagefindResultData>, query: string) {
275
+ if (!results.length) {
276
+ resultsBox!.innerHTML = `<div class="px-3 py-8 text-center text-muted-foreground">No results found.</div>`;
277
+ return;
278
+ }
279
+ const html = results
280
+ .map((r) => {
281
+ const title = escapeHtml(r.meta?.title ?? r.url);
282
+ // Page-level link with text fragment for top-of-page highlighting
283
+ const pageHref = withTextFragment(r.url, query);
284
+ // Sub-results: section-level matches with their own anchor URLs.
285
+ // We render them under the main result so users can jump straight
286
+ // to a specific heading. Each sub-result URL already has a fragment
287
+ // (e.g. /docs/foo/#section-id), so withTextFragment uses & instead
288
+ // of #:~: to combine the two.
289
+ const subHtml = (r.sub_results ?? [])
290
+ .slice(0, 3)
291
+ .map((sub) => `
292
+ <a href="${withTextFragment(sub.url, query)}" class="block rounded-md py-1.5 pl-6 pr-3 text-xs text-muted-foreground hover:bg-accent hover:text-foreground">
293
+ <div class="flex items-center gap-1.5">
294
+ <svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="shrink-0" aria-hidden="true">
295
+ <polyline points="9 18 15 12 9 6"></polyline>
296
+ </svg>
297
+ <span class="truncate">${escapeHtml(sub.title)}</span>
298
+ </div>
299
+ </a>
300
+ `)
301
+ .join("");
302
+ return `
303
+ <div class="mb-1">
304
+ <a href="${pageHref}" class="block rounded-md p-3 hover:bg-accent">
305
+ <div class="font-medium">${title}</div>
306
+ <div class="mt-1 line-clamp-2 text-xs text-muted-foreground [&_mark]:rounded-sm [&_mark]:bg-primary/20 [&_mark]:px-0.5 [&_mark]:font-medium [&_mark]:text-foreground">${r.excerpt}</div>
307
+ </a>
308
+ ${subHtml}
309
+ </div>
310
+ `;
311
+ })
312
+ .join("");
313
+ resultsBox!.innerHTML = html;
314
+ }
315
+
316
+ /**
317
+ * Build the facets sidebar. Runs once after Pagefind discovers
318
+ * filters, then again whenever filter state changes (so checkbox
319
+ * `checked` reflects current selections). When the corpus has no
320
+ * filters, the sidebar stays hidden — single-column layout.
321
+ */
322
+ function renderFacets() {
323
+ const facetNames = Object.keys(availableFacets);
324
+ if (facetNames.length === 0) {
325
+ facetsBox!.classList.add("hidden");
326
+ return;
327
+ }
328
+ facetsBox!.classList.remove("hidden");
329
+
330
+ const activeCount = countActiveFilters(filters);
331
+ const clearAll = activeCount > 0
332
+ ? `<button type="button" data-clear-filters class="mb-3 w-full rounded-md border border-border px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground">Clear all (${activeCount})</button>`
333
+ : "";
334
+
335
+ const groups = facetNames
336
+ .map((name) => {
337
+ const entries = availableFacets[name];
338
+ const title = escapeHtml(resolveFacetTitle(name));
339
+ const items = entries
340
+ .map((entry) => {
341
+ const checked = (filters[name] ?? []).includes(entry.value);
342
+ const label = escapeHtml(
343
+ resolveFacetLabel(name, entry.value, taxonomyDisplay),
344
+ );
345
+ const id = `facet-${name}-${entry.value}`.replace(/[^a-z0-9-]/gi, "-");
346
+ return `
347
+ <li>
348
+ <label for="${id}" class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1 hover:bg-accent">
349
+ <input
350
+ type="checkbox"
351
+ id="${id}"
352
+ data-facet-name="${escapeHtml(name)}"
353
+ data-facet-value="${escapeHtml(entry.value)}"
354
+ ${checked ? "checked" : ""}
355
+ class="size-3.5 rounded border-border accent-primary"
356
+ />
357
+ <span class="flex-1 truncate">${label}</span>
358
+ <span class="text-xs text-muted-foreground">${entry.count}</span>
359
+ </label>
360
+ </li>
361
+ `;
362
+ })
363
+ .join("");
364
+ return `
365
+ <fieldset class="mb-3">
366
+ <legend class="mb-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">${title}</legend>
367
+ <ul class="space-y-0.5">${items}</ul>
368
+ </fieldset>
369
+ `;
370
+ })
371
+ .join("");
372
+
373
+ facetsBox!.innerHTML = clearAll + groups;
374
+ }
375
+
376
+ async function runSearch(query: string) {
377
+ const trimmed = query.trim();
378
+ const hasActiveFilters = countActiveFilters(filters) > 0;
379
+ if (!trimmed && !hasActiveFilters) {
380
+ if (emptyState) emptyState.style.display = "";
381
+ resultsBox!.innerHTML = "";
382
+ if (emptyState) resultsBox!.appendChild(emptyState);
383
+ return;
384
+ }
385
+ await ensurePagefindLoaded();
386
+ if (!pagefind) {
387
+ resultsBox!.innerHTML = `<div class="px-3 py-8 text-center text-muted-foreground">Search index unavailable. Run <code>pnpm build</code> to generate it.</div>`;
388
+ return;
389
+ }
390
+ const token = ++activeQueryToken;
391
+ // Filter-only searches (empty query, active filters): Pagefind
392
+ // accepts an empty / null query and returns the full filtered
393
+ // result set.
394
+ const search = await pagefind.search(trimmed || null as unknown as string, {
395
+ filters: filterStateToPagefindFilters(filters),
396
+ });
397
+ // Bail if a newer query has started while we were awaiting
398
+ if (token !== activeQueryToken) return;
399
+ const data = await Promise.all(search.results.slice(0, 8).map((r) => r.data()));
400
+ if (token !== activeQueryToken) return;
401
+ renderResults(data, trimmed);
402
+ }
403
+
404
+ /**
405
+ * Mirror current state to the URL via replaceState so reload /
406
+ * paste preserves it. Only mutates the URL while the dialog is
407
+ * open; closing the dialog leaves the state in place so users
408
+ * can copy the link AFTER searching.
409
+ */
410
+ function syncUrl() {
411
+ const params = filterStateToUrlParams(input!.value, filters);
412
+ const newUrl = params.toString().length > 0
413
+ ? `${window.location.pathname}?${params}${window.location.hash}`
414
+ : `${window.location.pathname}${window.location.hash}`;
415
+ window.history.replaceState({}, "", newUrl);
416
+ }
417
+
418
+ function openDialog() {
419
+ // Prefetch index in the background; user might be slow to type
420
+ ensurePagefindLoaded();
421
+ dialog!.showModal();
422
+
423
+ // Restore from URL params on open. Keeps the dialog roundtrippable
424
+ // — paste a saved URL, open search, see the prior query + filters.
425
+ const fromUrl = parseFiltersFromUrl(new URLSearchParams(window.location.search));
426
+ input!.value = fromUrl.query;
427
+ filters = fromUrl.filters;
428
+ renderFacets();
429
+
430
+ const hasInitial = input!.value.length > 0 || countActiveFilters(filters) > 0;
431
+ if (hasInitial) {
432
+ runSearch(input!.value);
433
+ } else {
434
+ if (emptyState) {
435
+ resultsBox!.innerHTML = "";
436
+ resultsBox!.appendChild(emptyState);
437
+ emptyState.style.display = "";
438
+ }
439
+ }
440
+ // Focus after the dialog is in the layout tree
441
+ requestAnimationFrame(() => input!.focus());
442
+ }
443
+
444
+ trigger.addEventListener("click", openDialog);
445
+
446
+ // Keyboard shortcut: Cmd+K / Ctrl+K / `/` (when not focused on a form field)
447
+ document.addEventListener("keydown", (e) => {
448
+ const target = e.target as HTMLElement;
449
+ const inField =
450
+ target.tagName === "INPUT" ||
451
+ target.tagName === "TEXTAREA" ||
452
+ target.isContentEditable;
453
+
454
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
455
+ e.preventDefault();
456
+ if (dialog!.open) {
457
+ dialog!.close();
458
+ } else {
459
+ openDialog();
460
+ }
461
+ } else if (e.key === "/" && !inField && !dialog!.open) {
462
+ e.preventDefault();
463
+ openDialog();
464
+ }
465
+ });
466
+
467
+ // Debounced query
468
+ let debounceTimer: number | undefined;
469
+ input.addEventListener("input", () => {
470
+ clearTimeout(debounceTimer);
471
+ debounceTimer = window.setTimeout(() => {
472
+ runSearch(input!.value);
473
+ syncUrl();
474
+ }, 120);
475
+ });
476
+
477
+ // Facet checkbox toggling — event delegation on the sidebar.
478
+ facetsBox.addEventListener("change", (e) => {
479
+ const target = e.target as HTMLInputElement;
480
+ if (target.tagName !== "INPUT" || target.type !== "checkbox") return;
481
+ const name = target.dataset.facetName;
482
+ const value = target.dataset.facetValue;
483
+ if (!name || !value) return;
484
+ filters = toggleFilter(filters, name, value);
485
+ renderFacets();
486
+ runSearch(input!.value);
487
+ syncUrl();
488
+ });
489
+
490
+ // "Clear all" button (rendered when filters are active).
491
+ facetsBox.addEventListener("click", (e) => {
492
+ const target = e.target as HTMLElement;
493
+ const clear = target.closest<HTMLButtonElement>("[data-clear-filters]");
494
+ if (!clear) return;
495
+ filters = {};
496
+ renderFacets();
497
+ runSearch(input!.value);
498
+ syncUrl();
499
+ });
500
+
501
+ // Click outside to close
502
+ dialog.addEventListener("click", (e) => {
503
+ if (e.target === dialog) dialog!.close();
504
+ });
505
+
506
+ // Close when a result is clicked. For cross-page links the browser
507
+ // unloads the dialog implicitly, but for same-page hash navigation
508
+ // (e.g. clicking a sub-result heading anchor on the current page) the
509
+ // dialog otherwise stays open with no visible state change.
510
+ // Don't preventDefault — let the browser handle the actual navigation.
511
+ resultsBox.addEventListener("click", (e) => {
512
+ const target = e.target as HTMLElement;
513
+ const link = target.closest("a");
514
+ if (link && resultsBox!.contains(link)) {
515
+ dialog!.close();
516
+ }
517
+ });
518
+ }
519
+ </script>
520
+
521
+ <style is:global>
522
+ /* Theme-matching highlight for text fragments arriving via #:~:text=...
523
+ The browser default is a flat yellow that clashes with custom themes;
524
+ this inherits from --primary so it adapts to light/dark. */
525
+ ::target-text {
526
+ background-color: oklch(from var(--primary) l c h / 0.3);
527
+ color: inherit;
528
+ }
529
+ </style>
@@ -0,0 +1,79 @@
1
+ ---
2
+ /**
3
+ * StatusBadge — surfaces page lifecycle state.
4
+ *
5
+ * Closed enum: `draft | preview | stable | deprecated`. Renders
6
+ * nothing for `stable` (the default state — no visual noise on
7
+ * shipped pages).
8
+ *
9
+ * `deprecated` and `draft` get strong colors; `preview` is muted.
10
+ *
11
+ * When `href` is set, the badge renders as a link (e.g. to a
12
+ * `/by-status/<value>/` browse page). `<DocsLayout>` computes the
13
+ * href from `siteConfig.taxonomyIndexPaths.status` when the user
14
+ * has declared `taxonomies.status:` in their config.
15
+ */
16
+ type Status = "draft" | "preview" | "stable" | "deprecated";
17
+
18
+ interface Props {
19
+ status?: Status;
20
+ /** Optional link target — renders the badge as `<a>` when set. */
21
+ href?: string;
22
+ class?: string;
23
+ }
24
+
25
+ const { status, href, class: className } = Astro.props;
26
+
27
+ // Pre-compute the style + label strings in the script block. Doing
28
+ // the lookup in JSX with a typed cast (e.g. `styles[status as Exclude<Status, "stable">]`)
29
+ // trips esbuild — the `<` in the generic argument is parsed as JSX.
30
+ function styleFor(s: Status): string {
31
+ switch (s) {
32
+ case "draft":
33
+ return "border-amber-500/40 bg-amber-500/15 text-amber-700 dark:text-amber-300";
34
+ case "preview":
35
+ return "border-blue-500/40 bg-blue-500/15 text-blue-700 dark:text-blue-300";
36
+ case "deprecated":
37
+ return "border-destructive/50 bg-destructive/15 text-destructive";
38
+ default:
39
+ return "";
40
+ }
41
+ }
42
+
43
+ function labelFor(s: Status): string {
44
+ switch (s) {
45
+ case "draft":
46
+ return "Draft";
47
+ case "preview":
48
+ return "Preview";
49
+ case "deprecated":
50
+ return "Deprecated";
51
+ default:
52
+ return "";
53
+ }
54
+ }
55
+
56
+ const visible = status !== undefined && status !== "stable";
57
+ const style = status ? styleFor(status) : "";
58
+ const label = status ? labelFor(status) : "";
59
+ const baseClasses = "inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-semibold uppercase tracking-wide";
60
+ const linkClasses = "transition-colors hover:opacity-80";
61
+ ---
62
+
63
+ {visible && href && (
64
+ <a
65
+ href={href}
66
+ class:list={[baseClasses, linkClasses, style, className]}
67
+ data-status={status}
68
+ >
69
+ {label}
70
+ </a>
71
+ )}
72
+ {visible && !href && (
73
+ <span
74
+ class:list={[baseClasses, style, className]}
75
+ data-status={status}
76
+ >
77
+ {label}
78
+ </span>
79
+ )}