@waveso/docs 0.9.1 → 0.11.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/README.md CHANGED
@@ -69,16 +69,16 @@ Every figure below is a **ceiling**, and `pnpm size` fails the build if the meas
69
69
 
70
70
  | | At most |
71
71
  | --- | --- |
72
- | Everything the quick start ships, gzipped | 14.5 KB |
72
+ | Everything the quick start ships, gzipped | 14.6 KB |
73
73
  | Search dialog and router wiring | 9.5 KB |
74
74
  | Navigation: one sidebar, open and closed | 3.1 KB |
75
- | Table of contents | 0.9 KB |
75
+ | Table of contents | 1 KB |
76
76
  | Copy-button runtime | 1.1 KB |
77
77
  | hast over the wire vs HTML, prose page | 1.20× |
78
78
  | hast over the wire vs HTML, code and tables | 1.12× |
79
79
  | Highlighting vs no highlighting | 2.00× |
80
80
 
81
- The first row is the honest total: a reader who lands on a page of your documentation downloads under 14.5 KB gzipped of JavaScript from this package, and that is the whole of it. No markdown parser and no syntax highlighter reach the browser at all — those run in Node at build time. Drop the search dialog and it is under 4 KB.
81
+ The first row is the honest total: a reader who lands on a page of your documentation downloads under 14.6 KB gzipped of JavaScript from this package, and that is the whole of it. No markdown parser and no syntax highlighter reach the browser at all — those run in Node at build time. Drop the search dialog and it is under 4 KB.
82
82
 
83
83
  The one real cost is the middle pair: shipping a tree instead of a string is about 20% more brotli on a prose page, and about 12% on a page with code and tables, where Shiki's token spans dominate both representations equally. That is the price of never handing markup to `dangerouslySetInnerHTML`, and it is the first number a skeptical reviewer should ask for.
84
84
 
@@ -203,6 +203,8 @@ Every component takes data as props, and every module that imports from `next/*`
203
203
  | `DocContent` | `react/doc-content` | Renders a hast tree, inside `.wave-docs-prose`. Server Component |
204
204
  | `DocsHero` | `react/hero` | A landing page's header. `title`, `description`, `actions`, `Link`, `externalLabel`. Server Component |
205
205
  | `DocsSidebar` | `react/sidebar` | Takes `pathname` as a prop, not from `next/navigation`. `icons` controls the marker column — see [Sidebar icons](#sidebar-icons) |
206
+ | `DocsExplore` | `react/explore` | "Where to go next": a question per row and the page that answers it. `docs.Page` renders it when a page declares `explore` |
207
+ | `DocsPager` | `react/pager` | Links to the pages either side of this one. `docs.Page` renders it; `pager: false` on the route omits it |
206
208
  | `DocsToc` | `react/toc` | Scrollspy via `IntersectionObserver`. `label`, `topLabel`, `rootMargin`, `className` |
207
209
  | `DocsSearch` | `react/next-search` | `SearchDialog`, wired to Next's router. What you want |
208
210
  | `DocsLink` | `react/next-link` | `next/link`, adapted — pass it as `Link` when composing by hand |
@@ -214,6 +216,58 @@ Every component takes data as props, and every module that imports from `next/*`
214
216
 
215
217
  `DocsToc`'s `rootMargin` is the `IntersectionObserver` margin that decides how far above the viewport a heading counts as current; the default keeps the highlight on the section you are reading rather than the one about to arrive. `topLabel` is the back-to-top link at the end — it fades in once the reader is about a third of a screen down and fades out again on the way back, on a scroll timeline rather than a scroll listener, so the component ships no extra bytes to do it. Where that timeline cannot run — an engine without scroll-driven animations, a page too short to scroll, or a host that scrolls an inner pane rather than the document — the link is simply always there.
216
218
 
219
+ ### Where to go next
220
+
221
+ A sidebar is a structure; this is a router. It says *why* a reader would go somewhere, which no tree of titles can. A page opts in from its frontmatter:
222
+
223
+ ```yaml
224
+ ---
225
+ title: How it fits together
226
+ explore:
227
+ - question: How a person is recognised across servers
228
+ href: ./identity.md
229
+ - question: What happens when the network fails
230
+ href: ./delivery.md
231
+ - question: Where the source lives
232
+ href: https://github.com/example/repo
233
+ title: GitHub
234
+ ---
235
+ ```
236
+
237
+ | Field | Type | What it is |
238
+ | --- | --- | --- |
239
+ | `question` | `string` | What the reader might want to know. The row's left half |
240
+ | `href` | `string` | Where the answer is. Checked against the same allowlist as every other link |
241
+ | `title` | `string` | The link's text. Defaults to the destination's title **in the navigation** |
242
+
243
+ **The link text comes from the navigation**, so renaming a page updates every block pointing at it — the same source the sidebar and the pager read. Name a `title` only where the tree cannot answer: an external link, or a page kept out of the navigation. An `href` that resolves to neither stops the build and says which of the two fixes to reach for, rather than rendering a URL where a sentence should be.
244
+
245
+ It renders above the pager: this is the semantic answer, the pager is the linear one.
246
+
247
+ It wears **the panel** — a framed block with a header and an inset surface, styled by `.wave-docs-panel`, `.wave-docs-panel__header`, `.wave-docs-panel__title`, `.wave-docs-panel__actions` and `.wave-docs-panel__body`. That is a shared primitive rather than this component's furniture, so a code frame can wear it next without a second copy of the same rules. The two radii are not independent: the inner one is the outer minus the panel's padding, or the corners run at different curvatures and the surface reads as pasted onto the frame instead of set into it. `--wave-docs-radius-lg` is picked so the arithmetic lands on `--wave-docs-radius`.
248
+
249
+ The panel also exports `--wave-docs-panel-inset` for anything placed inside `__body`: the header's title sits at the frame's padding, but body content sits at that padding *plus the body's own border*, so the two columns miss each other by a pixel per border unless the inset is used.
250
+
251
+ `DocsExplore` takes `steps` (each with `question`, `href` and a resolved `title`), plus `heading`, `Link`, `externalLabel` and `className`. The heading defaults to `'Where to go next'` and is `explore` in `labels`. Under a 40rem container the question and its answer stack instead of sharing a row.
252
+
253
+ ### The pager
254
+
255
+ `docs.Page` renders it under every page, so most sites never touch it. The order is the navigation's — flattened from the same tree `DocsSidebar` renders — so a pager that disagrees with the sidebar beside it is impossible. Nothing is authored: a page gets one by being in the tree, and a page outside it (a draft, or a route you render yourself) gets none.
256
+
257
+ Separators and external links are not stops. A separator is a label with nowhere to go, and a "next page" that lands on npm has ended the sequence rather than continued it. A directory with an `index.md` contributes its own page before its children, which is the order its rows appear in.
258
+
259
+ | Prop | Type | Default | What it is |
260
+ | --- | --- | --- | --- |
261
+ | `previous` | `NavStop` | — | The stop before this page. Omit at the beginning |
262
+ | `next` | `NavStop` | — | The stop after it. Omit at the end |
263
+ | `Link` | `DocsLinkComponent` | `<a>` | Client-side router link |
264
+ | `previousLabel` | `string` | `'Previous'` | Above the previous page's title |
265
+ | `nextLabel` | `string` | `'Next'` | Above the next page's title |
266
+ | `label` | `string` | `'Pagination'` | Accessible name for the landmark |
267
+ | `className` | `string` | — | Extra classes |
268
+
269
+ With neither neighbour it renders nothing at all, rather than an empty landmark. Set `pager: false` on `createDocsRoute` to omit it everywhere, and the three strings through `labels` — `previousPage`, `nextPage`, `pagination`.
270
+
217
271
  ### Sidebar icons
218
272
 
219
273
  Every row in the sidebar carries a marker at its head: a folder on a group, a page on a page, an arrow on a link that leaves your site. Weight and a chevron were the only difference before, and where categories and pages interleave that is not enough to scan.
@@ -667,6 +721,48 @@ your own `:root`, after the import:
667
721
  }
668
722
  ```
669
723
 
724
+ ### Corners
725
+
726
+ Three tiers, all derived from one root, so a box's radius is decided by what
727
+ *kind* of box it is rather than by how big it happens to be.
728
+
729
+ | Token | What takes it |
730
+ | --- | --- |
731
+ | `--wave-docs-radius-sm` | Inline chips, small controls, and focus rings drawn on those |
732
+ | `--wave-docs-radius` | Controls, overlays, and the panel's inset surface |
733
+ | `--wave-docs-radius-lg` | Every block in the reading flow, and the panel's outer edge |
734
+
735
+ **Retune all three from one line.** `--wave-docs-radius-base` is the root and
736
+ the other three are `calc()` off it, so a host already running `@waveso/ui`
737
+ points this package at their scale and every corner follows — including any
738
+ theme that moves it:
739
+
740
+ ```css
741
+ :root {
742
+ --wave-docs-radius-base: var(--radius);
743
+ }
744
+ ```
745
+
746
+ The numbers are `@waveso/ui`'s to begin with. A page running both should not
747
+ show two radius scales a few pixels apart, and taking theirs is how that is
748
+ guaranteed rather than kept in step by hand.
749
+
750
+ ⚠️ **`--wave-docs-radius-step` is not decoration.** The panel's inset surface
751
+ takes the base radius and its frame takes `--wave-docs-radius-lg`, which is the
752
+ base plus one step — so the two corners are concentric only while the frame's
753
+ *padding* is that same step, which is why it is paid out of the token rather
754
+ than written as `4px`. Move the root and both stay true; hard-code the padding
755
+ and they drift the first time anyone retunes the scale.
756
+
757
+ Where the browser supports `corner-shape`, every corner this package draws
758
+ becomes a squircle and the root moves up, because a squircle reads tighter than
759
+ a circular arc at the same radius. `@waveso/ui` makes the same move to the same
760
+ value. Elsewhere it is an ordinary rounded corner at the original scale — an
761
+ enhancement, never a dependency. The shaping is scoped to elements this package
762
+ owns rather than applied with `*`: this stylesheet is mounted inside somebody
763
+ else's page, and reshaping the host's corners is the same trespass as claiming
764
+ `html`.
765
+
670
766
  That works because **everything this stylesheet declares lives in a `@layer`** —
671
767
  `theme` for the tokens, `base` for element resets, `components` for the classes
672
768
  — and unlayered CSS outranks every layer regardless of specificity.
@@ -16,6 +16,33 @@ declare const CODE_COPY_ATTRIBUTE = "data-wave-docs-copy";
16
16
  * tab stop where a control should be.
17
17
  */
18
18
  declare const CODE_READY_ATTRIBUTE = "data-wave-docs-code-ready";
19
+ /**
20
+ * The copy button's three icons, as Lucide paths.
21
+ *
22
+ * ⚠️ PATHS FROM THE SAME SET AS EVERY OTHER ICON HERE, AND THEY WERE FONT
23
+ * GLYPHS. The button rendered `⧉` and swapped in `✓` and `×` through CSS
24
+ * `content` — three characters drawn by whatever font resolved, at whatever
25
+ * weight and baseline that font has, beside a sidebar, a pager and a search
26
+ * dialog that are all Lucide at `stroke-width: 2`. It read as a different
27
+ * icon set because it was one.
28
+ *
29
+ * `copy`, `check` and `x`, on the 24×24 grid the rest of the package uses.
30
+ * Lucide draws its `copy` as a `<rect>` plus a `<path>`; the rect is written
31
+ * here as the path it is, so a frame's icons are one shape of node and the
32
+ * builder stays a list of `d` strings — the same shape `NAV_ICON_PATHS` has.
33
+ */
34
+ declare const CODE_ICON_PATHS: Record<'copy' | 'check' | 'x', string[]>;
35
+ /**
36
+ * The three states the button draws, in the order they are emitted.
37
+ *
38
+ * `idle` is what a reader sees; the runtime writes `data-copied` on the button
39
+ * and the stylesheet swaps which of the three is displayed. `display`, not
40
+ * `visibility`: the button is `visibility: hidden` until the runtime attaches,
41
+ * and `visibility` inherits — a child setting it back to `visible` would show
42
+ * an icon inside a button that is meant to be invisible and out of the tab
43
+ * order.
44
+ */
45
+ declare const CODE_ICON_STATES: readonly [readonly ["idle", "copy"], readonly ["copied", "check"], readonly ["failed", "x"]];
19
46
  /**
20
47
  * Whether a tree contains a code frame.
21
48
  *
@@ -26,4 +53,4 @@ declare const CODE_READY_ATTRIBUTE = "data-wave-docs-code-ready";
26
53
  */
27
54
  declare function hasCodeFrame(tree: Root): boolean;
28
55
  //#endregion
29
- export { CODE_COPY_ATTRIBUTE, CODE_FRAME_ATTRIBUTE, CODE_READY_ATTRIBUTE, hasCodeFrame };
56
+ export { CODE_COPY_ATTRIBUTE, CODE_FRAME_ATTRIBUTE, CODE_ICON_PATHS, CODE_ICON_STATES, CODE_READY_ATTRIBUTE, hasCodeFrame };
@@ -16,6 +16,41 @@ const CODE_COPY_ATTRIBUTE = "data-wave-docs-copy";
16
16
  */
17
17
  const CODE_READY_ATTRIBUTE = "data-wave-docs-code-ready";
18
18
  /**
19
+ * The copy button's three icons, as Lucide paths.
20
+ *
21
+ * ⚠️ PATHS FROM THE SAME SET AS EVERY OTHER ICON HERE, AND THEY WERE FONT
22
+ * GLYPHS. The button rendered `⧉` and swapped in `✓` and `×` through CSS
23
+ * `content` — three characters drawn by whatever font resolved, at whatever
24
+ * weight and baseline that font has, beside a sidebar, a pager and a search
25
+ * dialog that are all Lucide at `stroke-width: 2`. It read as a different
26
+ * icon set because it was one.
27
+ *
28
+ * `copy`, `check` and `x`, on the 24×24 grid the rest of the package uses.
29
+ * Lucide draws its `copy` as a `<rect>` plus a `<path>`; the rect is written
30
+ * here as the path it is, so a frame's icons are one shape of node and the
31
+ * builder stays a list of `d` strings — the same shape `NAV_ICON_PATHS` has.
32
+ */
33
+ const CODE_ICON_PATHS = {
34
+ copy: ["M10 8h10a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H10a2 2 0 0 1-2-2V10a2 2 0 0 1 2-2z", "M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"],
35
+ check: ["M20 6 9 17l-5-5"],
36
+ x: ["M18 6 6 18", "m6 6 12 12"]
37
+ };
38
+ /**
39
+ * The three states the button draws, in the order they are emitted.
40
+ *
41
+ * `idle` is what a reader sees; the runtime writes `data-copied` on the button
42
+ * and the stylesheet swaps which of the three is displayed. `display`, not
43
+ * `visibility`: the button is `visibility: hidden` until the runtime attaches,
44
+ * and `visibility` inherits — a child setting it back to `visible` would show
45
+ * an icon inside a button that is meant to be invisible and out of the tab
46
+ * order.
47
+ */
48
+ const CODE_ICON_STATES = [
49
+ ["idle", "copy"],
50
+ ["copied", "check"],
51
+ ["failed", "x"]
52
+ ];
53
+ /**
19
54
  * Whether a tree contains a code frame.
20
55
  *
21
56
  * `DocContent` asks before rendering the runtime, so a page with no fences
@@ -38,4 +73,4 @@ function isCodeFrame(node) {
38
73
  return node.properties[CODE_FRAME_ATTRIBUTE] !== void 0;
39
74
  }
40
75
  //#endregion
41
- export { CODE_COPY_ATTRIBUTE, CODE_FRAME_ATTRIBUTE, CODE_READY_ATTRIBUTE, hasCodeFrame };
76
+ export { CODE_COPY_ATTRIBUTE, CODE_FRAME_ATTRIBUTE, CODE_ICON_PATHS, CODE_ICON_STATES, CODE_READY_ATTRIBUTE, hasCodeFrame };
@@ -33,6 +33,11 @@ declare const docFrontmatterSchema: z.ZodObject<{
33
33
  aliases: z.ZodExactOptional<z.ZodArray<z.ZodString>>;
34
34
  order: z.ZodExactOptional<z.ZodNumber>;
35
35
  icon: z.ZodExactOptional<z.ZodString>;
36
+ explore: z.ZodExactOptional<z.ZodArray<z.ZodObject<{
37
+ question: z.ZodString;
38
+ href: z.ZodString;
39
+ title: z.ZodExactOptional<z.ZodString>;
40
+ }, z.core.$strip>>>;
36
41
  actions: z.ZodExactOptional<z.ZodArray<z.ZodObject<{
37
42
  label: z.ZodString;
38
43
  href: z.ZodString;
@@ -33,6 +33,11 @@ const docFrontmatterSchema = z.object({
33
33
  aliases: z.array(z.string()).exactOptional(),
34
34
  order: z.number().exactOptional(),
35
35
  icon: z.string().min(1).exactOptional(),
36
+ explore: z.array(z.object({
37
+ question: z.string().min(1),
38
+ href: z.string().min(1).refine(isSafeHref, "must be a safe URL (no javascript: or data:)"),
39
+ title: z.string().min(1).exactOptional()
40
+ })).exactOptional(),
36
41
  actions: z.array(z.object({
37
42
  label: z.string().min(1),
38
43
  href: z.string().min(1).refine(isSafeHref, "must be a safe URL (no javascript: or data:)"),
@@ -0,0 +1,43 @@
1
+ import { DocNavNode } from "./types.js";
2
+ //#region src/nav-order.d.ts
3
+ /** A destination in the reading order. */
4
+ interface NavStop {
5
+ title: string;
6
+ href: string;
7
+ }
8
+ /**
9
+ * Every page in the tree, in the order a reader meets them.
10
+ *
11
+ * ⚠️ SEPARATORS AND EXTERNAL LINKS ARE NOT STOPS. A separator is a label with
12
+ * nowhere to go, and an external link leaves the documentation entirely — a
13
+ * "next page" that lands on npm has ended the sequence rather than continued
14
+ * it. Internal `link` entries *are* stops: they are hand-written entries
15
+ * pointing at pages of this site, and a reader clicking down the sidebar hits
16
+ * them like any other row.
17
+ *
18
+ * ⚠️ A GROUP CONTRIBUTES ITS OWN PAGE FIRST, WHEN IT HAS ONE. A directory with
19
+ * an `index.md` renders as a link *and* a disclosure, so the reading order is
20
+ * the group's page and then its children — which is the order the rows appear
21
+ * in, and the order someone reading the section would take them.
22
+ */
23
+ declare function readingOrder(nodes: DocNavNode[], into?: NavStop[]): NavStop[];
24
+ /** The stops either side of `href`, or `undefined` at each end of the sequence. */
25
+ declare function neighbours(nodes: DocNavNode[], href: string): {
26
+ previous?: NavStop;
27
+ next?: NavStop;
28
+ };
29
+ /**
30
+ * A step's link text: the one it named, or the navigation's name for the page.
31
+ *
32
+ * ⚠️ A THROW, NOT A FALLBACK TO THE HREF. A row reading "Where this runs and
33
+ * what that buys → /docs/infrastructure" is a URL where a sentence should be,
34
+ * and it renders perfectly — nothing else in the pipeline would notice. The
35
+ * frontmatter is authored and the author is right there, so the build stops and
36
+ * says which page and which of the two fixes to reach for.
37
+ */
38
+ declare function stepTitle(stops: NavStop[], step: {
39
+ href: string;
40
+ title?: string | undefined;
41
+ }): string | undefined;
42
+ //#endregion
43
+ export { NavStop, neighbours, readingOrder, stepTitle };
@@ -0,0 +1,72 @@
1
+ //#region src/nav-order.ts
2
+ /** Trailing slashes are a routing detail, not a difference in identity. */
3
+ function normalize(href) {
4
+ return href.length > 1 ? href.replace(/\/+$/, "") : href;
5
+ }
6
+ /**
7
+ * Every page in the tree, in the order a reader meets them.
8
+ *
9
+ * ⚠️ SEPARATORS AND EXTERNAL LINKS ARE NOT STOPS. A separator is a label with
10
+ * nowhere to go, and an external link leaves the documentation entirely — a
11
+ * "next page" that lands on npm has ended the sequence rather than continued
12
+ * it. Internal `link` entries *are* stops: they are hand-written entries
13
+ * pointing at pages of this site, and a reader clicking down the sidebar hits
14
+ * them like any other row.
15
+ *
16
+ * ⚠️ A GROUP CONTRIBUTES ITS OWN PAGE FIRST, WHEN IT HAS ONE. A directory with
17
+ * an `index.md` renders as a link *and* a disclosure, so the reading order is
18
+ * the group's page and then its children — which is the order the rows appear
19
+ * in, and the order someone reading the section would take them.
20
+ */
21
+ function readingOrder(nodes, into = []) {
22
+ for (const node of nodes) switch (node.type) {
23
+ case "page":
24
+ into.push({
25
+ title: node.title,
26
+ href: node.href
27
+ });
28
+ break;
29
+ case "link":
30
+ if (!node.external) into.push({
31
+ title: node.title,
32
+ href: node.href
33
+ });
34
+ break;
35
+ case "group":
36
+ if (node.href !== void 0) into.push({
37
+ title: node.title,
38
+ href: node.href
39
+ });
40
+ readingOrder(node.children, into);
41
+ }
42
+ return into;
43
+ }
44
+ /** The stops either side of `href`, or `undefined` at each end of the sequence. */
45
+ function neighbours(nodes, href) {
46
+ const stops = readingOrder(nodes);
47
+ const here = normalize(href);
48
+ const at = stops.findIndex((stop) => normalize(stop.href) === here);
49
+ if (at === -1) return {};
50
+ const previous = stops[at - 1];
51
+ const next = stops[at + 1];
52
+ return {
53
+ ...previous === void 0 ? {} : { previous },
54
+ ...next === void 0 ? {} : { next }
55
+ };
56
+ }
57
+ /**
58
+ * A step's link text: the one it named, or the navigation's name for the page.
59
+ *
60
+ * ⚠️ A THROW, NOT A FALLBACK TO THE HREF. A row reading "Where this runs and
61
+ * what that buys → /docs/infrastructure" is a URL where a sentence should be,
62
+ * and it renders perfectly — nothing else in the pipeline would notice. The
63
+ * frontmatter is authored and the author is right there, so the build stops and
64
+ * says which page and which of the two fixes to reach for.
65
+ */
66
+ function stepTitle(stops, step) {
67
+ if (step.title !== void 0) return step.title;
68
+ const here = normalize(step.href);
69
+ return stops.find((stop) => normalize(stop.href) === here)?.title;
70
+ }
71
+ //#endregion
72
+ export { neighbours, readingOrder, stepTitle };
package/dist/next.d.ts CHANGED
@@ -13,6 +13,16 @@ import { Options } from "minisearch";
13
13
  interface DocsRouteOptions<TFrontmatter extends DocFrontmatter = DocFrontmatter> extends DocsConfig<TFrontmatter> {
14
14
  /** Overrides merged over the Next-flavoured defaults (`next/link` + `next/image`). */
15
15
  components?: MarkdownComponents | undefined;
16
+ /**
17
+ * Links to the pages either side of this one, under every page. Default on.
18
+ *
19
+ * The order is the navigation's, so it cannot disagree with the sidebar —
20
+ * see `nav-order.ts`. Nothing is authored: a page gets a pager by being in
21
+ * the tree, and a page outside it gets none.
22
+ *
23
+ * `false` omits it, for a host whose own layout already ends a page.
24
+ */
25
+ pager?: boolean | undefined;
16
26
  /** Reuse an existing Shiki highlighter. */
17
27
  highlighter?: DocsHighlighter | Promise<DocsHighlighter> | undefined;
18
28
  /** Grammars to load, when building the default highlighter. */
package/dist/next.js CHANGED
@@ -3,10 +3,13 @@ import { docsError } from "./docs-error.js";
3
3
  import { DOCS_CONTENT_ID } from "./docs-content-id.js";
4
4
  import { describeSuggestion } from "./link-suggestion.js";
5
5
  import { mapPooled } from "./map-pooled.js";
6
+ import { neighbours, readingOrder, stepTitle } from "./nav-order.js";
6
7
  import { findFunctionValuedOptions } from "./search-options.js";
7
8
  import { createMarkdownComponents } from "./react/markdown-components.js";
8
9
  import { DocContent } from "./react/doc-content.js";
9
10
  import { DocsHero } from "./react/hero.js";
11
+ import { DocsPager } from "./react/pager.js";
12
+ import { DocsExplore } from "./react/explore.js";
10
13
  import { DocsToc } from "./react/toc.js";
11
14
  import { wrapNextLink } from "./react/link-adapter.js";
12
15
  import { createDocsRenderer } from "./render.js";
@@ -427,6 +430,25 @@ function createDocsRoute(options) {
427
430
  etag: `"${createHash("sha1").update(json).digest("hex")}"`
428
431
  } });
429
432
  };
433
+ /**
434
+ * A page's `next` rows with their link text filled in.
435
+ *
436
+ * ⚠️ THE ERROR IS THE POINT. Falling back to the href renders a URL where a
437
+ * sentence should be, on a page that builds cleanly — so an unresolvable row
438
+ * stops the build and names both fixes.
439
+ */
440
+ async function resolveExplore(doc) {
441
+ const stops = readingOrder(await requestScopedSource.nav());
442
+ return (doc.frontmatter.explore ?? []).map((step) => {
443
+ const title = stepTitle(stops, step);
444
+ if (title === void 0) throw docsError("invalid-frontmatter", `${doc.href}: the "explore" entry pointing at "${step.href}" has no title, and no page in the navigation owns that route. Point it at a page in the tree, or give the entry its own \`title\` — which is what an external link or a page kept out of the navigation needs.`);
445
+ return {
446
+ question: step.question,
447
+ href: step.href,
448
+ title
449
+ };
450
+ });
451
+ }
430
452
  async function renderRoute(segments) {
431
453
  const doc = await getPage(segments);
432
454
  if (doc === void 0) return (await loadNotFound())();
@@ -448,6 +470,17 @@ function createDocsRoute(options) {
448
470
  ...options.components
449
471
  },
450
472
  ...copyLabels === void 0 ? {} : { labels: copyLabels }
473
+ }), (doc.frontmatter.explore?.length ?? 0) === 0 ? null : createElement(DocsExplore, {
474
+ links: await resolveExplore(doc),
475
+ Link: link,
476
+ ...routeLabels?.explore === void 0 ? {} : { heading: routeLabels.explore },
477
+ ...routeLabels?.externalLink === void 0 ? {} : { externalLabel: routeLabels.externalLink }
478
+ }), options.pager === false ? null : createElement(DocsPager, {
479
+ ...neighbours(await requestScopedSource.nav(), doc.href),
480
+ Link: link,
481
+ ...routeLabels?.previousPage === void 0 ? {} : { previousLabel: routeLabels.previousPage },
482
+ ...routeLabels?.nextPage === void 0 ? {} : { nextLabel: routeLabels.nextPage },
483
+ ...routeLabels?.pagination === void 0 ? {} : { label: routeLabels.pagination }
451
484
  })), doc.toc.length === 0 ? null : createElement("aside", { className: "wave-docs-layout__toc" }, createElement(DocsToc, {
452
485
  entries: doc.toc,
453
486
  ...routeLabels?.toc === void 0 ? {} : { label: routeLabels.toc },
@@ -1,4 +1,4 @@
1
- import { CODE_COPY_ATTRIBUTE, CODE_FRAME_ATTRIBUTE } from "../code-frame.js";
1
+ import { CODE_COPY_ATTRIBUTE, CODE_FRAME_ATTRIBUTE, CODE_ICON_PATHS, CODE_ICON_STATES } from "../code-frame.js";
2
2
  import { parseCodeMeta } from "../code-meta.js";
3
3
  import { CONTINUE, SKIP, visit } from "unist-util-visit";
4
4
  //#region src/plugins/rehype-code-frame.ts
@@ -28,12 +28,17 @@ const rehypeCodeFrame = (options = {}) => {
28
28
  value: title
29
29
  }]
30
30
  });
31
- children.push(copyButton(title === void 0 ? copyLabel : copyFromLabel.replace("{title}", title)), node);
31
+ children.push(copyButton(title === void 0 ? copyLabel : copyFromLabel.replace("{title}", title)), {
32
+ type: "element",
33
+ tagName: "div",
34
+ properties: { className: ["wave-docs-panel__body", "wave-docs-code__body"] },
35
+ children: [node]
36
+ });
32
37
  parent.children[index] = {
33
38
  type: "element",
34
39
  tagName: "figure",
35
40
  properties: {
36
- className: ["wave-docs-code"],
41
+ className: ["wave-docs-panel", "wave-docs-code"],
37
42
  [CODE_FRAME_ATTRIBUTE]: "",
38
43
  ...language === void 0 ? {} : { "data-lang": language }
39
44
  },
@@ -60,15 +65,40 @@ function copyButton(label) {
60
65
  [CODE_COPY_ATTRIBUTE]: "",
61
66
  "aria-label": label
62
67
  },
63
- children: [{
68
+ children: CODE_ICON_STATES.map(([state, name]) => stateIcon(state, name))
69
+ };
70
+ }
71
+ /**
72
+ * One Lucide glyph, as hast.
73
+ *
74
+ * Decoration, so it is out of the accessibility tree — the button reads as its
75
+ * `aria-label` — and out of the search index by the same rule that drops the
76
+ * heading anchor icons.
77
+ */
78
+ function stateIcon(state, name) {
79
+ return {
80
+ type: "element",
81
+ tagName: "svg",
82
+ properties: {
83
+ className: ["wave-docs-code__copy-icon"],
84
+ "data-state": state,
85
+ "aria-hidden": "true",
86
+ focusable: "false",
87
+ viewBox: "0 0 24 24",
88
+ width: "16",
89
+ height: "16",
90
+ fill: "none",
91
+ stroke: "currentColor",
92
+ strokeWidth: "2",
93
+ strokeLinecap: "round",
94
+ strokeLinejoin: "round"
95
+ },
96
+ children: CODE_ICON_PATHS[name].map((d) => ({
64
97
  type: "element",
65
- tagName: "span",
66
- properties: { "aria-hidden": "true" },
67
- children: [{
68
- type: "text",
69
- value: "⧉"
70
- }]
71
- }]
98
+ tagName: "path",
99
+ properties: { d },
100
+ children: []
101
+ }))
72
102
  };
73
103
  }
74
104
  /** `code.data.meta` — read, never written. */
@@ -0,0 +1,53 @@
1
+ import { DocsLinkComponent } from "./markdown-components.js";
2
+ import { ReactNode } from "react";
3
+ //#region src/react/explore.d.ts
4
+ /** One row: a question, and the page that answers it. */
5
+ interface DocsExploreLink {
6
+ question: string;
7
+ href: string;
8
+ /** Resolved by the caller — from the navigation, or from the frontmatter. */
9
+ title: string;
10
+ }
11
+ interface DocsExploreProps {
12
+ links: DocsExploreLink[];
13
+ /** The block's heading. Defaults to `'Where to go next'`. */
14
+ heading?: string | undefined;
15
+ /** Client-side router link, e.g. `next/link`. Falls back to `<a>`. */
16
+ Link?: DocsLinkComponent | undefined;
17
+ /**
18
+ * Screen-reader suffix on a row whose answer is off-site. Defaults to
19
+ * `'(opens in a new tab)'`.
20
+ */
21
+ externalLabel?: string | undefined;
22
+ className?: string | undefined;
23
+ }
24
+ /**
25
+ * "Where to go next" — a question per row, and the page that answers it.
26
+ *
27
+ * The sidebar is a structure and this is a router: it says *why* a reader would
28
+ * go somewhere, which no tree of titles can. A page opts in by declaring
29
+ * `explore` in its frontmatter, so the same component is a landing page's
30
+ * onboarding and an ordinary page's footnote.
31
+ *
32
+ * ⚠️ `explore`, NOT `next` — AND NOT `steps` EITHER.
33
+ *
34
+ * `next` is unusable in this package: `src/next.ts` is the Next.js adapter, so
35
+ * two files one directory apart would carry the same name for entirely
36
+ * different things, and `doc.frontmatter.next` would read like a routing hook.
37
+ *
38
+ * `steps` is wrong for a second reason: these rows are a *branch*, not a
39
+ * sequence. A reader picks one and ignores the rest; none of them is first. A
40
+ * numbered "1 → 2 → 3" component is a real and separate thing worth building,
41
+ * and `steps` is the name it will need.
42
+ *
43
+ * ⚠️ A LIST, NOT A TABLE — which is what the markdown this replaces had to be.
44
+ * A screen reader announces "table, 2 columns, 7 rows" for what is a list of
45
+ * links with descriptions, and asks the reader to navigate it by cell. Two
46
+ * columns of sentence-length questions are also cramped on a phone, where this
47
+ * stacks instead.
48
+ *
49
+ * No client JavaScript: a heading and a list of links, rendered on the server.
50
+ */
51
+ declare function DocsExplore({ links, heading, Link, externalLabel, className }: DocsExploreProps): ReactNode;
52
+ //#endregion
53
+ export { DocsExplore, DocsExploreLink, DocsExploreProps };
@@ -0,0 +1,86 @@
1
+ import { opensInNewTab } from "../safe-href.js";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ //#region src/react/explore.tsx
4
+ /**
5
+ * "Where to go next" — a question per row, and the page that answers it.
6
+ *
7
+ * The sidebar is a structure and this is a router: it says *why* a reader would
8
+ * go somewhere, which no tree of titles can. A page opts in by declaring
9
+ * `explore` in its frontmatter, so the same component is a landing page's
10
+ * onboarding and an ordinary page's footnote.
11
+ *
12
+ * ⚠️ `explore`, NOT `next` — AND NOT `steps` EITHER.
13
+ *
14
+ * `next` is unusable in this package: `src/next.ts` is the Next.js adapter, so
15
+ * two files one directory apart would carry the same name for entirely
16
+ * different things, and `doc.frontmatter.next` would read like a routing hook.
17
+ *
18
+ * `steps` is wrong for a second reason: these rows are a *branch*, not a
19
+ * sequence. A reader picks one and ignores the rest; none of them is first. A
20
+ * numbered "1 → 2 → 3" component is a real and separate thing worth building,
21
+ * and `steps` is the name it will need.
22
+ *
23
+ * ⚠️ A LIST, NOT A TABLE — which is what the markdown this replaces had to be.
24
+ * A screen reader announces "table, 2 columns, 7 rows" for what is a list of
25
+ * links with descriptions, and asks the reader to navigate it by cell. Two
26
+ * columns of sentence-length questions are also cramped on a phone, where this
27
+ * stacks instead.
28
+ *
29
+ * No client JavaScript: a heading and a list of links, rendered on the server.
30
+ */
31
+ function DocsExplore({ links, heading = "Where to go next", Link, externalLabel = "(opens in a new tab)", className }) {
32
+ if (links.length === 0) return null;
33
+ return /* @__PURE__ */ jsxs("section", {
34
+ className: [
35
+ "wave-docs-panel",
36
+ "wave-docs-explore",
37
+ className
38
+ ].filter(Boolean).join(" "),
39
+ "aria-labelledby": "wave-docs-explore-heading",
40
+ children: [/* @__PURE__ */ jsx("div", {
41
+ className: "wave-docs-panel__header",
42
+ children: /* @__PURE__ */ jsx("h2", {
43
+ className: "wave-docs-panel__title",
44
+ id: "wave-docs-explore-heading",
45
+ children: heading
46
+ })
47
+ }), /* @__PURE__ */ jsx("ul", {
48
+ className: "wave-docs-panel__body wave-docs-explore__list",
49
+ children: links.map((row) => /* @__PURE__ */ jsxs("li", {
50
+ className: "wave-docs-explore__item",
51
+ children: [/* @__PURE__ */ jsx("span", {
52
+ className: "wave-docs-explore__question",
53
+ children: row.question
54
+ }), /* @__PURE__ */ jsx(ExploreAnswer, {
55
+ row,
56
+ Link,
57
+ externalLabel
58
+ })]
59
+ }, row.href))
60
+ })]
61
+ });
62
+ }
63
+ function ExploreAnswer({ row, Link, externalLabel }) {
64
+ const className = "wave-docs-explore__answer";
65
+ if (opensInNewTab(row.href)) return /* @__PURE__ */ jsxs("a", {
66
+ className,
67
+ href: row.href,
68
+ target: "_blank",
69
+ rel: "noopener noreferrer",
70
+ children: [row.title, /* @__PURE__ */ jsxs("span", {
71
+ className: "wave-docs-sr-only",
72
+ children: [" ", externalLabel]
73
+ })]
74
+ });
75
+ return Link === void 0 ? /* @__PURE__ */ jsx("a", {
76
+ className,
77
+ href: row.href,
78
+ children: row.title
79
+ }) : /* @__PURE__ */ jsx(Link, {
80
+ className,
81
+ href: row.href,
82
+ children: row.title
83
+ });
84
+ }
85
+ //#endregion
86
+ export { DocsExplore };