@waveso/docs 0.2.0 → 0.3.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 +110 -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 +20 -8
  36. package/dist/react/search-dialog.js +15 -10
  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 +939 -93
  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 };
@@ -35,15 +35,27 @@ interface SearchDialogProps {
35
35
  /**
36
36
  * Overrides applied through `mergeSearchOptions` when the index is
37
37
  * 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.
38
+ * query defaults, without waiting on a release of this package.
40
39
  *
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.
40
+ * MiniSearch's own name for the query defaults is `searchOptions`, so they
41
+ * nest one level down:
42
+ *
43
+ * ```tsx
44
+ * <DocsSearch miniSearchOptions={{ searchOptions: { fuzzy: 0.1 } }} />
45
+ * ```
46
+ *
47
+ * That stutter is why this prop is not called `searchOptions` too. It was,
48
+ * and `searchOptions={{ fuzzy: 0.1 }}` reads so naturally that both README
49
+ * examples were written that way — neither compiled, and the flat form is
50
+ * not a runtime error either. It is a `fuzzy` MiniSearch never reads.
51
+ *
52
+ * ⚠️ HAND THE IDENTICAL OVERRIDES TO THE BUILD — `createDocsRoute`'s
53
+ * `miniSearchOptions`, or `buildSearchIndex`'s second argument. `tokenize`
54
+ * and `processTerm` decide how terms were written into the index; a client
55
+ * that splits differently from the build looks up terms that were never
56
+ * written and finds nothing, silently.
45
57
  */
46
- searchOptions?: Partial<Options<SearchRecord>> | undefined;
58
+ miniSearchOptions?: Partial<Options<SearchRecord>> | undefined;
47
59
  }
48
60
  /**
49
61
  * Search trigger plus its dialog.
@@ -52,6 +64,6 @@ interface SearchDialogProps {
52
64
  * portalled to `document.body`, so a navbar's stacking context cannot trap
53
65
  * it behind the page.
54
66
  */
55
- declare function SearchDialog({ indexUrl, navigate, Link, triggerLabel, placeholder, dialogLabel, maxResults, debounceMs, className, searchOptions }: SearchDialogProps): ReactNode;
67
+ declare function SearchDialog({ indexUrl, navigate, Link, triggerLabel, placeholder, dialogLabel, maxResults, debounceMs, className, miniSearchOptions }: SearchDialogProps): ReactNode;
56
68
  //#endregion
57
69
  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,7 +26,7 @@ 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", maxResults = 8, debounceMs = 120, className, miniSearchOptions }) {
30
30
  const [isOpen, setIsOpen] = useState(false);
31
31
  const [query, setQuery] = useState("");
32
32
  const [hits, setHits] = useState([]);
@@ -39,21 +39,21 @@ function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", place
39
39
  const listRef = useRef(null);
40
40
  const returnFocusRef = useRef(null);
41
41
  const indexCacheRef = useRef(/* @__PURE__ */ new Map());
42
- const searchOptionsRef = useRef(searchOptions);
42
+ const miniSearchOptionsRef = useRef(miniSearchOptions);
43
43
  /** Whether the dialog has ever been open. See the focus effect below. */
44
44
  const hasOpenedRef = useRef(false);
45
45
  const baseId = useId();
46
46
  const listId = `${baseId}-results`;
47
47
  const optionId = (index) => `${baseId}-option-${index}`;
48
48
  useEffect(() => {
49
- searchOptionsRef.current = searchOptions;
50
- }, [searchOptions]);
49
+ miniSearchOptionsRef.current = miniSearchOptions;
50
+ }, [miniSearchOptions]);
51
51
  /** Load each URL at most once; a failure evicts that key so a retry can. */
52
52
  const ensureIndex = useCallback(() => {
53
53
  const cache = indexCacheRef.current;
54
54
  let pending = cache.get(indexUrl);
55
55
  if (pending === void 0) {
56
- pending = loadIndex(indexUrl, searchOptionsRef.current).catch((error) => {
56
+ pending = loadIndex(indexUrl, miniSearchOptionsRef.current).catch((error) => {
57
57
  cache.delete(indexUrl);
58
58
  throw error;
59
59
  });
@@ -190,7 +190,7 @@ function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", place
190
190
  }
191
191
  }
192
192
  const activeHit = hits[activeIndex];
193
- return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsxs("button", {
193
+ return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsxs("button", {
194
194
  type: "button",
195
195
  ref: triggerRef,
196
196
  className: ["wave-docs-search-trigger", className].filter(Boolean).join(" "),
@@ -276,7 +276,7 @@ function SearchResultOption({ hit, id, isActive, onActivate, onSelect, Link }) {
276
276
  onSelect(hit);
277
277
  }
278
278
  const trail = toBreadcrumbs(hit);
279
- const body = /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("span", {
279
+ const body = /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("span", {
280
280
  className: "wave-docs-search-result-heading",
281
281
  children: hit.heading
282
282
  }), trail.length === 0 ? null : /* @__PURE__ */ jsx("span", {
@@ -330,7 +330,7 @@ function SearchStatus({ status, query, hitCount }) {
330
330
  message = `No results for “${query}”.`;
331
331
  modifier = " wave-docs-search-status-empty";
332
332
  }
333
- return /* @__PURE__ */ jsxs(Fragment, { children: [message === null ? null : /* @__PURE__ */ jsx("p", {
333
+ return /* @__PURE__ */ jsxs(Fragment$1, { children: [message === null ? null : /* @__PURE__ */ jsx("p", {
334
334
  className: `wave-docs-search-status${modifier}`,
335
335
  children: message
336
336
  }), /* @__PURE__ */ jsx("p", {
@@ -348,7 +348,12 @@ function SearchStatus({ status, query, hitCount }) {
348
348
  */
349
349
  async function loadIndex(url, overrides) {
350
350
  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}).`);
351
+ 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';
352
+
353
+ export const GET = docs.searchIndex;
354
+ export const dynamic = 'force-static';
355
+
356
+ Or pass \`search={false}\` to \`docs.Layout\` to hide the trigger.` : `Failed to load the search index from ${url} (HTTP ${response.status}).`);
352
357
  return MiniSearchClass.loadJSONAsync(await response.text(), mergeSearchOptions(overrides));
353
358
  }
354
359
  /**
@@ -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
@@ -1,6 +1,7 @@
1
1
  "use client";
2
- import { useId, useRef, useState } from "react";
3
- import { Fragment, jsx, jsxs } from "react/jsx-runtime";
2
+ import { nearestScrollTop } from "./nearest-scroll-top.js";
3
+ import { useId, useLayoutEffect, useRef, useState } from "react";
4
+ import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
4
5
  //#region src/react/sidebar.tsx
5
6
  /** Trailing slashes are a routing detail, not a difference in identity. */
6
7
  function normalizeHref(href) {
@@ -21,9 +22,44 @@ function containsActive(node, pathname) {
21
22
  /**
22
23
  * The docs navigation tree.
23
24
  *
24
- * Prefetch is off by default on the injected link. A full-tree sidebar on a
25
- * few hundred pages otherwise asks Next to prefetch every route in it —
26
- * ~1.8 KB brotli each, all of it wasted on the routes nobody clicks.
25
+ * ## Prefetch
26
+ *
27
+ * Nearby links prefetch; the rest do not. Nearby means the list that directly
28
+ * contains the current page, plus the heading link of the group the reader is
29
+ * inside — 5 to 15 warm links on a real sidebar rather than 400 or none.
30
+ *
31
+ * ⚠️ THE PREVIOUS NOTE HERE WAS WRONG, AND THE RETRACTION IS THE POINT. It
32
+ * said prefetch was off because a full-tree sidebar otherwise asks Next to
33
+ * prefetch every route in it, ~1.8 KB brotli each. That reasoning is correct
34
+ * for the Pages Router and wrong for the App Router:
35
+ * `next/dist/client/app-dir/link.js` computes
36
+ * `const prefetchEnabled = prefetchProp !== false`, and BOTH the hover path
37
+ * and the touch path bail on it, while the IntersectionObserver is only
38
+ * registered when it is true. So `prefetch={false}` did not trade viewport
39
+ * prefetching for hover prefetching — it turned off both, and made every
40
+ * navigation from the most-clicked control in a docs site a cold RSC
41
+ * round-trip.
42
+ *
43
+ * ## The keyboard model, and why there is no `role="tree"`
44
+ *
45
+ * This is the **APG Disclosure Navigation** pattern: a list of links, with a
46
+ * button per collapsible group. Every link is an ordinary tab stop, Enter
47
+ * follows it, and the browser does all of it. There is no `tabindex`
48
+ * anywhere in here and no roving focus, deliberately.
49
+ *
50
+ * `role="tree"` is the tempting alternative and it is refused. It removes
51
+ * every link from the tab order in favour of a single roving tabstop, so a
52
+ * reader who tabs into the navigation can no longer tab through it; and it
53
+ * makes a screen reader announce "tree item, level 3" for what is, in every
54
+ * way that matters to the person hearing it, a link to a page. A docs sidebar
55
+ * is not a file explorer. `sidebar.test.tsx` asserts the absence of both, so
56
+ * the decision survives someone reaching for the aria pattern that sounds
57
+ * closest to "collapsible tree".
58
+ *
59
+ * ⚠️ AND IT IS UNOBSERVABLE IN `next dev`. The same file guards the hover path
60
+ * with `if (!prefetchEnabled || process.env.NODE_ENV === 'development')`, so
61
+ * nothing prefetches locally whatever this says. Do not "fix" it back because
62
+ * the network tab looks the same.
27
63
  */
28
64
  function DocsSidebar({ nav, pathname, Link, label = "Docs", className }) {
29
65
  const baseId = useId();
@@ -39,7 +75,23 @@ function DocsSidebar({ nav, pathname, Link, label = "Docs", className }) {
39
75
  [key]: isOpen
40
76
  }));
41
77
  };
78
+ const navRef = useRef(null);
79
+ useLayoutEffect(() => {
80
+ const active = navRef.current?.querySelector("[aria-current=\"page\"]");
81
+ if (!(active instanceof HTMLElement)) return;
82
+ const port = scrollableAncestor(active);
83
+ if (port === null) return;
84
+ const next = nearestScrollTop({
85
+ itemTop: active.getBoundingClientRect().top - port.getBoundingClientRect().top + port.scrollTop,
86
+ itemHeight: active.offsetHeight,
87
+ viewHeight: port.clientHeight,
88
+ scrollTop: port.scrollTop,
89
+ scrollHeight: port.scrollHeight
90
+ });
91
+ if (next !== void 0) port.scrollTop = next;
92
+ }, [pathname]);
42
93
  return /* @__PURE__ */ jsx("nav", {
94
+ ref: navRef,
43
95
  "aria-label": label,
44
96
  className: ["wave-docs-sidebar", className].filter(Boolean).join(" "),
45
97
  children: /* @__PURE__ */ jsx(NavList, {
@@ -53,7 +105,27 @@ function DocsSidebar({ nav, pathname, Link, label = "Docs", className }) {
53
105
  })
54
106
  });
55
107
  }
108
+ /**
109
+ * The nearest ancestor that actually scrolls, or `null`.
110
+ *
111
+ * ⚠️ THIS EXISTS SO `scrollIntoView` DOES NOT HAVE TO. `scrollIntoView({ block:
112
+ * 'nearest' })` reads as exactly the right call and scrolls **every**
113
+ * scrollable ancestor including the document — so on a docs page it brings the
114
+ * sidebar item into view and jumps the article the reader came to read, on the
115
+ * one navigation where they know precisely what they asked for.
116
+ * `sidebar.test.tsx` spies on it and asserts it is never called.
117
+ */
118
+ function scrollableAncestor(element) {
119
+ let current = element.parentElement;
120
+ while (current !== null) {
121
+ const overflow = getComputedStyle(current).overflowY;
122
+ if ((overflow === "auto" || overflow === "scroll") && current.scrollHeight > current.clientHeight) return current;
123
+ current = current.parentElement;
124
+ }
125
+ return null;
126
+ }
56
127
  function NavList({ nodes, depth, keyPrefix, pathname, Link, toggled, onToggle, id }) {
128
+ const holdsActive = nodes.some((node) => (node.type === "page" || node.type === "link" && !node.external) && isActiveHref(pathname, node.href));
57
129
  return /* @__PURE__ */ jsx("ul", {
58
130
  id,
59
131
  className: "wave-docs-sidebar__list",
@@ -74,6 +146,7 @@ function NavList({ nodes, depth, keyPrefix, pathname, Link, toggled, onToggle, i
74
146
  href: node.href,
75
147
  isExternal: node.external,
76
148
  isActive: !node.external && isActiveHref(pathname, node.href),
149
+ isNearby: holdsActive,
77
150
  Link,
78
151
  children: node.title
79
152
  })
@@ -84,6 +157,7 @@ function NavList({ nodes, depth, keyPrefix, pathname, Link, toggled, onToggle, i
84
157
  href: node.href,
85
158
  isExternal: false,
86
159
  isActive: isActiveHref(pathname, node.href),
160
+ isNearby: holdsActive,
87
161
  Link,
88
162
  children: node.title
89
163
  })
@@ -122,10 +196,11 @@ function NavGroup({ node, itemKey, depth, pathname, Link, toggled, onToggle }) {
122
196
  className: "wave-docs-sidebar__group-title",
123
197
  children: node.title
124
198
  }), /* @__PURE__ */ jsx(Chevron, { isOpen })]
125
- }) : /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(NavLink, {
199
+ }) : /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(NavLink, {
126
200
  href: node.href,
127
201
  isExternal: false,
128
202
  isActive: isGroupActive,
203
+ isNearby: hasActive,
129
204
  Link,
130
205
  children: node.title
131
206
  }), /* @__PURE__ */ jsx("button", {
@@ -149,17 +224,34 @@ function NavGroup({ node, itemKey, depth, pathname, Link, toggled, onToggle }) {
149
224
  }) : null]
150
225
  });
151
226
  }
152
- function NavLink({ href, isExternal, isActive, Link, children }) {
227
+ function NavLink({ href, isExternal, isActive, isNearby = false, Link, children }) {
153
228
  const className = "wave-docs-sidebar__link";
154
229
  if (isExternal) return /* @__PURE__ */ jsxs("a", {
155
230
  className,
156
231
  href,
157
232
  target: "_blank",
158
233
  rel: "noopener noreferrer",
159
- children: [children, /* @__PURE__ */ jsx("span", {
160
- className: "wave-docs-sr-only",
161
- children: " (opens in a new tab)"
162
- })]
234
+ children: [
235
+ children,
236
+ /* @__PURE__ */ jsx("svg", {
237
+ className: "wave-docs-sidebar__external",
238
+ "aria-hidden": "true",
239
+ focusable: "false",
240
+ viewBox: "0 0 24 24",
241
+ width: "12",
242
+ height: "12",
243
+ fill: "none",
244
+ stroke: "currentColor",
245
+ strokeWidth: "2",
246
+ strokeLinecap: "round",
247
+ strokeLinejoin: "round",
248
+ children: /* @__PURE__ */ jsx("path", { d: "M14 4h6v6M20 4l-8 8M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5" })
249
+ }),
250
+ /* @__PURE__ */ jsx("span", {
251
+ className: "wave-docs-sr-only",
252
+ children: " (opens in a new tab)"
253
+ })
254
+ ]
163
255
  });
164
256
  if (Link === void 0) return /* @__PURE__ */ jsx("a", {
165
257
  className,
@@ -170,7 +262,7 @@ function NavLink({ href, isExternal, isActive, Link, children }) {
170
262
  return /* @__PURE__ */ jsx(Link, {
171
263
  className,
172
264
  href,
173
- prefetch: false,
265
+ prefetch: isNearby ? void 0 : false,
174
266
  "aria-current": isActive ? "page" : void 0,
175
267
  children
176
268
  });
@@ -1,14 +1,6 @@
1
+ import { DOCS_CONTENT_ID } from "../docs-content-id.js";
1
2
  import { ReactNode } from "react";
2
3
  //#region src/react/skip-link.d.ts
3
- /**
4
- * The `id` this link targets, and the one `createDocsRoute` puts on its
5
- * `<article>`.
6
- *
7
- * One constant for both halves: the two files spelled the string independently,
8
- * and a skip link pointing at an id nothing carries scrolls nowhere and focuses
9
- * nothing — a failure with no symptom until a keyboard user hits it.
10
- */
11
- declare const DOCS_CONTENT_ID = "docs-content";
12
4
  interface SkipLinkProps {
13
5
  /** Fragment id of the main content region. */
14
6
  href?: string | undefined;
@@ -1,14 +1,15 @@
1
+ import { DOCS_CONTENT_ID } from "../docs-content-id.js";
1
2
  import { jsx } from "react/jsx-runtime";
2
3
  //#region src/react/skip-link.tsx
3
4
  /**
4
5
  * The `id` this link targets, and the one `createDocsRoute` puts on its
5
- * `<article>`.
6
+ * `<main>`. Defined in a private module so the two halves cannot spell it
7
+ * differently; re-exported here because this is the documented import path.
6
8
  *
7
- * One constant for both halves: the two files spelled the string independently,
8
- * and a skip link pointing at an id nothing carries scrolls nowhere and focuses
9
- * nothing a failure with no symptom until a keyboard user hits it.
9
+ * (This comment used to call the file a `'use client'` module. It is not one
10
+ * and never was `SkipLink` is an anchor with no state, so it is a Server
11
+ * Component like everything else here that does not need a browser.)
10
12
  */
11
- const DOCS_CONTENT_ID = "docs-content";
12
13
  /**
13
14
  * Skip-to-content link: invisible until focused, first in the tab order.
14
15
  *
@@ -16,6 +16,8 @@ interface DocsTocProps {
16
16
  * other unit, `rem` included.
17
17
  */
18
18
  rootMargin?: string | undefined;
19
+ /** Text for the back-to-top link. */
20
+ topLabel?: string | undefined;
19
21
  className?: string | undefined;
20
22
  }
21
23
  /**
@@ -26,10 +28,16 @@ interface DocsTocProps {
26
28
  * out of sync on duplicate headings.
27
29
  *
28
30
  * Scrolling itself is left to the browser: the links are real anchors, and
29
- * smooth scrolling is applied in CSS under
30
- * `@media (prefers-reduced-motion: no-preference)`. Doing it in JavaScript
31
- * means reimplementing that check, and getting it wrong makes people ill.
31
+ * nothing here calls `scrollTo`. Doing it in JavaScript means reimplementing
32
+ * the `prefers-reduced-motion` check, and getting that wrong makes people ill.
33
+ *
34
+ * ⚠️ AND THE STYLESHEET SETS NO `scroll-behavior: smooth` EITHER, deliberately
35
+ * — this comment used to say it did. Next 16 suppresses smooth scrolling
36
+ * across a route change only when `<html>` carries
37
+ * `data-scroll-behavior="smooth"`, an attribute only the host can set, so a
38
+ * package-level rule would smooth-scroll every navigation and no reader could
39
+ * turn it off. `styles.css` says the same at greater length.
32
40
  */
33
- declare function DocsToc({ entries, label, rootMargin, className }: DocsTocProps): ReactNode;
41
+ declare function DocsToc({ entries, label, rootMargin, className, topLabel }: DocsTocProps): ReactNode;
34
42
  //#endregion
35
43
  export { DocsToc, DocsTocProps };