@waveso/docs 0.2.0 → 0.4.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.
Files changed (59) hide show
  1. package/CHANGELOG.md +138 -0
  2. package/README.md +490 -75
  3. package/dist/code-frame.d.ts +29 -0
  4. package/dist/code-frame.js +41 -0
  5. package/dist/code-meta.d.ts +48 -0
  6. package/dist/code-meta.js +72 -0
  7. package/dist/docs-content-id.d.ts +19 -0
  8. package/dist/docs-content-id.js +19 -0
  9. package/dist/docs-error.d.ts +2 -57
  10. package/dist/docs-error.js +3 -15
  11. package/dist/errors.d.ts +94 -0
  12. package/dist/errors.js +45 -0
  13. package/dist/next.d.ts +153 -28
  14. package/dist/next.js +65 -33
  15. package/dist/plugins/rehype-capture-toc.js +26 -5
  16. package/dist/plugins/rehype-code-frame.d.ts +10 -0
  17. package/dist/plugins/rehype-code-frame.js +88 -0
  18. package/dist/plugins/rehype-code-language.js +7 -1
  19. package/dist/react/code-runtime.d.ts +14 -0
  20. package/dist/react/code-runtime.js +161 -0
  21. package/dist/react/doc-content.d.ts +39 -2
  22. package/dist/react/doc-content.js +42 -10
  23. package/dist/react/layout.d.ts +44 -0
  24. package/dist/react/layout.js +65 -0
  25. package/dist/react/nav.d.ts +28 -0
  26. package/dist/react/nav.js +70 -0
  27. package/dist/react/nearest-scroll-top.d.ts +45 -0
  28. package/dist/react/nearest-scroll-top.js +44 -0
  29. package/dist/react/next-link.d.ts +34 -0
  30. package/dist/react/next-link.js +30 -0
  31. package/dist/react/next-nav.d.ts +11 -0
  32. package/dist/react/next-nav.js +32 -0
  33. package/dist/react/next-search.d.ts +22 -0
  34. package/dist/react/next-search.js +52 -0
  35. package/dist/react/search-dialog.d.ts +53 -10
  36. package/dist/react/search-dialog.js +147 -47
  37. package/dist/react/shell-labels.d.ts +43 -0
  38. package/dist/react/shell-labels.js +27 -0
  39. package/dist/react/sidebar.d.ts +38 -3
  40. package/dist/react/sidebar.js +104 -12
  41. package/dist/react/skip-link.d.ts +1 -9
  42. package/dist/react/skip-link.js +6 -5
  43. package/dist/react/toc.d.ts +12 -4
  44. package/dist/react/toc.js +18 -7
  45. package/dist/react/youtube.d.ts +31 -5
  46. package/dist/react/youtube.js +76 -54
  47. package/dist/render.d.ts +35 -1
  48. package/dist/render.js +35 -14
  49. package/dist/route-path.d.ts +46 -0
  50. package/dist/route-path.js +51 -0
  51. package/dist/search-index.d.ts +6 -23
  52. package/dist/search-index.js +6 -51
  53. package/dist/sitemap-limit.d.ts +34 -0
  54. package/dist/sitemap-limit.js +37 -0
  55. package/dist/source.d.ts +1 -23
  56. package/dist/source.js +40 -43
  57. package/dist/styles.css +1001 -106
  58. package/dist/types.d.ts +11 -2
  59. package/package.json +58 -23
@@ -0,0 +1,32 @@
1
+ "use client";
2
+ import { wrapNextLink } from "./next-link.js";
3
+ import { DocsNav } from "./nav.js";
4
+ import { jsx } from "react/jsx-runtime";
5
+ import NextLink from "next/link";
6
+ import { usePathname } from "next/navigation";
7
+ //#region src/react/next-nav.tsx
8
+ /**
9
+ * {@link DocsNav}, wired to Next's router.
10
+ *
11
+ * Private, and the **only** module in `src/react/` that imports
12
+ * `next/navigation` — named so that is obvious from the file list. Everything
13
+ * else in here takes `pathname` and `Link` as props, which is what lets the
14
+ * components be tested without mounting a router and reused outside Next.
15
+ *
16
+ * The split is not ceremony: `usePathname` is why the consumer used to have to
17
+ * hand-write a `'use client'` wrapper of their own, and deleting that file from
18
+ * the README is most of what `docs.Layout` is for.
19
+ */
20
+ /** Module scope: a fresh identity here remounts every nav link on every render. */
21
+ const Link = wrapNextLink(NextLink);
22
+ function DocsNextNav({ nav, label, closeLabel }) {
23
+ return /* @__PURE__ */ jsx(DocsNav, {
24
+ nav,
25
+ pathname: usePathname(),
26
+ Link,
27
+ ...label === void 0 ? {} : { label },
28
+ ...closeLabel === void 0 ? {} : { closeLabel }
29
+ });
30
+ }
31
+ //#endregion
32
+ export { DocsNextNav };
@@ -0,0 +1,22 @@
1
+ import { SearchDialogProps } from "./search-dialog.js";
2
+ import { ReactNode } from "react";
3
+ //#region src/react/next-search.d.ts
4
+ /**
5
+ * Everything `SearchDialog` takes except the two things Next answers for you.
6
+ *
7
+ * `indexUrl` stays required, and `docs.searchIndexUrl` is the only value
8
+ * anyone should pass: defaulting it to `/search-index.json` would be wrong
9
+ * under every non-root `basePath`, and wrong as a 404 the reader hits and the
10
+ * author never sees.
11
+ */
12
+ type DocsSearchProps = Omit<SearchDialogProps, 'navigate' | 'Link'>;
13
+ /**
14
+ * Search trigger and dialog for a Next application.
15
+ *
16
+ * `next/link` is passed through rather than left to the plain-anchor fallback,
17
+ * so hovering a result prefetches the page it points at — which is most of why
18
+ * a hit feels instant when you press Enter.
19
+ */
20
+ declare function DocsSearch(props: DocsSearchProps): ReactNode;
21
+ //#endregion
22
+ export { DocsSearch, DocsSearchProps };
@@ -0,0 +1,52 @@
1
+ "use client";
2
+ import { wrapNextLink } from "./next-link.js";
3
+ import { SearchDialog } from "./search-dialog.js";
4
+ import { useCallback } from "react";
5
+ import { jsx } from "react/jsx-runtime";
6
+ import NextLink from "next/link";
7
+ import { useRouter } from "next/navigation";
8
+ //#region src/react/next-search.tsx
9
+ /**
10
+ * {@link SearchDialog}, wired to Next's router and `next/link`.
11
+ *
12
+ * `SearchDialog` takes `navigate` and `Link` as props so it stays
13
+ * host-agnostic, and that seam is why its own tests are worth having — they
14
+ * assert behaviour against a stub router rather than mounting Next. But this
15
+ * package is a Next adapter, so in practice every consumer wrote the same
16
+ * fifteen-line `'use client'` wrapper around `useRouter().push` and
17
+ * `next/link`, and the ones who skipped `Link` lost hover prefetching on
18
+ * every result without anything telling them.
19
+ *
20
+ * So it ships. The same bargain `createDocsRoute` already strikes on the
21
+ * server, where `next/link` and `next/image` are wired by default and
22
+ * overridable through `components`.
23
+ *
24
+ * ```tsx
25
+ * // app/docs/layout.tsx — a Server Component; this file carries the boundary
26
+ * import { DocsSearch } from '@waveso/docs/react/next-search';
27
+ * import { docs } from '@/lib/docs';
28
+ *
29
+ * <DocsSearch indexUrl={docs.searchIndexUrl} />
30
+ * ```
31
+ */
32
+ const Link = wrapNextLink(NextLink);
33
+ /**
34
+ * Search trigger and dialog for a Next application.
35
+ *
36
+ * `next/link` is passed through rather than left to the plain-anchor fallback,
37
+ * so hovering a result prefetches the page it points at — which is most of why
38
+ * a hit feels instant when you press Enter.
39
+ */
40
+ function DocsSearch(props) {
41
+ const router = useRouter();
42
+ const navigate = useCallback((href) => {
43
+ router.push(href);
44
+ }, [router]);
45
+ return /* @__PURE__ */ jsx(SearchDialog, {
46
+ ...props,
47
+ navigate,
48
+ Link
49
+ });
50
+ }
51
+ //#endregion
52
+ export { DocsSearch };
@@ -26,8 +26,39 @@ interface SearchDialogProps {
26
26
  placeholder?: string | undefined;
27
27
  /** Accessible name for the dialog. Defaults to `'Search documentation'`. */
28
28
  dialogLabel?: string | undefined;
29
- /** Maximum results rendered. Defaults to 8. */
30
- maxResults?: number | undefined;
29
+ /**
30
+ * How many results to render at a time. Defaults to 20.
31
+ *
32
+ * ⚠️ NOT A CAP. Every match is reachable — the list renders this many, then
33
+ * another `pageSize` each time the reader scrolls near the end, so the DOM
34
+ * stays bounded without anything being withheld.
35
+ *
36
+ * This was `maxResults`, and it was a hard ceiling of 8. On a *six-page*
37
+ * site "docs" matches 18, so ten results simply could not be reached, and
38
+ * the live region announced "8 results" — not a smaller truth but a false
39
+ * one. The ceiling was justified by a claim nobody had measured, and the
40
+ * measurement did not support it: on a 300-page corpus (2,100 records) a
41
+ * query costs 1.3–3.0 ms and rendering *every* row costs 40 ms, 128 ms at
42
+ * 4x CPU throttle. Paging exists to keep that worst case from ever being
43
+ * reached, not because the search cannot find things.
44
+ */
45
+ pageSize?: number | undefined;
46
+ /**
47
+ * Shortest query that runs. Defaults to 2.
48
+ *
49
+ * A single character is not a query — measured on this package's own docs,
50
+ * "a" matches 100% of the corpus, "i" 97%, "s" 93%. Answering those wastes a
51
+ * render and, worse, teaches a reader mid-word that search returns noise.
52
+ *
53
+ * ⚠️ TWO, NOT THREE, AND THE DIFFERENCE MATTERS ON A DOCS SITE. Three would
54
+ * refuse `ts`, `js`, `id`, `h1`, `px` — every one a real query here, and each
55
+ * one selective: 10%, 17%, 14%, 3%, 0%. The noise is at one character, so
56
+ * that is where the floor goes.
57
+ *
58
+ * A word like `is` still matches 83%; that is a stopword problem rather than
59
+ * a length one, and `miniSearchOptions.processTerm` is the tool for it.
60
+ */
61
+ minQueryLength?: number | undefined;
31
62
  /** Input debounce in milliseconds. Defaults to 120. */
32
63
  debounceMs?: number | undefined;
33
64
  /** Extra class names for the trigger button, e.g. a navbar's own layout. */
@@ -35,15 +66,27 @@ interface SearchDialogProps {
35
66
  /**
36
67
  * Overrides applied through `mergeSearchOptions` when the index is
37
68
  * deserialised — the escape hatch for tokenisation, `processTerm` and the
38
- * query defaults (`fuzzy`, `prefix`, `combineWith`, `boost`) without waiting
39
- * on a release of this package.
69
+ * query defaults, without waiting on a release of this package.
70
+ *
71
+ * MiniSearch's own name for the query defaults is `searchOptions`, so they
72
+ * nest one level down:
73
+ *
74
+ * ```tsx
75
+ * <DocsSearch miniSearchOptions={{ searchOptions: { fuzzy: 0.1 } }} />
76
+ * ```
77
+ *
78
+ * That stutter is why this prop is not called `searchOptions` too. It was,
79
+ * and `searchOptions={{ fuzzy: 0.1 }}` reads so naturally that both README
80
+ * examples were written that way — neither compiled, and the flat form is
81
+ * not a runtime error either. It is a `fuzzy` MiniSearch never reads.
40
82
  *
41
- * ⚠️ HAND THE IDENTICAL OVERRIDES TO `buildSearchIndex`. `tokenize` and
42
- * `processTerm` decide how terms were written into the index; a client that
43
- * splits differently from the build looks up terms that were never written
44
- * and finds nothing, silently.
83
+ * ⚠️ HAND THE IDENTICAL OVERRIDES TO THE BUILD — `createDocsRoute`'s
84
+ * `miniSearchOptions`, or `buildSearchIndex`'s second argument. `tokenize`
85
+ * and `processTerm` decide how terms were written into the index; a client
86
+ * that splits differently from the build looks up terms that were never
87
+ * written and finds nothing, silently.
45
88
  */
46
- searchOptions?: Partial<Options<SearchRecord>> | undefined;
89
+ miniSearchOptions?: Partial<Options<SearchRecord>> | undefined;
47
90
  }
48
91
  /**
49
92
  * Search trigger plus its dialog.
@@ -52,6 +95,6 @@ interface SearchDialogProps {
52
95
  * portalled to `document.body`, so a navbar's stacking context cannot trap
53
96
  * it behind the page.
54
97
  */
55
- declare function SearchDialog({ indexUrl, navigate, Link, triggerLabel, placeholder, dialogLabel, maxResults, debounceMs, className, searchOptions }: SearchDialogProps): ReactNode;
98
+ declare function SearchDialog({ indexUrl, navigate, Link, triggerLabel, placeholder, dialogLabel, pageSize, minQueryLength, debounceMs, className, miniSearchOptions }: SearchDialogProps): ReactNode;
56
99
  //#endregion
57
100
  export { SearchDialog, SearchDialogProps };
@@ -2,7 +2,7 @@
2
2
  import { docsError } from "../docs-error.js";
3
3
  import { mergeSearchOptions } from "../search-options.js";
4
4
  import { useCallback, useEffect, useId, useRef, useState } from "react";
5
- import { Fragment, jsx, jsxs } from "react/jsx-runtime";
5
+ import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
6
6
  import { createPortal } from "react-dom";
7
7
  //#region src/react/search-dialog.tsx
8
8
  /**
@@ -26,10 +26,31 @@ const FOCUSABLE_SELECTOR = [
26
26
  * portalled to `document.body`, so a navbar's stacking context cannot trap
27
27
  * it behind the page.
28
28
  */
29
- function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", placeholder = "Search documentation", dialogLabel = "Search documentation", maxResults = 8, debounceMs = 120, className, searchOptions }) {
29
+ function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", placeholder = "Search documentation", dialogLabel = "Search documentation", pageSize = 20, minQueryLength = 2, debounceMs = 120, className, miniSearchOptions }) {
30
30
  const [isOpen, setIsOpen] = useState(false);
31
31
  const [query, setQuery] = useState("");
32
32
  const [hits, setHits] = useState([]);
33
+ /**
34
+ * How many of `hits` are rendered.
35
+ *
36
+ * `hits` holds every match; this is the window. It grows by `pageSize` when
37
+ * the reader scrolls near the end, and whenever the keyboard walks past it —
38
+ * so an option always exists for `aria-activedescendant` to point at.
39
+ */
40
+ const [visibleCount, setVisibleCount] = useState(pageSize);
41
+ /**
42
+ * Whether the active option moved because of a key, rather than a pointer.
43
+ *
44
+ * ⚠️ THE SCROLL-INTO-VIEW BELOW MUST NOT RUN FOR A HOVER. Pointing at a row
45
+ * that is half-clipped by the top or bottom edge set the active index, which
46
+ * scrolled that row flush — moving the whole list under the cursor, which
47
+ * then landed on a different row. Measured: hovering the visible sliver of a
48
+ * clipped row jumped the list 28px.
49
+ *
50
+ * A ref rather than state: it records how the *last* change happened and must
51
+ * not itself cause a render.
52
+ */
53
+ const movedByKeyboard = useRef(false);
33
54
  const [activeIndex, setActiveIndex] = useState(0);
34
55
  const [status, setStatus] = useState("idle");
35
56
  const [shortcutHint, setShortcutHint] = useState("");
@@ -39,21 +60,21 @@ function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", place
39
60
  const listRef = useRef(null);
40
61
  const returnFocusRef = useRef(null);
41
62
  const indexCacheRef = useRef(/* @__PURE__ */ new Map());
42
- const searchOptionsRef = useRef(searchOptions);
63
+ const miniSearchOptionsRef = useRef(miniSearchOptions);
43
64
  /** Whether the dialog has ever been open. See the focus effect below. */
44
65
  const hasOpenedRef = useRef(false);
45
66
  const baseId = useId();
46
67
  const listId = `${baseId}-results`;
47
68
  const optionId = (index) => `${baseId}-option-${index}`;
48
69
  useEffect(() => {
49
- searchOptionsRef.current = searchOptions;
50
- }, [searchOptions]);
70
+ miniSearchOptionsRef.current = miniSearchOptions;
71
+ }, [miniSearchOptions]);
51
72
  /** Load each URL at most once; a failure evicts that key so a retry can. */
52
73
  const ensureIndex = useCallback(() => {
53
74
  const cache = indexCacheRef.current;
54
75
  let pending = cache.get(indexUrl);
55
76
  if (pending === void 0) {
56
- pending = loadIndex(indexUrl, searchOptionsRef.current).catch((error) => {
77
+ pending = loadIndex(indexUrl, miniSearchOptionsRef.current).catch((error) => {
57
78
  cache.delete(indexUrl);
58
79
  throw error;
59
80
  });
@@ -135,9 +156,10 @@ function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", place
135
156
  }, [isOpen]);
136
157
  useEffect(() => {
137
158
  const trimmed = query.trim();
138
- if (trimmed === "") {
159
+ if (trimmed.length < minQueryLength) {
139
160
  setHits([]);
140
161
  setActiveIndex(0);
162
+ setVisibleCount(pageSize);
141
163
  return;
142
164
  }
143
165
  let isCancelled = false;
@@ -145,8 +167,9 @@ function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", place
145
167
  ensureIndex().then((index) => {
146
168
  if (isCancelled) return;
147
169
  setStatus("ready");
148
- setHits(index.search(trimmed).slice(0, maxResults).map(toSearchHit).filter(isSearchHit));
170
+ setHits(index.search(trimmed).map(toSearchHit).filter(isSearchHit));
149
171
  setActiveIndex(0);
172
+ setVisibleCount(pageSize);
150
173
  }, () => {
151
174
  if (!isCancelled) setStatus("error");
152
175
  });
@@ -158,11 +181,55 @@ function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", place
158
181
  }, [
159
182
  query,
160
183
  ensureIndex,
161
- maxResults,
162
- debounceMs
184
+ pageSize,
185
+ debounceMs,
186
+ minQueryLength
163
187
  ]);
188
+ /**
189
+ * Reveal another page when the reader nears the end of the list.
190
+ *
191
+ * A scroll handler rather than an `IntersectionObserver` on a sentinel: the
192
+ * scrollport is one element this component already holds a ref to, the test
193
+ * is one subtraction, and an observer would cost bytes on the largest client
194
+ * entry this package ships for no behaviour the reader can tell apart.
195
+ *
196
+ * `passive`, because this never calls `preventDefault` and a non-passive
197
+ * scroll listener blocks the compositor on every wheel event.
198
+ *
199
+ * No `isOpen` dependency, though the list does not exist while the dialog is
200
+ * closed: the effect returns early on a null ref, and `hits.length` going
201
+ * from 0 to N re-runs it — which happens after the list has mounted, because
202
+ * closing resets the query. The listener attaches exactly when there is
203
+ * something to scroll.
204
+ */
205
+ useEffect(() => {
206
+ const list = listRef.current;
207
+ if (list === null || visibleCount >= hits.length) return;
208
+ const onScroll = () => {
209
+ if (list.scrollHeight - list.scrollTop - list.clientHeight < list.clientHeight) setVisibleCount((count) => Math.min(count + pageSize, hits.length));
210
+ };
211
+ list.addEventListener("scroll", onScroll, { passive: true });
212
+ return () => list.removeEventListener("scroll", onScroll);
213
+ }, [
214
+ visibleCount,
215
+ hits.length,
216
+ pageSize
217
+ ]);
218
+ useEffect(() => {
219
+ if (activeIndex >= visibleCount) setVisibleCount(Math.min(activeIndex + 1, hits.length));
220
+ }, [
221
+ activeIndex,
222
+ visibleCount,
223
+ hits.length
224
+ ]);
225
+ useEffect(() => {
226
+ const list = listRef.current;
227
+ if (list !== null && list.scrollTop !== 0) list.scrollTop = 0;
228
+ }, [hits]);
164
229
  useEffect(() => {
165
230
  if (hits.length === 0) return;
231
+ if (!movedByKeyboard.current) return;
232
+ movedByKeyboard.current = false;
166
233
  (listRef.current?.querySelector(`#${CSS.escape(`${baseId}-option-${activeIndex}`)}`))?.scrollIntoView({ block: "nearest" });
167
234
  }, [
168
235
  activeIndex,
@@ -179,6 +246,7 @@ function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", place
179
246
  if (hits.length === 0) return;
180
247
  event.preventDefault();
181
248
  const delta = event.key === "ArrowDown" ? 1 : -1;
249
+ movedByKeyboard.current = true;
182
250
  setActiveIndex((index) => (index + delta + hits.length) % hits.length);
183
251
  return;
184
252
  }
@@ -190,7 +258,7 @@ function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", place
190
258
  }
191
259
  }
192
260
  const activeHit = hits[activeIndex];
193
- return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsxs("button", {
261
+ return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsxs("button", {
194
262
  type: "button",
195
263
  ref: triggerRef,
196
264
  className: ["wave-docs-search-trigger", className].filter(Boolean).join(" "),
@@ -250,11 +318,16 @@ function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", place
250
318
  className: "wave-docs-search-results",
251
319
  role: "listbox",
252
320
  "aria-label": dialogLabel,
253
- children: hits.map((hit, index) => /* @__PURE__ */ jsx(SearchResultOption, {
321
+ children: hits.slice(0, visibleCount).map((hit, index) => /* @__PURE__ */ jsx(SearchResultOption, {
254
322
  hit,
255
323
  id: optionId(index),
256
324
  isActive: index === activeIndex,
257
- onActivate: () => setActiveIndex(index),
325
+ setSize: hits.length,
326
+ posInSet: index + 1,
327
+ onActivate: () => {
328
+ movedByKeyboard.current = false;
329
+ setActiveIndex(index);
330
+ },
258
331
  onSelect: selectHit,
259
332
  ...Link === void 0 ? {} : { Link }
260
333
  }, hit.id))
@@ -262,40 +335,36 @@ function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", place
262
335
  /* @__PURE__ */ jsx(SearchStatus, {
263
336
  status,
264
337
  query: query.trim(),
265
- hitCount: hits.length
338
+ hitCount: hits.length,
339
+ minQueryLength
266
340
  })
267
341
  ]
268
342
  })
269
343
  }), document.body) : null] });
270
344
  }
271
345
  /** One result row: a real link, so middle-click and "open in new tab" work. */
272
- function SearchResultOption({ hit, id, isActive, onActivate, onSelect, Link }) {
346
+ function SearchResultOption({ hit, id, isActive, setSize, posInSet, onActivate, onSelect, Link }) {
273
347
  function handleClick(event) {
274
348
  if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
275
349
  event.preventDefault();
276
350
  onSelect(hit);
277
351
  }
278
- const trail = toBreadcrumbs(hit);
279
- const body = /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("span", {
352
+ const body = /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("span", {
280
353
  className: "wave-docs-search-result-heading",
281
354
  children: hit.heading
282
- }), trail.length === 0 ? null : /* @__PURE__ */ jsx("span", {
283
- className: "wave-docs-search-result-breadcrumb",
284
- children: trail.map((crumb, index) => /* @__PURE__ */ jsxs("span", {
285
- className: "wave-docs-search-result-crumb",
286
- children: [index === 0 ? null : /* @__PURE__ */ jsx("span", {
287
- className: "wave-docs-search-result-crumb-separator",
288
- "aria-hidden": "true",
289
- children: "›"
290
- }), crumb.text]
291
- }, crumb.key))
355
+ }), /* @__PURE__ */ jsx("span", {
356
+ className: "wave-docs-search-result-location",
357
+ "aria-hidden": "true",
358
+ children: toDisplayPath(hit.href)
292
359
  })] });
293
360
  return /* @__PURE__ */ jsx("div", {
294
361
  id,
295
362
  className: isActive ? "wave-docs-search-result wave-docs-search-result-active" : "wave-docs-search-result",
296
363
  role: "option",
297
364
  "aria-selected": isActive,
298
- "aria-label": [hit.heading, ...trail.map((crumb) => crumb.text)].join(", "),
365
+ "aria-setsize": setSize,
366
+ "aria-posinset": posInSet,
367
+ "aria-label": spokenName(hit),
299
368
  tabIndex: -1,
300
369
  onPointerMove: onActivate,
301
370
  children: Link === void 0 ? /* @__PURE__ */ jsx("a", {
@@ -314,7 +383,7 @@ function SearchResultOption({ hit, id, isActive, onActivate, onSelect, Link }) {
314
383
  });
315
384
  }
316
385
  /** Loading, failure and empty states, plus a live region for hit counts. */
317
- function SearchStatus({ status, query, hitCount }) {
386
+ function SearchStatus({ status, query, hitCount, minQueryLength }) {
318
387
  let message = null;
319
388
  let modifier = "";
320
389
  if (status === "error") {
@@ -323,6 +392,9 @@ function SearchStatus({ status, query, hitCount }) {
323
392
  } else if (query === "") {
324
393
  message = "Start typing to search the documentation.";
325
394
  modifier = " wave-docs-search-status-hint";
395
+ } else if (query.length < minQueryLength) {
396
+ message = `Keep typing — ${minQueryLength} characters or more.`;
397
+ modifier = " wave-docs-search-status-hint";
326
398
  } else if (status !== "ready") {
327
399
  message = "Loading the search index…";
328
400
  modifier = " wave-docs-search-status-loading";
@@ -330,7 +402,7 @@ function SearchStatus({ status, query, hitCount }) {
330
402
  message = `No results for “${query}”.`;
331
403
  modifier = " wave-docs-search-status-empty";
332
404
  }
333
- return /* @__PURE__ */ jsxs(Fragment, { children: [message === null ? null : /* @__PURE__ */ jsx("p", {
405
+ return /* @__PURE__ */ jsxs(Fragment$1, { children: [message === null ? null : /* @__PURE__ */ jsx("p", {
334
406
  className: `wave-docs-search-status${modifier}`,
335
407
  children: message
336
408
  }), /* @__PURE__ */ jsx("p", {
@@ -348,7 +420,12 @@ function SearchStatus({ status, query, hitCount }) {
348
420
  */
349
421
  async function loadIndex(url, overrides) {
350
422
  const [{ default: MiniSearchClass }, response] = await Promise.all([import("minisearch"), fetch(url)]);
351
- if (!response.ok) throw docsError("search-index-unavailable", `Failed to load the search index from ${url} (HTTP ${response.status}).`);
423
+ if (!response.ok) throw docsError("search-index-unavailable", response.status === 404 ? `No search index at ${url}. Create the route that serves it:\n\n // app${url}/route.ts — the whole file\n import { docs } from '@/lib/docs';
424
+
425
+ export const GET = docs.searchIndex;
426
+ export const dynamic = 'force-static';
427
+
428
+ Or pass \`search={false}\` to \`docs.Layout\` to hide the trigger.` : `Failed to load the search index from ${url} (HTTP ${response.status}).`);
352
429
  return MiniSearchClass.loadJSONAsync(await response.text(), mergeSearchOptions(overrides));
353
430
  }
354
431
  /**
@@ -375,24 +452,47 @@ function isSearchHit(hit) {
375
452
  return hit !== void 0;
376
453
  }
377
454
  /**
378
- * Page title first, then the ancestor headings: `ancestors` deliberately excludes
379
- * the page title so the index does not carry it twice.
455
+ * The route, without its anchor, for display only.
380
456
  *
381
- * A page's lead record carries `heading === title` and no ancestors (see
382
- * `extractSearchRecords`), so its trail would be the one string already printed
383
- * above it. "Installation" over "Installation" is not a path, it is a bug that
384
- * reads as a rendering glitch such a hit gets no trail at all.
457
+ * ⚠️ THE ANCHOR IS NOISE HERE, AND ALMOST ALWAYS A REPEAT. A section's anchor
458
+ * is slugged from its heading, so `/docs/styling#layout-tokens` under a row
459
+ * whose first line already reads "Layout tokens" spends its width restating
460
+ * it and on a real site it is the part that pushes the line past the
461
+ * ellipsis.
462
+ *
463
+ * What the line is for is "which page does this land on", and the path answers
464
+ * that on its own. Two rows from the same page showing the same path is not
465
+ * ambiguity: they *are* the same page, and their headings above say which part.
466
+ *
467
+ * Display only. `hit.href` keeps the anchor, so the link still deep-links to
468
+ * the section — that is the whole point of section-scoped records.
385
469
  */
386
- function toBreadcrumbs(hit) {
387
- if (hit.ancestors.length === 0 && hit.heading === hit.title) return [];
388
- let trail = "";
389
- return [hit.title, ...hit.ancestors].map((text) => {
390
- trail = trail === "" ? text : `${trail}/${text}`;
391
- return {
392
- key: trail,
393
- text
394
- };
395
- });
470
+ function toDisplayPath(href) {
471
+ const hash = href.indexOf("#");
472
+ return hash === -1 ? href : href.slice(0, hash);
473
+ }
474
+ /**
475
+ * What a result is called when it is read aloud.
476
+ *
477
+ * Words, not the route the row displays. `/docs/styling#layout-tokens` is
478
+ * punctuation to a screen reader — spelled out slash by slash — so the visible
479
+ * line and the announced name deliberately carry the same fact in two forms:
480
+ * the route for a sighted reader scanning for where a hit lands, and
481
+ * "Layout tokens, Styling" for a listener.
482
+ *
483
+ * `ancestors` deliberately excludes the page title, so the page comes first
484
+ * here and the enclosing headings follow, outermost first.
485
+ *
486
+ * A page's own record carries `heading === title` and no ancestors, so its name
487
+ * is the heading alone — "Styling, Styling" is not a path, it is a stutter.
488
+ */
489
+ function spokenName(hit) {
490
+ if (hit.ancestors.length === 0 && hit.heading === hit.title) return hit.heading;
491
+ return [
492
+ hit.heading,
493
+ hit.title,
494
+ ...hit.ancestors
495
+ ].join(", ");
396
496
  }
397
497
  function trapFocus(root, event) {
398
498
  if (root === null) return;
@@ -0,0 +1,43 @@
1
+ //#region src/react/shell-labels.d.ts
2
+ /**
3
+ * Every user-visible string the shell renders that is not the reader's content.
4
+ *
5
+ * ⚠️ THERE ARE ONLY FIVE, AND THAT IS THE POINT. `docs.Layout` renders the
6
+ * whole chrome of a documentation site, and until this existed all five were
7
+ * hardcoded English with no way to reach them: `DocsNav` declared `label` and
8
+ * `closeLabel` props, documented them, defaulted them — and the layout that is
9
+ * the only thing rendering `DocsNav` never passed either, while `DocsLayoutProps`
10
+ * had no way to say them. Dead options that read as configuration.
11
+ *
12
+ * Private module, public type: `next.ts` re-exports {@link DocsLabels} as part
13
+ * of `DocsLayoutProps`, and this file exists so the Node adapter can name the
14
+ * type without importing a `'use client'` module for it.
15
+ *
16
+ * Not here: the search dialog's strings, which are reachable through
17
+ * `search={{ … }}`; and the sidebar's own `label`, which is public API on
18
+ * `DocsSidebar` for anyone composing a shell by hand. This is the set that had
19
+ * no route at all.
20
+ */
21
+ interface DocsLabels {
22
+ /** The navigation landmark's accessible name. Default `'Documentation'`. */
23
+ nav?: string | undefined;
24
+ /** The header button that opens the drawer. Default `'Open navigation'`. */
25
+ openNav?: string | undefined;
26
+ /** The button that closes the drawer. Default `'Close navigation'`. */
27
+ closeNav?: string | undefined;
28
+ /** The skip link's visible text. Default `'Skip to content'`. */
29
+ skipToContent?: string | undefined;
30
+ }
31
+ /**
32
+ * The defaults, in one place.
33
+ *
34
+ * A `Required<DocsLabels>` rather than four `=` defaults spread across three
35
+ * components: the previous arrangement is how `DocsSidebar` came to default its
36
+ * landmark to `'Docs'` while `DocsNav` defaulted the same landmark to
37
+ * `'Documentation'` — two names for one region, depending on the viewport.
38
+ */
39
+ declare const DEFAULT_DOCS_LABELS: Required<DocsLabels>;
40
+ /** The given labels over the defaults, with `undefined` treated as unset. */
41
+ declare function resolveLabels(labels: DocsLabels | undefined): Required<DocsLabels>;
42
+ //#endregion
43
+ export { DEFAULT_DOCS_LABELS, DocsLabels, resolveLabels };
@@ -0,0 +1,27 @@
1
+ //#region src/react/shell-labels.ts
2
+ /**
3
+ * The defaults, in one place.
4
+ *
5
+ * A `Required<DocsLabels>` rather than four `=` defaults spread across three
6
+ * components: the previous arrangement is how `DocsSidebar` came to default its
7
+ * landmark to `'Docs'` while `DocsNav` defaulted the same landmark to
8
+ * `'Documentation'` — two names for one region, depending on the viewport.
9
+ */
10
+ const DEFAULT_DOCS_LABELS = {
11
+ nav: "Documentation",
12
+ openNav: "Open navigation",
13
+ closeNav: "Close navigation",
14
+ skipToContent: "Skip to content"
15
+ };
16
+ /** The given labels over the defaults, with `undefined` treated as unset. */
17
+ function resolveLabels(labels) {
18
+ if (labels === void 0) return DEFAULT_DOCS_LABELS;
19
+ return {
20
+ nav: labels.nav ?? DEFAULT_DOCS_LABELS.nav,
21
+ openNav: labels.openNav ?? DEFAULT_DOCS_LABELS.openNav,
22
+ closeNav: labels.closeNav ?? DEFAULT_DOCS_LABELS.closeNav,
23
+ skipToContent: labels.skipToContent ?? DEFAULT_DOCS_LABELS.skipToContent
24
+ };
25
+ }
26
+ //#endregion
27
+ export { DEFAULT_DOCS_LABELS, resolveLabels };
@@ -20,9 +20,44 @@ interface DocsSidebarProps {
20
20
  /**
21
21
  * The docs navigation tree.
22
22
  *
23
- * Prefetch is off by default on the injected link. A full-tree sidebar on a
24
- * few hundred pages otherwise asks Next to prefetch every route in it —
25
- * ~1.8 KB brotli each, all of it wasted on the routes nobody clicks.
23
+ * ## Prefetch
24
+ *
25
+ * Nearby links prefetch; the rest do not. Nearby means the list that directly
26
+ * contains the current page, plus the heading link of the group the reader is
27
+ * inside — 5 to 15 warm links on a real sidebar rather than 400 or none.
28
+ *
29
+ * ⚠️ THE PREVIOUS NOTE HERE WAS WRONG, AND THE RETRACTION IS THE POINT. It
30
+ * said prefetch was off because a full-tree sidebar otherwise asks Next to
31
+ * prefetch every route in it, ~1.8 KB brotli each. That reasoning is correct
32
+ * for the Pages Router and wrong for the App Router:
33
+ * `next/dist/client/app-dir/link.js` computes
34
+ * `const prefetchEnabled = prefetchProp !== false`, and BOTH the hover path
35
+ * and the touch path bail on it, while the IntersectionObserver is only
36
+ * registered when it is true. So `prefetch={false}` did not trade viewport
37
+ * prefetching for hover prefetching — it turned off both, and made every
38
+ * navigation from the most-clicked control in a docs site a cold RSC
39
+ * round-trip.
40
+ *
41
+ * ## The keyboard model, and why there is no `role="tree"`
42
+ *
43
+ * This is the **APG Disclosure Navigation** pattern: a list of links, with a
44
+ * button per collapsible group. Every link is an ordinary tab stop, Enter
45
+ * follows it, and the browser does all of it. There is no `tabindex`
46
+ * anywhere in here and no roving focus, deliberately.
47
+ *
48
+ * `role="tree"` is the tempting alternative and it is refused. It removes
49
+ * every link from the tab order in favour of a single roving tabstop, so a
50
+ * reader who tabs into the navigation can no longer tab through it; and it
51
+ * makes a screen reader announce "tree item, level 3" for what is, in every
52
+ * way that matters to the person hearing it, a link to a page. A docs sidebar
53
+ * is not a file explorer. `sidebar.test.tsx` asserts the absence of both, so
54
+ * the decision survives someone reaching for the aria pattern that sounds
55
+ * closest to "collapsible tree".
56
+ *
57
+ * ⚠️ AND IT IS UNOBSERVABLE IN `next dev`. The same file guards the hover path
58
+ * with `if (!prefetchEnabled || process.env.NODE_ENV === 'development')`, so
59
+ * nothing prefetches locally whatever this says. Do not "fix" it back because
60
+ * the network tab looks the same.
26
61
  */
27
62
  declare function DocsSidebar({ nav, pathname, Link, label, className }: DocsSidebarProps): ReactNode;
28
63
  //#endregion