@waveso/docs 0.6.0 → 0.7.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.
package/dist/next.js CHANGED
@@ -6,6 +6,7 @@ import { mapPooled } from "./map-pooled.js";
6
6
  import { findFunctionValuedOptions } from "./search-options.js";
7
7
  import { createMarkdownComponents } from "./react/markdown-components.js";
8
8
  import { DocContent } from "./react/doc-content.js";
9
+ import { DocsHero } from "./react/hero.js";
9
10
  import { DocsToc } from "./react/toc.js";
10
11
  import { wrapNextLink } from "./react/link-adapter.js";
11
12
  import { createDocsRenderer } from "./render.js";
@@ -172,11 +173,15 @@ async function buildNextComponents(labels) {
172
173
  const [linkMod, imageMod] = await Promise.all([importNext(() => import("next/link"), "next/link"), importNext(() => import("next/image"), "next/image")]);
173
174
  const NextLink = readDefaultExport(linkMod, "next/link");
174
175
  const NextImage = readDefaultExport(imageMod, "next/image");
175
- return createMarkdownComponents({
176
- Link: wrapNextLink(NextLink),
177
- Image: wrapNextImage(NextImage),
178
- ...labels === void 0 ? {} : { labels }
179
- });
176
+ const link = wrapNextLink(NextLink);
177
+ return {
178
+ components: createMarkdownComponents({
179
+ Link: link,
180
+ Image: wrapNextImage(NextImage),
181
+ ...labels === void 0 ? {} : { labels }
182
+ }),
183
+ link
184
+ };
180
185
  }
181
186
  /**
182
187
  * The named subset of `labels`, or `undefined` when none of it is set.
@@ -220,7 +225,7 @@ function pickLabels(labels, map) {
220
225
  */
221
226
  function serializableSearchOptions(candidate) {
222
227
  const functions = findFunctionValuedOptions(candidate);
223
- if (functions.length > 0) throw docsError("invalid-config", `the search dialog cannot be given MiniSearch functions from a server component: ${functions.map((name) => `\`miniSearchOptions.${name}\``).join(", ")}. \`docs.Layout\` renders the dialog as a client component, so its props are serialised on the way across and React rejects a function with "Functions cannot be passed directly to Client Components" while prerendering. Keep the function on \`createDocsRoute\` so the index is still built with it, pass \`search={false}\` to \`docs.Layout\`, and render the dialog yourself from a \`'use client'\` module that imports the same function — \`<DocsSearch indexUrl={docs.searchIndexUrl} miniSearchOptions={{ processTerm }} />\` putting that component in \`actions\`. Serialisable overrides (\`storeFields\`, \`boost\`, \`searchOptions.fuzzy\`) need none of this and are forwarded as before.`);
228
+ if (functions.length > 0) throw docsError("invalid-config", `the search dialog cannot be given MiniSearch functions from a server component: ${functions.map((name) => `\`miniSearchOptions.${name}\``).join(", ")}. \`docs.Layout\` renders the dialog as a client component, so its props are serialised on the way across and React rejects a function with "Functions cannot be passed directly to Client Components" while prerendering. Keep the function on \`createDocsRoute\` so the index is still built with it, pass \`search={false}\` to \`docs.Layout\`, and render the dialog yourself from a \`'use client'\` module that imports the same function — \`<DocsSearch indexUrl={docs.searchIndexUrl} miniSearchOptions={{ processTerm }} />\` in your own layout. Serialisable overrides (\`storeFields\`, \`boost\`, \`searchOptions.fuzzy\`) need none of this and are forwarded as before.`);
224
229
  return candidate;
225
230
  }
226
231
  /**
@@ -425,12 +430,18 @@ function createDocsRoute(options) {
425
430
  async function renderRoute(segments) {
426
431
  const doc = await getPage(segments);
427
432
  if (doc === void 0) return (await loadNotFound())();
428
- const components = await loadComponents();
433
+ const { components, link } = await loadComponents();
429
434
  return createElement(Fragment, null, createElement("main", {
430
435
  className: "wave-docs-layout__main",
431
436
  id: DOCS_CONTENT_ID,
432
437
  tabIndex: -1
433
- }, createElement(DocContent, {
438
+ }, (doc.frontmatter.actions?.length ?? 0) === 0 ? null : createElement(DocsHero, {
439
+ title: doc.frontmatter.title,
440
+ ...doc.frontmatter.description === void 0 ? {} : { description: doc.frontmatter.description },
441
+ ...doc.frontmatter.actions === void 0 ? {} : { actions: doc.frontmatter.actions },
442
+ Link: link,
443
+ ...routeLabels?.externalLink === void 0 ? {} : { externalLabel: routeLabels.externalLink }
444
+ }), createElement(DocContent, {
434
445
  hast: doc.hast,
435
446
  components: {
436
447
  ...components,
@@ -457,7 +468,7 @@ function createDocsRoute(options) {
457
468
  async IndexPage() {
458
469
  return renderRoute([]);
459
470
  },
460
- async Layout({ children, title, actions, search, labels }) {
471
+ async Layout({ children, search, labels }) {
461
472
  const { DocsLayoutShell } = await import("./react/layout.js");
462
473
  const host = search === true || search === void 0 || search === false ? void 0 : search;
463
474
  const requestedOptions = host?.miniSearchOptions ?? options.miniSearchOptions;
@@ -474,8 +485,6 @@ function createDocsRoute(options) {
474
485
  nav: await requestScopedSource.nav(),
475
486
  searchIndexUrl,
476
487
  search: searchProps,
477
- ...title === void 0 ? {} : { title },
478
- ...actions === void 0 ? {} : { actions },
479
488
  ...shellLabels === void 0 ? {} : { labels: shellLabels }
480
489
  });
481
490
  },
@@ -0,0 +1,19 @@
1
+ import { DocAction } from "../types.js";
2
+ import { DocsLinkComponent } from "./markdown-components.js";
3
+ import { ReactNode } from "react";
4
+ //#region src/react/hero.d.ts
5
+ interface DocsHeroProps {
6
+ /** `frontmatter.title`, rendered as the page's `<h1>`. */
7
+ title: string;
8
+ /** `frontmatter.description`. Omitted rather than rendered empty. */
9
+ description?: string | undefined;
10
+ /** `frontmatter.actions`. An empty list renders no `<nav>` at all. */
11
+ actions?: readonly DocAction[] | undefined;
12
+ /** Client-side router link, e.g. `next/link`. Falls back to `<a>`. */
13
+ Link?: DocsLinkComponent | undefined;
14
+ /** Screen-reader suffix on a link that opens elsewhere. */
15
+ externalLabel?: string | undefined;
16
+ }
17
+ declare function DocsHero({ title, description, actions, Link, externalLabel }: DocsHeroProps): ReactNode;
18
+ //#endregion
19
+ export { DocsHero, DocsHeroProps };
@@ -0,0 +1,44 @@
1
+ import { opensInNewTab } from "../safe-href.js";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ //#region src/react/hero.tsx
4
+ function DocsHero({ title, description, actions, Link, externalLabel = "(opens in a new tab)" }) {
5
+ const Anchor = Link ?? "a";
6
+ return /* @__PURE__ */ jsx("header", {
7
+ className: "wave-docs-hero",
8
+ children: /* @__PURE__ */ jsxs("div", {
9
+ className: "wave-docs-hero__body",
10
+ children: [
11
+ /* @__PURE__ */ jsx("h1", {
12
+ className: "wave-docs-hero__title",
13
+ children: title
14
+ }),
15
+ description === void 0 || description === "" ? null : /* @__PURE__ */ jsx("p", {
16
+ className: "wave-docs-hero__tagline",
17
+ children: description
18
+ }),
19
+ actions === void 0 || actions.length === 0 ? null : /* @__PURE__ */ jsx("div", {
20
+ className: "wave-docs-hero__actions",
21
+ children: actions.map((action, index) => {
22
+ const external = opensInNewTab(action.href);
23
+ const variant = action.variant ?? (index === 0 ? "primary" : "secondary");
24
+ return /* @__PURE__ */ jsxs(external ? "a" : Anchor, {
25
+ href: action.href,
26
+ className: "wave-docs-hero__action",
27
+ "data-variant": variant,
28
+ ...external ? {
29
+ target: "_blank",
30
+ rel: "noreferrer"
31
+ } : {},
32
+ children: [action.label, external ? /* @__PURE__ */ jsx("span", {
33
+ className: "wave-docs-sr-only",
34
+ children: ` ${externalLabel}`
35
+ }) : null]
36
+ }, action.href);
37
+ })
38
+ })
39
+ ]
40
+ })
41
+ });
42
+ }
43
+ //#endregion
44
+ export { DocsHero };
@@ -27,8 +27,6 @@ interface DocsLayoutShellProps {
27
27
  children: ReactNode;
28
28
  nav: DocNavNode[];
29
29
  searchIndexUrl: string;
30
- title?: ReactNode;
31
- actions?: ReactNode;
32
30
  /**
33
31
  * `false` to omit the trigger; an object to configure it.
34
32
  *
@@ -56,6 +54,6 @@ interface DocsLayoutShellProps {
56
54
  */
57
55
  labels?: DocsLabels | undefined;
58
56
  }
59
- declare function DocsLayoutShell({ children, nav, searchIndexUrl, title, actions, search, labels }: DocsLayoutShellProps): ReactNode;
57
+ declare function DocsLayoutShell({ children, nav, searchIndexUrl, search, labels }: DocsLayoutShellProps): ReactNode;
60
58
  //#endregion
61
59
  export { DocsLayoutSearchProps, DocsLayoutShell, DocsLayoutShellProps };
@@ -1,68 +1,31 @@
1
1
  import { DocsSearch } from "./next-search.js";
2
2
  import { resolveLabels } from "./shell-labels.js";
3
- import { DOCS_NAV_ID } from "./nav.js";
4
3
  import { DocsNextNav } from "./next-nav.js";
5
4
  import { SkipLink } from "./skip-link.js";
6
5
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
7
6
  //#region src/react/layout.tsx
8
- function DocsLayoutShell({ children, nav, searchIndexUrl, title, actions, search = true, labels }) {
7
+ function DocsLayoutShell({ children, nav, searchIndexUrl, search = true, labels }) {
9
8
  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
- ...search === true ? {} : search,
42
- className: ["wave-docs-layout__search", search === true ? void 0 : search?.className].filter(Boolean).join(" ")
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", {
9
+ return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(SkipLink, { children: text.skipToContent }), /* @__PURE__ */ jsx("div", {
10
+ className: "wave-docs-shell",
11
+ children: /* @__PURE__ */ jsxs("div", {
52
12
  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
- ...labels?.expandGroup === void 0 ? {} : { expandGroup: labels.expandGroup },
60
- ...labels?.collapseGroup === void 0 ? {} : { collapseGroup: labels.collapseGroup },
61
- ...labels?.externalLink === void 0 ? {} : { externalLink: labels.externalLink }
13
+ children: [/* @__PURE__ */ jsx(DocsNextNav, {
14
+ nav,
15
+ label: text.nav,
16
+ closeLabel: text.closeNav,
17
+ openLabel: text.openNav,
18
+ ...labels?.expandGroup === void 0 ? {} : { expandGroup: labels.expandGroup },
19
+ ...labels?.collapseGroup === void 0 ? {} : { collapseGroup: labels.collapseGroup },
20
+ ...labels?.externalLink === void 0 ? {} : { externalLink: labels.externalLink },
21
+ children: search === false ? null : /* @__PURE__ */ jsx(DocsSearch, {
22
+ indexUrl: searchIndexUrl,
23
+ ...search === true ? {} : search,
24
+ className: ["wave-docs-layout__search", search === true ? void 0 : search?.className].filter(Boolean).join(" ")
62
25
  })
63
26
  }), children]
64
27
  })
65
- ] });
28
+ })] });
66
29
  }
67
30
  //#endregion
68
31
  export { DocsLayoutShell };
@@ -2,14 +2,7 @@ import { DocNavNode } from "../types.js";
2
2
  import { DocsLinkComponent } from "./markdown-components.js";
3
3
  import { ReactNode } from "react";
4
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
- */
5
+ /** The navigation's `id`, and the trigger's `aria-controls`. */
13
6
  declare const DOCS_NAV_ID = "wave-docs-nav";
14
7
  interface DocsNavProps {
15
8
  /** The tree from `docs.source.nav()`. */
@@ -18,15 +11,22 @@ interface DocsNavProps {
18
11
  pathname: string;
19
12
  /** Client-side router link, e.g. `next/link`. Falls back to `<a>`. */
20
13
  Link?: DocsLinkComponent | undefined;
21
- /** Accessible name for the nav landmark and the drawer. */
14
+ /** Accessible name for the nav landmark. */
22
15
  label?: string | undefined;
23
- /** Accessible name for the close button. */
16
+ /** Accessible name for the trigger while the sidebar is open. */
24
17
  closeLabel?: string | undefined;
18
+ /** Accessible name for the trigger while the sidebar is closed. */
19
+ openLabel?: string | undefined;
20
+ /**
21
+ * Rendered above the tree. The search trigger goes here — it belongs to the
22
+ * navigation, so it moves with it and is never a second thing to place.
23
+ */
24
+ children?: ReactNode;
25
25
  /** Passed through to the tree. See `DocsSidebarProps.expandGroup`. */
26
26
  expandGroup?: string | undefined;
27
27
  collapseGroup?: string | undefined;
28
28
  externalLink?: string | undefined;
29
29
  }
30
- declare function DocsNav({ nav, pathname, Link, label, closeLabel, expandGroup, collapseGroup, externalLink }: DocsNavProps): ReactNode;
30
+ declare function DocsNav({ nav, pathname, Link, label, children, closeLabel, openLabel, expandGroup, collapseGroup, externalLink }: DocsNavProps): ReactNode;
31
31
  //#endregion
32
32
  export { DOCS_NAV_ID, DocsNav, DocsNavProps };
package/dist/react/nav.js CHANGED
@@ -1,73 +1,121 @@
1
1
  "use client";
2
2
  import { DocsSidebar } from "./sidebar.js";
3
- import { useEffect, useRef } from "react";
4
- import { jsx, jsxs } from "react/jsx-runtime";
3
+ import { useCallback, useEffect, useRef, useState } from "react";
4
+ import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
5
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
- */
6
+ /** The navigation's `id`, and the trigger's `aria-controls`. */
14
7
  const DOCS_NAV_ID = "wave-docs-nav";
15
- function DocsNav({ nav, pathname, Link, label = "Documentation", closeLabel = "Close navigation", expandGroup, collapseGroup, externalLink }) {
16
- const ref = useRef(null);
8
+ function DocsNav({ nav, pathname, Link, label = "Documentation", children, closeLabel = "Close navigation", openLabel = "Open navigation", expandGroup, collapseGroup, externalLink }) {
9
+ const shellRef = useRef(null);
10
+ const navRef = useRef(null);
11
+ const returnFocusRef = useRef(null);
12
+ /**
13
+ * ⚠️ THREE STATES, AND THE THIRD ONE IS WHY THERE IS NO FLASH.
14
+ *
15
+ * `null` is "nobody has chosen yet". The server renders it, no `data-state`
16
+ * reaches the DOM, and the stylesheet decides per mode — closed where the
17
+ * navigation would cover the article, open where it would sit beside it. So
18
+ * the first paint is already right at both shapes, with no JavaScript and
19
+ * nothing to correct afterwards.
20
+ *
21
+ * A boolean would have to be picked before the container's width is known,
22
+ * which is either a flash on a phone or a flash on a desktop.
23
+ */
24
+ const [choice, setChoice] = useState(null);
25
+ const [mode, setMode] = useState(null);
26
+ const [ready, setReady] = useState(false);
27
+ const open = choice ?? mode === "push";
28
+ const covering = mode === "cover" && open;
17
29
  useEffect(() => {
18
- ref.current?.close?.();
30
+ const shell = shellRef.current;
31
+ const layout = shell?.parentElement;
32
+ if (shell == null || layout == null) return;
33
+ const read = () => {
34
+ const value = getComputedStyle(shell).getPropertyValue("--wave-docs-sidebar-mode").trim();
35
+ setMode(value === "push" ? "push" : "cover");
36
+ };
37
+ read();
38
+ const frame = requestAnimationFrame(() => {
39
+ setReady(true);
40
+ });
41
+ const observer = new ResizeObserver(read);
42
+ observer.observe(layout);
43
+ return () => {
44
+ cancelAnimationFrame(frame);
45
+ observer.disconnect();
46
+ };
47
+ }, []);
48
+ const close = useCallback(() => {
49
+ setChoice(false);
50
+ }, []);
51
+ useEffect(() => {
52
+ if (covering) setChoice(false);
19
53
  }, [pathname]);
20
54
  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();
55
+ if (!covering) return;
56
+ const onKeyDown = (event) => {
57
+ if (event.key !== "Escape") return;
58
+ event.stopPropagation();
59
+ close();
32
60
  };
33
- document.addEventListener("click", onClick);
61
+ document.addEventListener("keydown", onKeyDown);
34
62
  return () => {
35
- document.removeEventListener("click", onClick);
63
+ document.removeEventListener("keydown", onKeyDown);
36
64
  };
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", {
65
+ }, [covering, close]);
66
+ useEffect(() => {
67
+ const shell = shellRef.current;
68
+ const layout = shell?.parentElement;
69
+ if (shell == null || layout == null) return;
70
+ const outside = [...layout.children].filter((child) => child !== shell && !child.classList.contains(SCRIM_CLASS));
71
+ if (!covering) {
72
+ for (const element of outside) element.removeAttribute("inert");
73
+ return;
74
+ }
75
+ returnFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
76
+ for (const element of outside) element.setAttribute("inert", "");
77
+ navRef.current?.focus();
78
+ return () => {
79
+ for (const element of outside) element.removeAttribute("inert");
80
+ returnFocusRef.current?.focus({ preventScroll: true });
81
+ };
82
+ }, [covering]);
83
+ const state = choice === null && mode === null ? void 0 : open;
84
+ return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsxs("div", {
85
+ ref: shellRef,
86
+ className: "wave-docs-layout__sidebar",
87
+ ...state === void 0 ? {} : { "data-state": state ? "open" : "closed" },
88
+ ...ready ? { "data-ready": "" } : {},
89
+ children: [/* @__PURE__ */ jsxs("div", {
90
+ ref: navRef,
91
+ id: DOCS_NAV_ID,
92
+ className: "wave-docs-layout__sidebar-nav",
93
+ tabIndex: -1,
94
+ children: [children, /* @__PURE__ */ jsx(DocsSidebar, {
95
+ nav,
96
+ pathname,
97
+ label,
98
+ Link,
99
+ ...expandGroup === void 0 ? {} : { expandGroup },
100
+ ...collapseGroup === void 0 ? {} : { collapseGroup },
101
+ ...externalLink === void 0 ? {} : { externalLink }
102
+ })]
103
+ }), /* @__PURE__ */ jsx("button", {
45
104
  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
- ...expandGroup === void 0 ? {} : { expandGroup },
67
- ...collapseGroup === void 0 ? {} : { collapseGroup },
68
- ...externalLink === void 0 ? {} : { externalLink }
105
+ className: "wave-docs-layout__sidebar-trigger",
106
+ "aria-controls": DOCS_NAV_ID,
107
+ "aria-label": open ? closeLabel : openLabel,
108
+ ...state === void 0 ? {} : { "aria-expanded": state },
109
+ onClick: () => {
110
+ setChoice(!open);
111
+ }
69
112
  })]
70
- });
113
+ }), /* @__PURE__ */ jsx("div", {
114
+ className: SCRIM_CLASS,
115
+ "aria-hidden": "true",
116
+ onClick: close
117
+ })] });
71
118
  }
119
+ const SCRIM_CLASS = "wave-docs-layout__sidebar-scrim";
72
120
  //#endregion
73
121
  export { DOCS_NAV_ID, DocsNav };
@@ -5,11 +5,15 @@ interface DocsNextNavProps {
5
5
  nav: DocNavNode[];
6
6
  label?: string | undefined;
7
7
  closeLabel?: string | undefined;
8
+ /** Accessible name for the trigger while the sidebar is closed. */
9
+ openLabel?: string | undefined;
10
+ /** Rendered above the tree. See `DocsNavProps.children`. */
11
+ children?: ReactNode;
8
12
  /** Passed through to the tree. See `DocsSidebarProps.expandGroup`. */
9
13
  expandGroup?: string | undefined;
10
14
  collapseGroup?: string | undefined;
11
15
  externalLink?: string | undefined;
12
16
  }
13
- declare function DocsNextNav({ nav, label, closeLabel, expandGroup, collapseGroup, externalLink }: DocsNextNavProps): ReactNode;
17
+ declare function DocsNextNav({ nav, label, closeLabel, openLabel, children, expandGroup, collapseGroup, externalLink }: DocsNextNavProps): ReactNode;
14
18
  //#endregion
15
19
  export { DocsNextNav, DocsNextNavProps };
@@ -19,16 +19,18 @@ import { usePathname } from "next/navigation";
19
19
  */
20
20
  /** Module scope: a fresh identity here remounts every nav link on every render. */
21
21
  const Link = wrapNextLink(NextLink);
22
- function DocsNextNav({ nav, label, closeLabel, expandGroup, collapseGroup, externalLink }) {
22
+ function DocsNextNav({ nav, label, closeLabel, openLabel, children, expandGroup, collapseGroup, externalLink }) {
23
23
  return /* @__PURE__ */ jsx(DocsNav, {
24
24
  nav,
25
25
  pathname: usePathname(),
26
26
  Link,
27
27
  ...label === void 0 ? {} : { label },
28
28
  ...closeLabel === void 0 ? {} : { closeLabel },
29
+ ...openLabel === void 0 ? {} : { openLabel },
29
30
  ...expandGroup === void 0 ? {} : { expandGroup },
30
31
  ...collapseGroup === void 0 ? {} : { collapseGroup },
31
- ...externalLink === void 0 ? {} : { externalLink }
32
+ ...externalLink === void 0 ? {} : { externalLink },
33
+ children
32
34
  });
33
35
  }
34
36
  //#endregion
@@ -44,9 +44,15 @@
44
44
  interface DocsLabels {
45
45
  /** The navigation landmark's accessible name. Default `'Documentation'`. */
46
46
  nav?: string | undefined;
47
- /** The header button that opens the drawer. Default `'Open navigation'`. */
47
+ /**
48
+ * The sidebar's trigger, named for what pressing it does next. One button
49
+ * carries both strings: `openNav` while the sidebar is closed, `closeNav`
50
+ * while it is open.
51
+ *
52
+ * Default `'Open navigation'`.
53
+ */
48
54
  openNav?: string | undefined;
49
- /** The button that closes the drawer. Default `'Close navigation'`. */
55
+ /** The same button, while the sidebar is open. Default `'Close navigation'`. */
50
56
  closeNav?: string | undefined;
51
57
  /** The skip link's visible text. Default `'Skip to content'`. */
52
58
  skipToContent?: string | undefined;
@@ -8,9 +8,19 @@ interface DocsTocProps {
8
8
  label?: string | undefined;
9
9
  /**
10
10
  * Region of the viewport that counts as "current", as an
11
- * `IntersectionObserver` root margin. The default reserves 80px for a sticky
12
- * header and ignores the bottom 60% of the screen, so the active entry
13
- * tracks what you are reading rather than what has scrolled into view.
11
+ * `IntersectionObserver` root margin. The default ignores the bottom 60% of
12
+ * the screen, so the active entry tracks what you are reading rather than
13
+ * what has scrolled into view.
14
+ *
15
+ * ⚠️ IT USED TO RESERVE 80px FOR A STICKY HEADER, AND TWO THINGS WERE WRONG
16
+ * WITH THAT. There is no header — above 80rem, where this component
17
+ * is rendered at all, nothing of ours overlays the content. And 80px never
18
+ * matched the header anyway: the token was `3.5rem`, which is 56px, so the
19
+ * figure was never derived from the thing it claimed to reserve for.
20
+ *
21
+ * This component is exported standalone, so its default cannot assume our
22
+ * shell. It assumes nothing overlays the content; a host whose own chrome
23
+ * does sets this prop, which is what the prop is for.
14
24
  *
15
25
  * Only `px` and `%` are legal here — `IntersectionObserver` throws on any
16
26
  * other unit, `rem` included.
package/dist/react/toc.js CHANGED
@@ -3,7 +3,7 @@ import { DOCS_CONTENT_ID } from "../docs-content-id.js";
3
3
  import { useEffect, useMemo, useRef, useState } from "react";
4
4
  import { jsx, jsxs } from "react/jsx-runtime";
5
5
  //#region src/react/toc.tsx
6
- const DEFAULT_ROOT_MARGIN = "-80px 0px -60% 0px";
6
+ const DEFAULT_ROOT_MARGIN = "0px 0px -60% 0px";
7
7
  /**
8
8
  * How many frames to keep looking for headings that are not in the document
9
9
  * yet. ~1s at 60Hz, which covers a `<Suspense>` boundary resolving or a
@@ -86,15 +86,22 @@ function DocsToc({ entries, label = "On this page", rootMargin = DEFAULT_ROOT_MA
86
86
  return /* @__PURE__ */ jsxs("nav", {
87
87
  "aria-label": label,
88
88
  className: ["wave-docs-toc", className].filter(Boolean).join(" "),
89
- children: [/* @__PURE__ */ jsx(TocList, {
90
- entries,
91
- activeId,
92
- onSelect: setActiveId
93
- }), /* @__PURE__ */ jsx("a", {
94
- className: "wave-docs-toc__top",
95
- href: `#${DOCS_CONTENT_ID}`,
96
- children: topLabel
97
- })]
89
+ children: [
90
+ /* @__PURE__ */ jsx("p", {
91
+ className: "wave-docs-toc__title",
92
+ children: label
93
+ }),
94
+ /* @__PURE__ */ jsx(TocList, {
95
+ entries,
96
+ activeId,
97
+ onSelect: setActiveId
98
+ }),
99
+ /* @__PURE__ */ jsx("a", {
100
+ className: "wave-docs-toc__top",
101
+ href: `#${DOCS_CONTENT_ID}`,
102
+ children: topLabel
103
+ })
104
+ ]
98
105
  });
99
106
  }
100
107
  function TocList({ entries, activeId, onSelect }) {
package/dist/render.js CHANGED
@@ -431,7 +431,8 @@ function createDocsRenderer(options) {
431
431
  relativePath: file.relativePath
432
432
  };
433
433
  const hast = await processor.run(processor.parse(vfile), vfile);
434
- if (titleHeading && !hasHeadingOne(hast)) hast.children.unshift(titleHeadingNode(file.frontmatter.title));
434
+ const hasHero = (file.frontmatter.actions?.length ?? 0) > 0;
435
+ if (titleHeading && !hasHero && !hasHeadingOne(hast)) hast.children.unshift(titleHeadingNode(file.frontmatter.title));
435
436
  await resolveImages(hast, file, imageResolver);
436
437
  assertLinks(file, vfile.data.docLinks ?? []);
437
438
  assertOwnAnchors(file, hast, vfile.data.docLinks ?? []);
package/dist/source.js CHANGED
@@ -18,7 +18,7 @@ const INDEX_NAME = "index";
18
18
  * ⚠️ THE SCAN USED TO OPEN EVERY MARKDOWN FILE AT ONCE. `scanDir` recursed into
19
19
  * its subdirectories in parallel and read that directory's pages with a bare
20
20
  * `Promise.all`, so the number of `readFile` calls in flight equalled the number
21
- * of markdown files in the entire tree. On a 1,200-page corpus and the common
21
+ * of markdown files in the entire tree. On a 1,201-page corpus and the common
22
22
  * 1,024-descriptor soft limit, `next build` died with a bare `EMFILE: too many
23
23
  * open files` — no error code, no mention that this was the docs scan, and
24
24
  * nothing pointing at the fix. Exactly the large content set this package is