@waveso/docs 0.1.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 (79) hide show
  1. package/CHANGELOG.md +194 -0
  2. package/README.md +592 -88
  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 +19 -0
  10. package/dist/docs-error.js +28 -0
  11. package/dist/errors.d.ts +94 -0
  12. package/dist/errors.js +45 -0
  13. package/dist/frontmatter.d.ts +39 -7
  14. package/dist/frontmatter.js +51 -24
  15. package/dist/highlighter.d.ts +2 -2
  16. package/dist/highlighter.js +3 -2
  17. package/dist/map-pooled.d.ts +26 -0
  18. package/dist/map-pooled.js +45 -0
  19. package/dist/meta.d.ts +7 -3
  20. package/dist/meta.js +61 -15
  21. package/dist/next.d.ts +182 -35
  22. package/dist/next.js +177 -49
  23. package/dist/plugins/rehype-capture-toc.js +52 -20
  24. package/dist/plugins/rehype-code-frame.d.ts +10 -0
  25. package/dist/plugins/rehype-code-frame.js +88 -0
  26. package/dist/plugins/rehype-code-language.d.ts +24 -0
  27. package/dist/plugins/rehype-code-language.js +54 -0
  28. package/dist/plugins/rehype-fallback-heading-ids.d.ts +6 -0
  29. package/dist/plugins/rehype-fallback-heading-ids.js +51 -0
  30. package/dist/plugins/rehype-flatten-roots.d.ts +7 -0
  31. package/dist/plugins/rehype-flatten-roots.js +39 -0
  32. package/dist/plugins/remark-doc-links.d.ts +12 -1
  33. package/dist/plugins/remark-doc-links.js +147 -20
  34. package/dist/react/code-runtime.d.ts +14 -0
  35. package/dist/react/code-runtime.js +161 -0
  36. package/dist/react/doc-content.d.ts +39 -2
  37. package/dist/react/doc-content.js +42 -10
  38. package/dist/react/layout.d.ts +44 -0
  39. package/dist/react/layout.js +65 -0
  40. package/dist/react/markdown-components.js +71 -6
  41. package/dist/react/nav.d.ts +28 -0
  42. package/dist/react/nav.js +70 -0
  43. package/dist/react/nearest-scroll-top.d.ts +45 -0
  44. package/dist/react/nearest-scroll-top.js +44 -0
  45. package/dist/react/next-link.d.ts +34 -0
  46. package/dist/react/next-link.js +30 -0
  47. package/dist/react/next-nav.d.ts +11 -0
  48. package/dist/react/next-nav.js +32 -0
  49. package/dist/react/next-search.d.ts +22 -0
  50. package/dist/react/next-search.js +52 -0
  51. package/dist/react/search-dialog.d.ts +35 -7
  52. package/dist/react/search-dialog.js +55 -33
  53. package/dist/react/shell-labels.d.ts +43 -0
  54. package/dist/react/shell-labels.js +27 -0
  55. package/dist/react/sidebar.d.ts +38 -3
  56. package/dist/react/sidebar.js +104 -12
  57. package/dist/react/skip-link.d.ts +1 -9
  58. package/dist/react/skip-link.js +6 -5
  59. package/dist/react/toc.d.ts +12 -4
  60. package/dist/react/toc.js +46 -12
  61. package/dist/react/youtube.d.ts +31 -5
  62. package/dist/react/youtube.js +76 -52
  63. package/dist/render.d.ts +78 -10
  64. package/dist/render.js +137 -54
  65. package/dist/route-path.d.ts +46 -0
  66. package/dist/route-path.js +51 -0
  67. package/dist/search-index.d.ts +22 -21
  68. package/dist/search-index.js +27 -78
  69. package/dist/search-options.d.ts +32 -1
  70. package/dist/search-options.js +66 -3
  71. package/dist/section-boundary.d.ts +17 -0
  72. package/dist/section-boundary.js +43 -0
  73. package/dist/sitemap-limit.d.ts +34 -0
  74. package/dist/sitemap-limit.js +37 -0
  75. package/dist/source.d.ts +12 -22
  76. package/dist/source.js +165 -72
  77. package/dist/styles.css +1117 -125
  78. package/dist/types.d.ts +52 -29
  79. package/package.json +70 -34
@@ -0,0 +1,44 @@
1
+ import { DocNavNode } from "../types.js";
2
+ import { DocsSearchProps } from "./next-search.js";
3
+ import { DocsLabels } from "./shell-labels.js";
4
+ import { ReactNode } from "react";
5
+ //#region src/react/layout.d.ts
6
+ /**
7
+ * What a host may say about the search trigger, minus the URL.
8
+ *
9
+ * `indexUrl` is derived from `basePath` and is not negotiable here: the whole
10
+ * reason `docs.Layout` exists is that nobody should have to know the index's
11
+ * address, and a hand-passed one is wrong under every non-root `basePath`.
12
+ */
13
+ type DocsLayoutSearchProps = Omit<DocsSearchProps, 'indexUrl'>;
14
+ interface DocsLayoutShellProps {
15
+ children: ReactNode;
16
+ nav: DocNavNode[];
17
+ searchIndexUrl: string;
18
+ title?: ReactNode;
19
+ actions?: ReactNode;
20
+ /**
21
+ * `false` to omit the trigger; an object to configure it.
22
+ *
23
+ * ⚠️ AN OBJECT IS WHAT MAKES `miniSearchOptions` REACHABLE. MiniSearch reads
24
+ * `tokenize` and `processTerm` both when indexing and when querying, so the
25
+ * object `createDocsRoute` built the index with has to be the object the
26
+ * dialog queries it with — and while this was a bare boolean there was no
27
+ * channel for it at all. Configuring the route and rendering `docs.Layout`
28
+ * produced an index whose terms no query could spell: zero results, no error,
29
+ * nothing in the console, and the option's own docstring warning about
30
+ * exactly that.
31
+ */
32
+ search?: boolean | DocsLayoutSearchProps | undefined;
33
+ /**
34
+ * The four strings the chrome renders, for a site that is not in English.
35
+ *
36
+ * `DocsNav` declared `label` and `closeLabel`, documented them and defaulted
37
+ * them — and this component, the only thing that renders `DocsNav`, never
38
+ * passed either. Configuration that could not be configured.
39
+ */
40
+ labels?: DocsLabels | undefined;
41
+ }
42
+ declare function DocsLayoutShell({ children, nav, searchIndexUrl, title, actions, search, labels }: DocsLayoutShellProps): ReactNode;
43
+ //#endregion
44
+ export { DocsLayoutSearchProps, DocsLayoutShell, DocsLayoutShellProps };
@@ -0,0 +1,65 @@
1
+ import { DocsSearch } from "./next-search.js";
2
+ import { resolveLabels } from "./shell-labels.js";
3
+ import { DOCS_NAV_ID } from "./nav.js";
4
+ import { DocsNextNav } from "./next-nav.js";
5
+ import { SkipLink } from "./skip-link.js";
6
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
7
+ //#region src/react/layout.tsx
8
+ function DocsLayoutShell({ children, nav, searchIndexUrl, title, actions, search = true, labels }) {
9
+ const text = resolveLabels(labels);
10
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
11
+ /* @__PURE__ */ jsx(SkipLink, { children: text.skipToContent }),
12
+ /* @__PURE__ */ jsx("header", {
13
+ className: "wave-docs-layout__header",
14
+ children: /* @__PURE__ */ jsxs("div", {
15
+ className: "wave-docs-layout__header-inner",
16
+ children: [
17
+ /* @__PURE__ */ jsx("button", {
18
+ type: "button",
19
+ className: "wave-docs-layout__nav-trigger",
20
+ "aria-label": text.openNav,
21
+ command: "show-modal",
22
+ commandfor: DOCS_NAV_ID,
23
+ children: /* @__PURE__ */ jsx("svg", {
24
+ "aria-hidden": "true",
25
+ viewBox: "0 0 16 16",
26
+ width: "18",
27
+ height: "18",
28
+ fill: "none",
29
+ stroke: "currentColor",
30
+ strokeWidth: "1.5",
31
+ strokeLinecap: "round",
32
+ children: /* @__PURE__ */ jsx("path", { d: "M2.5 4h11M2.5 8h11M2.5 12h11" })
33
+ })
34
+ }),
35
+ title === void 0 ? null : /* @__PURE__ */ jsx("div", {
36
+ className: "wave-docs-layout__title",
37
+ children: title
38
+ }),
39
+ search === false ? null : /* @__PURE__ */ jsx(DocsSearch, {
40
+ indexUrl: searchIndexUrl,
41
+ className: "wave-docs-layout__search",
42
+ ...search === true ? {} : search
43
+ }),
44
+ actions === void 0 ? null : /* @__PURE__ */ jsx("div", {
45
+ className: "wave-docs-layout__actions",
46
+ children: actions
47
+ })
48
+ ]
49
+ })
50
+ }),
51
+ /* @__PURE__ */ jsxs("div", {
52
+ className: "wave-docs-layout",
53
+ children: [/* @__PURE__ */ jsx("div", {
54
+ className: "wave-docs-layout__sidebar",
55
+ children: /* @__PURE__ */ jsx(DocsNextNav, {
56
+ nav,
57
+ label: text.nav,
58
+ closeLabel: text.closeNav
59
+ })
60
+ }), children]
61
+ })
62
+ ] });
63
+ }
64
+ //#endregion
65
+ export { DocsLayoutShell };
@@ -6,6 +6,62 @@ import { jsx, jsxs } from "react/jsx-runtime";
6
6
  const HTTP_SCHEME = /^https?:\/\//i;
7
7
  /** Any URL with a scheme, or protocol-relative. */
8
8
  const ABSOLUTE_URL = /^([a-z][a-z0-9+.-]*:|\/\/)/i;
9
+ /**
10
+ * The schemes a markdown link may carry.
11
+ *
12
+ * GitHub's own allowlist, which is the bar to match: documentation links to
13
+ * `sms:`, `ftp:` and `irc:` are ordinary, and an allowlist of three silently
14
+ * deleted them. The point of the check is to stop `javascript:`, `data:` and
15
+ * `vbscript:` reaching an `href`, not to have an opinion about protocols.
16
+ *
17
+ * A scheme not listed here — `vscode:`, `obsidian:`, `slack:` — is dropped
18
+ * rather than rendered. That is deliberate: an allowlist that grows on request
19
+ * is safe, one that guesses is not. {@link warnDroppedHref} makes it visible.
20
+ */
21
+ const SAFE_SCHEME = /^(https?|mailto|tel|sms|ftp|ftps|irc|ircs|xmpp|news|nntp|feed|git|matrix):/i;
22
+ /** Hrefs already reported, so a re-render does not repeat the warning. */
23
+ const warnedHrefs = /* @__PURE__ */ new Set();
24
+ /**
25
+ * Say something when a link is dropped.
26
+ *
27
+ * A destination that vanishes with the text left behind is the quietest
28
+ * possible failure — the page looks fine and the link is simply gone. Every
29
+ * other rejection in this package names a file and a fix; this one cannot see
30
+ * the file, so it names the href and stays out of production noise.
31
+ */
32
+ function warnDroppedHref(href) {
33
+ if (process.env.NODE_ENV === "production" || warnedHrefs.has(href)) return;
34
+ warnedHrefs.add(href);
35
+ console.warn(`@waveso/docs: dropped a link to '${href}' — its URL scheme is not in the allowlist, so the text was kept and the destination removed. Use http, https, mailto, tel, sms, ftp, irc, xmpp or matrix, or render the link yourself with a custom \`a\` component.`);
36
+ }
37
+ /**
38
+ * A copy of `href` as a browser will parse it.
39
+ *
40
+ * ASCII control characters and spaces are stripped before parsing, so
41
+ * ` javascript:` and `java<TAB>script:` both navigate where the raw string
42
+ * matches no scheme at all — which is how a scheme check gets walked around.
43
+ */
44
+ function normaliseUrl(href) {
45
+ return [...href].filter((char) => (char.codePointAt(0) ?? 0) > 32).join("");
46
+ }
47
+ /**
48
+ * Would this href navigate somewhere we are willing to send a reader?
49
+ *
50
+ * Nothing upstream filters it: `remarkDocLinks` skips every href with a scheme
51
+ * (`isRelativeLink` is false for it), so `assertLinks` never sees one either,
52
+ * and `remarkRehype` runs with `allowDangerousHtml` off but passes a link's own
53
+ * url through untouched. Verified against React 19: it neutralises
54
+ * `javascript:` in every obfuscated form, silently — but it lets `vbscript:`
55
+ * and `data:text/html;base64,…` reach the DOM verbatim. So the allowlist is
56
+ * ours to keep.
57
+ *
58
+ * Tested against {@link normaliseUrl}, not the raw string.
59
+ */
60
+ function isSafeHref(href) {
61
+ const normalised = normaliseUrl(href);
62
+ if (!ABSOLUTE_URL.test(normalised)) return true;
63
+ return normalised.startsWith("//") || SAFE_SCHEME.test(normalised);
64
+ }
9
65
  function joinClassNames(...values) {
10
66
  const joined = values.filter(Boolean).join(" ");
11
67
  return joined === "" ? void 0 : joined;
@@ -23,6 +79,10 @@ function createAnchor(Link) {
23
79
  ...rest,
24
80
  children
25
81
  });
82
+ if (!isSafeHref(href)) {
83
+ warnDroppedHref(href);
84
+ return /* @__PURE__ */ jsx("span", { children });
85
+ }
26
86
  if (HTTP_SCHEME.test(href) || href.startsWith("//")) return /* @__PURE__ */ jsxs("a", {
27
87
  ...rest,
28
88
  href,
@@ -46,28 +106,33 @@ function createAnchor(Link) {
46
106
  };
47
107
  }
48
108
  function createImage(Image) {
49
- return function MarkdownImage({ src, alt, width, height, title, className, ...rest }) {
109
+ return function MarkdownImage({ src, alt, width, height, title, className, sizes, loading, ...rest }) {
50
110
  const resolvedWidth = toDimension(width);
51
111
  const resolvedHeight = toDimension(height);
112
+ const resolvedLoading = loading ?? "lazy";
113
+ const resolvedClassName = joinClassNames("wave-docs-image", className);
52
114
  if (Image !== void 0 && typeof src === "string" && resolvedWidth !== void 0 && resolvedHeight !== void 0) return /* @__PURE__ */ jsx(Image, {
115
+ ...rest,
53
116
  src,
54
117
  alt: alt ?? "",
55
118
  width: resolvedWidth,
56
119
  height: resolvedHeight,
57
120
  title,
58
- className: joinClassNames("wave-docs-image", className),
59
- loading: "lazy"
121
+ className: resolvedClassName,
122
+ sizes,
123
+ loading: resolvedLoading
60
124
  });
61
125
  return /* @__PURE__ */ jsx("img", {
126
+ decoding: "async",
62
127
  ...rest,
63
128
  src,
64
129
  alt: alt ?? "",
65
130
  width,
66
131
  height,
67
132
  title,
68
- className: joinClassNames("wave-docs-image", className),
69
- loading: "lazy",
70
- decoding: "async"
133
+ className: resolvedClassName,
134
+ sizes,
135
+ loading: resolvedLoading
71
136
  });
72
137
  };
73
138
  }
@@ -0,0 +1,28 @@
1
+ import { DocNavNode } from "../types.js";
2
+ import { DocsLinkComponent } from "./markdown-components.js";
3
+ import { ReactNode } from "react";
4
+ //#region src/react/nav.d.ts
5
+ /**
6
+ * The drawer's `id`, and the header trigger's `commandfor`.
7
+ *
8
+ * A constant rather than a `useId`, for two reasons that both matter: the
9
+ * trigger is rendered on the server in a different subtree and cannot see a
10
+ * hook's value, and `command`/`commandfor` must agree before React hydrates or
11
+ * the button does nothing on the first tap.
12
+ */
13
+ declare const DOCS_NAV_ID = "wave-docs-nav";
14
+ interface DocsNavProps {
15
+ /** The tree from `docs.source.nav()`. */
16
+ nav: DocNavNode[];
17
+ /** Current route. Injected, so this stays testable without a router. */
18
+ pathname: string;
19
+ /** Client-side router link, e.g. `next/link`. Falls back to `<a>`. */
20
+ Link?: DocsLinkComponent | undefined;
21
+ /** Accessible name for the nav landmark and the drawer. */
22
+ label?: string | undefined;
23
+ /** Accessible name for the close button. */
24
+ closeLabel?: string | undefined;
25
+ }
26
+ declare function DocsNav({ nav, pathname, Link, label, closeLabel }: DocsNavProps): ReactNode;
27
+ //#endregion
28
+ export { DOCS_NAV_ID, DocsNav, DocsNavProps };
@@ -0,0 +1,70 @@
1
+ "use client";
2
+ import { DocsSidebar } from "./sidebar.js";
3
+ import { useEffect, useRef } from "react";
4
+ import { jsx, jsxs } from "react/jsx-runtime";
5
+ //#region src/react/nav.tsx
6
+ /**
7
+ * The drawer's `id`, and the header trigger's `commandfor`.
8
+ *
9
+ * A constant rather than a `useId`, for two reasons that both matter: the
10
+ * trigger is rendered on the server in a different subtree and cannot see a
11
+ * hook's value, and `command`/`commandfor` must agree before React hydrates or
12
+ * the button does nothing on the first tap.
13
+ */
14
+ const DOCS_NAV_ID = "wave-docs-nav";
15
+ function DocsNav({ nav, pathname, Link, label = "Documentation", closeLabel = "Close navigation" }) {
16
+ const ref = useRef(null);
17
+ useEffect(() => {
18
+ ref.current?.close?.();
19
+ }, [pathname]);
20
+ useEffect(() => {
21
+ if ("command" in HTMLButtonElement.prototype) return;
22
+ const onClick = (event) => {
23
+ const target = event.target;
24
+ if (!(target instanceof Element)) return;
25
+ const button = target.closest("button[commandfor]");
26
+ if (button === null) return;
27
+ if (button.getAttribute("commandfor") !== "wave-docs-nav") return;
28
+ const dialog = ref.current;
29
+ if (dialog === null) return;
30
+ if (button.getAttribute("command") === "close") dialog.close();
31
+ else dialog.showModal();
32
+ };
33
+ document.addEventListener("click", onClick);
34
+ return () => {
35
+ document.removeEventListener("click", onClick);
36
+ };
37
+ }, []);
38
+ return /* @__PURE__ */ jsxs("dialog", {
39
+ ref,
40
+ id: DOCS_NAV_ID,
41
+ className: "wave-docs-layout__drawer",
42
+ closedby: "any",
43
+ "aria-label": label,
44
+ children: [/* @__PURE__ */ jsx("button", {
45
+ type: "button",
46
+ className: "wave-docs-layout__drawer-close",
47
+ "aria-label": closeLabel,
48
+ command: "close",
49
+ commandfor: DOCS_NAV_ID,
50
+ children: /* @__PURE__ */ jsx("svg", {
51
+ "aria-hidden": "true",
52
+ viewBox: "0 0 16 16",
53
+ width: "16",
54
+ height: "16",
55
+ fill: "none",
56
+ stroke: "currentColor",
57
+ strokeWidth: "1.5",
58
+ strokeLinecap: "round",
59
+ children: /* @__PURE__ */ jsx("path", { d: "M4 4l8 8M12 4l-8 8" })
60
+ })
61
+ }), /* @__PURE__ */ jsx(DocsSidebar, {
62
+ nav,
63
+ pathname,
64
+ label,
65
+ Link
66
+ })]
67
+ });
68
+ }
69
+ //#endregion
70
+ export { DOCS_NAV_ID, DocsNav };
@@ -0,0 +1,45 @@
1
+ //#region src/react/nearest-scroll-top.d.ts
2
+ /**
3
+ * Where a scrollport should be scrolled to so an item inside it is visible,
4
+ * or `undefined` when it already is.
5
+ *
6
+ * Private, and pure, and separate from the component for one reason: this is
7
+ * the whole of the geometry, and geometry is the half that can be tested
8
+ * exhaustively. jsdom reports every rectangle as zero, so a test that drove
9
+ * the effect would assert nothing about the arithmetic.
10
+ *
11
+ * ## Why not `scrollIntoView`
12
+ *
13
+ * ⚠️ `element.scrollIntoView({ block: 'nearest' })` LOOKS LIKE THE ANSWER AND
14
+ * IS A TRAP. It scrolls **every** scrollable ancestor, the document included.
15
+ * On a docs page that means opening a deep link scrolls the sidebar *and*
16
+ * jumps the article the reader came to read — a page that silently moves under
17
+ * them, on the one navigation where they know exactly what they asked for.
18
+ *
19
+ * So the caller finds the nearest scrollable ancestor and assigns `scrollTop`
20
+ * itself. `sidebar.test.tsx` spies on `scrollIntoView` and asserts it is never
21
+ * called: a test for the API deliberately not used, which is the only thing
22
+ * that stops the page-jump being reintroduced by someone simplifying the code.
23
+ */
24
+ interface NearestScrollTopInput {
25
+ /** The item's offset from the top of the scrollport's content. */
26
+ itemTop: number;
27
+ itemHeight: number;
28
+ /** The scrollport's visible height. */
29
+ viewHeight: number;
30
+ /** Where the scrollport is scrolled to now. */
31
+ scrollTop: number;
32
+ /** Total scrollable content height, used to clamp. */
33
+ scrollHeight: number;
34
+ }
35
+ /**
36
+ * The new `scrollTop`, or `undefined` when nothing should move.
37
+ *
38
+ * `undefined` for the already-visible case is not an optimisation: it is the
39
+ * common case — most navigations are to a page already on screen — and
40
+ * assigning `scrollTop` to its current value still cancels a smooth scroll in
41
+ * progress and still fires a `scroll` event.
42
+ */
43
+ declare function nearestScrollTop({ itemTop, itemHeight, viewHeight, scrollTop, scrollHeight }: NearestScrollTopInput): number | undefined;
44
+ //#endregion
45
+ export { NearestScrollTopInput, nearestScrollTop };
@@ -0,0 +1,44 @@
1
+ //#region src/react/nearest-scroll-top.ts
2
+ /**
3
+ * Where a scrollport should be scrolled to so an item inside it is visible,
4
+ * or `undefined` when it already is.
5
+ *
6
+ * Private, and pure, and separate from the component for one reason: this is
7
+ * the whole of the geometry, and geometry is the half that can be tested
8
+ * exhaustively. jsdom reports every rectangle as zero, so a test that drove
9
+ * the effect would assert nothing about the arithmetic.
10
+ *
11
+ * ## Why not `scrollIntoView`
12
+ *
13
+ * ⚠️ `element.scrollIntoView({ block: 'nearest' })` LOOKS LIKE THE ANSWER AND
14
+ * IS A TRAP. It scrolls **every** scrollable ancestor, the document included.
15
+ * On a docs page that means opening a deep link scrolls the sidebar *and*
16
+ * jumps the article the reader came to read — a page that silently moves under
17
+ * them, on the one navigation where they know exactly what they asked for.
18
+ *
19
+ * So the caller finds the nearest scrollable ancestor and assigns `scrollTop`
20
+ * itself. `sidebar.test.tsx` spies on `scrollIntoView` and asserts it is never
21
+ * called: a test for the API deliberately not used, which is the only thing
22
+ * that stops the page-jump being reintroduced by someone simplifying the code.
23
+ */
24
+ /** Breathing room above or below the item, so it is not flush against the edge. */
25
+ const MARGIN = 16;
26
+ /**
27
+ * The new `scrollTop`, or `undefined` when nothing should move.
28
+ *
29
+ * `undefined` for the already-visible case is not an optimisation: it is the
30
+ * common case — most navigations are to a page already on screen — and
31
+ * assigning `scrollTop` to its current value still cancels a smooth scroll in
32
+ * progress and still fires a `scroll` event.
33
+ */
34
+ function nearestScrollTop({ itemTop, itemHeight, viewHeight, scrollTop, scrollHeight }) {
35
+ if (viewHeight <= 0 || scrollHeight <= viewHeight) return void 0;
36
+ const itemBottom = itemTop + itemHeight;
37
+ const viewBottom = scrollTop + viewHeight;
38
+ const target = itemTop < scrollTop ? itemTop - MARGIN : itemBottom > viewBottom ? itemBottom - viewHeight + MARGIN : void 0;
39
+ if (target === void 0) return void 0;
40
+ const clamped = Math.max(0, Math.min(target, scrollHeight - viewHeight));
41
+ return itemHeight > viewHeight ? Math.max(0, Math.min(itemTop - MARGIN, scrollHeight - viewHeight)) : clamped;
42
+ }
43
+ //#endregion
44
+ export { nearestScrollTop };
@@ -0,0 +1,34 @@
1
+ import { DocsLinkComponent } from "./markdown-components.js";
2
+ import { ComponentProps, ComponentType } from "react";
3
+ //#region src/react/next-link.d.ts
4
+ /**
5
+ * The part of `next/link` this package uses.
6
+ *
7
+ * Declared structurally rather than imported: `next` is an optional peer, and
8
+ * a type-only import of it would still be a hard resolution requirement for
9
+ * anyone type-checking against our `.d.ts`.
10
+ */
11
+ type NextLinkComponent = ComponentType<Omit<ComponentProps<'a'>, 'href' | 'ref'> & {
12
+ href: string;
13
+ prefetch?: boolean | null;
14
+ }>;
15
+ /**
16
+ * Adapt `next/link` to {@link DocsLinkProps}.
17
+ *
18
+ * `next/link` widens `href` to `string | UrlObject` and `prefetch` to
19
+ * `boolean | 'auto' | null`; the React layer promises neither, because it must
20
+ * also run with a plain `<a>`. One wrapper keeps that mismatch in a single
21
+ * place instead of at every call site.
22
+ *
23
+ * `prefetch` is omitted rather than passed as `undefined`, which is not
24
+ * pedantry: under `exactOptionalPropertyTypes` — which this package compiles
25
+ * with, and which any consumer may turn on — `undefined` is not assignable to
26
+ * `boolean | 'auto' | null`, and `<SearchDialog Link={Link} />` written by
27
+ * hand fails to compile for a reason that reads as our bug.
28
+ *
29
+ * Call it once at module scope, never during a render: a fresh component
30
+ * identity for `a` remounts every link in the document on every render.
31
+ */
32
+ declare function wrapNextLink(NextLink: NextLinkComponent): DocsLinkComponent;
33
+ //#endregion
34
+ export { NextLinkComponent, wrapNextLink };
@@ -0,0 +1,30 @@
1
+ import { createElement } from "react";
2
+ //#region src/react/next-link.ts
3
+ /**
4
+ * Adapt `next/link` to {@link DocsLinkProps}.
5
+ *
6
+ * `next/link` widens `href` to `string | UrlObject` and `prefetch` to
7
+ * `boolean | 'auto' | null`; the React layer promises neither, because it must
8
+ * also run with a plain `<a>`. One wrapper keeps that mismatch in a single
9
+ * place instead of at every call site.
10
+ *
11
+ * `prefetch` is omitted rather than passed as `undefined`, which is not
12
+ * pedantry: under `exactOptionalPropertyTypes` — which this package compiles
13
+ * with, and which any consumer may turn on — `undefined` is not assignable to
14
+ * `boolean | 'auto' | null`, and `<SearchDialog Link={Link} />` written by
15
+ * hand fails to compile for a reason that reads as our bug.
16
+ *
17
+ * Call it once at module scope, never during a render: a fresh component
18
+ * identity for `a` remounts every link in the document on every render.
19
+ */
20
+ function wrapNextLink(NextLink) {
21
+ return function DocsNextLink({ href, prefetch, children, ...rest }) {
22
+ return createElement(NextLink, {
23
+ ...rest,
24
+ href,
25
+ ...prefetch === void 0 ? {} : { prefetch }
26
+ }, children);
27
+ };
28
+ }
29
+ //#endregion
30
+ export { wrapNextLink };
@@ -0,0 +1,11 @@
1
+ import { DocNavNode } from "../types.js";
2
+ import { ReactNode } from "react";
3
+ //#region src/react/next-nav.d.ts
4
+ interface DocsNextNavProps {
5
+ nav: DocNavNode[];
6
+ label?: string | undefined;
7
+ closeLabel?: string | undefined;
8
+ }
9
+ declare function DocsNextNav({ nav, label, closeLabel }: DocsNextNavProps): ReactNode;
10
+ //#endregion
11
+ export { DocsNextNav, DocsNextNavProps };
@@ -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 };
@@ -1,5 +1,7 @@
1
+ import { SearchRecord } from "../types.js";
1
2
  import { DocsLinkComponent } from "./markdown-components.js";
2
3
  import { ReactNode } from "react";
4
+ import { Options } from "minisearch";
3
5
  //#region src/react/search-dialog.d.ts
4
6
  interface SearchDialogProps {
5
7
  /**
@@ -17,17 +19,43 @@ interface SearchDialogProps {
17
19
  * Optional link component for results, e.g. `next/link`, so hovering a hit
18
20
  * prefetches the page. Results fall back to a plain anchor.
19
21
  */
20
- Link?: DocsLinkComponent;
22
+ Link?: DocsLinkComponent | undefined;
21
23
  /** Trigger button label. Defaults to `'Search'`. */
22
- triggerLabel?: string;
24
+ triggerLabel?: string | undefined;
23
25
  /** Input placeholder. Defaults to `'Search documentation'`. */
24
- placeholder?: string;
26
+ placeholder?: string | undefined;
25
27
  /** Accessible name for the dialog. Defaults to `'Search documentation'`. */
26
- dialogLabel?: string;
28
+ dialogLabel?: string | undefined;
27
29
  /** Maximum results rendered. Defaults to 8. */
28
- maxResults?: number;
30
+ maxResults?: number | undefined;
29
31
  /** Input debounce in milliseconds. Defaults to 120. */
30
- debounceMs?: number;
32
+ debounceMs?: number | undefined;
33
+ /** Extra class names for the trigger button, e.g. a navbar's own layout. */
34
+ className?: string | undefined;
35
+ /**
36
+ * Overrides applied through `mergeSearchOptions` when the index is
37
+ * deserialised — the escape hatch for tokenisation, `processTerm` and the
38
+ * query defaults, without waiting on a release of this package.
39
+ *
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.
57
+ */
58
+ miniSearchOptions?: Partial<Options<SearchRecord>> | undefined;
31
59
  }
32
60
  /**
33
61
  * Search trigger plus its dialog.
@@ -36,6 +64,6 @@ interface SearchDialogProps {
36
64
  * portalled to `document.body`, so a navbar's stacking context cannot trap
37
65
  * it behind the page.
38
66
  */
39
- declare function SearchDialog({ indexUrl, navigate, Link, triggerLabel, placeholder, dialogLabel, maxResults, debounceMs }: SearchDialogProps): ReactNode;
67
+ declare function SearchDialog({ indexUrl, navigate, Link, triggerLabel, placeholder, dialogLabel, maxResults, debounceMs, className, miniSearchOptions }: SearchDialogProps): ReactNode;
40
68
  //#endregion
41
69
  export { SearchDialog, SearchDialogProps };