@waveso/docs 0.4.0 → 0.6.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 (58) hide show
  1. package/CHANGELOG.md +270 -0
  2. package/README.md +221 -79
  3. package/dist/anchors.d.ts +44 -0
  4. package/dist/anchors.js +76 -0
  5. package/dist/errors.d.ts +4 -0
  6. package/dist/highlighter.js +2 -1
  7. package/dist/link-suggestion.d.ts +31 -0
  8. package/dist/link-suggestion.js +94 -0
  9. package/dist/meta.js +6 -9
  10. package/dist/next.d.ts +54 -14
  11. package/dist/next.js +135 -20
  12. package/dist/plugins/rehype-code-frame.d.ts +13 -1
  13. package/dist/plugins/rehype-code-frame.js +2 -1
  14. package/dist/plugins/rehype-fallback-heading-ids.js +1 -1
  15. package/dist/plugins/remark-doc-links.d.ts +83 -1
  16. package/dist/plugins/remark-doc-links.js +50 -23
  17. package/dist/plugins/remark-youtube.d.ts +18 -3
  18. package/dist/plugins/remark-youtube.js +57 -9
  19. package/dist/react/callout.d.ts +13 -1
  20. package/dist/react/callout.js +2 -2
  21. package/dist/react/code-runtime.d.ts +12 -2
  22. package/dist/react/code-runtime.js +28 -4
  23. package/dist/react/doc-content.d.ts +12 -1
  24. package/dist/react/doc-content.js +2 -2
  25. package/dist/react/layout.d.ts +27 -10
  26. package/dist/react/layout.js +6 -3
  27. package/dist/react/link-adapter.d.ts +34 -0
  28. package/dist/react/link-adapter.js +30 -0
  29. package/dist/react/markdown-components.d.ts +29 -1
  30. package/dist/react/markdown-components.js +69 -67
  31. package/dist/react/nav.d.ts +5 -1
  32. package/dist/react/nav.js +5 -2
  33. package/dist/react/next-link.d.ts +6 -28
  34. package/dist/react/next-link.js +45 -24
  35. package/dist/react/next-nav.d.ts +5 -1
  36. package/dist/react/next-nav.js +6 -3
  37. package/dist/react/next-search.js +1 -1
  38. package/dist/react/search-dialog.d.ts +59 -3
  39. package/dist/react/search-dialog.js +53 -9
  40. package/dist/react/shell-labels.d.ts +135 -21
  41. package/dist/react/shell-labels.js +47 -6
  42. package/dist/react/sidebar.d.ts +18 -1
  43. package/dist/react/sidebar.js +59 -23
  44. package/dist/react/youtube.d.ts +22 -1
  45. package/dist/react/youtube.js +22 -4
  46. package/dist/render.d.ts +12 -1
  47. package/dist/render.js +107 -21
  48. package/dist/route-path.js +7 -2
  49. package/dist/safe-href.d.ts +47 -0
  50. package/dist/safe-href.js +73 -0
  51. package/dist/search-index.js +1 -1
  52. package/dist/search-options.d.ts +64 -2
  53. package/dist/search-options.js +25 -1
  54. package/dist/semaphore.d.ts +46 -0
  55. package/dist/semaphore.js +60 -0
  56. package/dist/source.js +86 -12
  57. package/dist/types.d.ts +102 -6
  58. package/package.json +6 -3
@@ -1,24 +1,14 @@
1
+ import { isSafeHref, opensInNewTab } from "../safe-href.js";
1
2
  import { Callout } from "./callout.js";
2
3
  import { YouTube } from "./youtube.js";
3
4
  import { jsx, jsxs } from "react/jsx-runtime";
4
5
  //#region src/react/markdown-components.tsx
5
- /** Schemes we send to a new tab. `mailto:`/`tel:` are left to the OS. */
6
- const HTTP_SCHEME = /^https?:\/\//i;
7
- /** Any URL with a scheme, or protocol-relative. */
6
+ /** Default, and the only string here a reader sees without a screen reader. */
7
+ const DEFAULT_EXTERNAL_LINK = "(opens in a new tab)";
8
+ /** Default name for a wide table's scroll region. */
9
+ const DEFAULT_TABLE = "Table";
10
+ /** Any URL with a scheme, or protocol-relative. Decides router vs plain `<a>`. */
8
11
  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
12
  /** Hrefs already reported, so a re-render does not repeat the warning. */
23
13
  const warnedHrefs = /* @__PURE__ */ new Set();
24
14
  /**
@@ -32,35 +22,7 @@ const warnedHrefs = /* @__PURE__ */ new Set();
32
22
  function warnDroppedHref(href) {
33
23
  if (process.env.NODE_ENV === "production" || warnedHrefs.has(href)) return;
34
24
  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);
25
+ console.warn(`@waveso/docs: dropped a link to '${href}' — its scheme is not in the allowlist. Use http(s), mailto, tel or another documented scheme.`);
64
26
  }
65
27
  function joinClassNames(...values) {
66
28
  const joined = values.filter(Boolean).join(" ");
@@ -73,7 +35,7 @@ function toDimension(value) {
73
35
  return Number.isFinite(parsed) ? parsed : void 0;
74
36
  }
75
37
  }
76
- function createAnchor(Link) {
38
+ function createAnchor(Link, externalLink) {
77
39
  return function MarkdownAnchor({ href, children, ...rest }) {
78
40
  if (href === void 0) return /* @__PURE__ */ jsx("a", {
79
41
  ...rest,
@@ -83,14 +45,14 @@ function createAnchor(Link) {
83
45
  warnDroppedHref(href);
84
46
  return /* @__PURE__ */ jsx("span", { children });
85
47
  }
86
- if (HTTP_SCHEME.test(href) || href.startsWith("//")) return /* @__PURE__ */ jsxs("a", {
48
+ if (opensInNewTab(href)) return /* @__PURE__ */ jsxs("a", {
87
49
  ...rest,
88
50
  href,
89
51
  target: "_blank",
90
52
  rel: "noopener noreferrer",
91
- children: [children, /* @__PURE__ */ jsx("span", {
53
+ children: [children, /* @__PURE__ */ jsxs("span", {
92
54
  className: "wave-docs-sr-only",
93
- children: " (opens in a new tab)"
55
+ children: [" ", externalLink]
94
56
  })]
95
57
  });
96
58
  if (href.startsWith("#") || ABSOLUTE_URL.test(href) || Link === void 0) return /* @__PURE__ */ jsx("a", {
@@ -106,7 +68,7 @@ function createAnchor(Link) {
106
68
  };
107
69
  }
108
70
  function createImage(Image) {
109
- return function MarkdownImage({ src, alt, width, height, title, className, sizes, loading, ...rest }) {
71
+ return function MarkdownImage({ src, alt, width, height, title, className, sizes, loading, decoding, fetchPriority, ...rest }) {
110
72
  const resolvedWidth = toDimension(width);
111
73
  const resolvedHeight = toDimension(height);
112
74
  const resolvedLoading = loading ?? "lazy";
@@ -120,10 +82,11 @@ function createImage(Image) {
120
82
  title,
121
83
  className: resolvedClassName,
122
84
  sizes,
123
- loading: resolvedLoading
85
+ loading: resolvedLoading,
86
+ decoding: decoding ?? "async",
87
+ fetchPriority
124
88
  });
125
89
  return /* @__PURE__ */ jsx("img", {
126
- decoding: "async",
127
90
  ...rest,
128
91
  src,
129
92
  alt: alt ?? "",
@@ -132,7 +95,9 @@ function createImage(Image) {
132
95
  title,
133
96
  className: resolvedClassName,
134
97
  sizes,
135
- loading: resolvedLoading
98
+ loading: resolvedLoading,
99
+ decoding: decoding ?? "async",
100
+ fetchPriority
136
101
  });
137
102
  };
138
103
  }
@@ -146,16 +111,18 @@ function createImage(Image) {
146
111
  * `<section>` is a `region` landmark, so the tab stop announces itself instead
147
112
  * of being a mystery stop in the tab order.
148
113
  */
149
- function MarkdownTable({ className, ...rest }) {
150
- return /* @__PURE__ */ jsx("section", {
151
- className: "wave-docs-table-scroll",
152
- "aria-label": "Table",
153
- tabIndex: 0,
154
- children: /* @__PURE__ */ jsx("table", {
155
- ...rest,
156
- className: joinClassNames("wave-docs-table", className)
157
- })
158
- });
114
+ function createTable(label) {
115
+ return function MarkdownTable({ className, ...rest }) {
116
+ return /* @__PURE__ */ jsx("section", {
117
+ className: "wave-docs-table-scroll",
118
+ "aria-label": label,
119
+ tabIndex: 0,
120
+ children: /* @__PURE__ */ jsx("table", {
121
+ ...rest,
122
+ className: joinClassNames("wave-docs-table", className)
123
+ })
124
+ });
125
+ };
159
126
  }
160
127
  /**
161
128
  * Build the default component map, optionally injecting host-specific link and
@@ -173,12 +140,47 @@ function MarkdownTable({ className, ...rest }) {
173
140
  * ```
174
141
  */
175
142
  function createMarkdownComponents(options = {}) {
143
+ const labels = options.labels ?? {};
144
+ const calloutTitles = calloutTitleMap(labels);
176
145
  return {
177
- a: createAnchor(options.Link),
146
+ a: createAnchor(options.Link, labels.externalLink ?? DEFAULT_EXTERNAL_LINK),
178
147
  img: createImage(options.Image),
179
- table: MarkdownTable,
180
- callout: Callout,
181
- youtube: YouTube
148
+ table: createTable(labels.table ?? DEFAULT_TABLE),
149
+ callout: (props) => /* @__PURE__ */ jsx(Callout, {
150
+ ...props,
151
+ ...calloutTitles === void 0 ? {} : { labels: calloutTitles }
152
+ }),
153
+ youtube: (props) => /* @__PURE__ */ jsx(YouTube, { ...youtubeDefaults(labels, props) })
154
+ };
155
+ }
156
+ /** The five headings as `Callout` wants them, or `undefined` if none are set. */
157
+ function calloutTitleMap(labels) {
158
+ const titles = {};
159
+ let found = false;
160
+ for (const [type, key] of Object.entries(CALLOUT_LABEL_KEYS)) {
161
+ const value = labels[key];
162
+ if (value !== void 0) {
163
+ titles[type] = value;
164
+ found = true;
165
+ }
166
+ }
167
+ return found ? titles : void 0;
168
+ }
169
+ /** Which `DocsLabels` key names a given callout type's heading. */
170
+ const CALLOUT_LABEL_KEYS = {
171
+ note: "calloutNote",
172
+ tip: "calloutTip",
173
+ important: "calloutImportant",
174
+ warning: "calloutWarning",
175
+ caution: "calloutCaution"
176
+ };
177
+ /** `props`, with the site's YouTube strings filled in. */
178
+ function youtubeDefaults(labels, props) {
179
+ return {
180
+ ...props,
181
+ ...props.title === void 0 && labels.youtubeTitle !== void 0 ? { title: labels.youtubeTitle } : {},
182
+ ...labels.youtubePlay === void 0 ? {} : { playLabel: labels.youtubePlay },
183
+ ...labels.youtubeHide === void 0 ? {} : { hideLabel: labels.youtubeHide }
182
184
  };
183
185
  }
184
186
  /** The map used when a caller supplies none. Plain `<a>` and `<img>`. */
@@ -22,7 +22,11 @@ interface DocsNavProps {
22
22
  label?: string | undefined;
23
23
  /** Accessible name for the close button. */
24
24
  closeLabel?: string | undefined;
25
+ /** Passed through to the tree. See `DocsSidebarProps.expandGroup`. */
26
+ expandGroup?: string | undefined;
27
+ collapseGroup?: string | undefined;
28
+ externalLink?: string | undefined;
25
29
  }
26
- declare function DocsNav({ nav, pathname, Link, label, closeLabel }: DocsNavProps): ReactNode;
30
+ declare function DocsNav({ nav, pathname, Link, label, closeLabel, expandGroup, collapseGroup, externalLink }: DocsNavProps): ReactNode;
27
31
  //#endregion
28
32
  export { DOCS_NAV_ID, DocsNav, DocsNavProps };
package/dist/react/nav.js CHANGED
@@ -12,7 +12,7 @@ import { jsx, jsxs } from "react/jsx-runtime";
12
12
  * the button does nothing on the first tap.
13
13
  */
14
14
  const DOCS_NAV_ID = "wave-docs-nav";
15
- function DocsNav({ nav, pathname, Link, label = "Documentation", closeLabel = "Close navigation" }) {
15
+ function DocsNav({ nav, pathname, Link, label = "Documentation", closeLabel = "Close navigation", expandGroup, collapseGroup, externalLink }) {
16
16
  const ref = useRef(null);
17
17
  useEffect(() => {
18
18
  ref.current?.close?.();
@@ -62,7 +62,10 @@ function DocsNav({ nav, pathname, Link, label = "Documentation", closeLabel = "C
62
62
  nav,
63
63
  pathname,
64
64
  label,
65
- Link
65
+ Link,
66
+ ...expandGroup === void 0 ? {} : { expandGroup },
67
+ ...collapseGroup === void 0 ? {} : { collapseGroup },
68
+ ...externalLink === void 0 ? {} : { externalLink }
66
69
  })]
67
70
  });
68
71
  }
@@ -1,34 +1,12 @@
1
1
  import { DocsLinkComponent } from "./markdown-components.js";
2
- import { ComponentProps, ComponentType } from "react";
3
2
  //#region src/react/next-link.d.ts
4
3
  /**
5
- * The part of `next/link` this package uses.
4
+ * Module scope, and never inside a render.
6
5
  *
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`.
6
+ * A fresh component identity for a link remounts every link in the document on
7
+ * every render which is why `next-search.tsx` and `next-nav.tsx` each build
8
+ * theirs at module scope too, and why this is a constant rather than a factory.
10
9
  */
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;
10
+ declare const DocsLink: DocsLinkComponent;
33
11
  //#endregion
34
- export { NextLinkComponent, wrapNextLink };
12
+ export { DocsLink };
@@ -1,30 +1,51 @@
1
- import { createElement } from "react";
2
- //#region src/react/next-link.ts
1
+ "use client";
2
+ import { wrapNextLink } from "./link-adapter.js";
3
+ import NextLink from "next/link";
4
+ //#region src/react/next-link.tsx
3
5
  /**
4
- * Adapt `next/link` to {@link DocsLinkProps}.
6
+ * `next/link`, adapted to this package's {@link DocsLinkProps} and ready to pass.
5
7
  *
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.
8
+ * ⚠️ IT EXISTS BECAUSE PASSING `next/link` DIRECTLY DOES NOT TYPE-CHECK. Under
9
+ * `exactOptionalPropertyTypes` which this package compiles with, and which any
10
+ * consumer may turn on Next's `LinkProps` re-declares `onClick?`,
11
+ * `onMouseEnter?` and `onTouchStart?` *without* `| undefined` while React's
12
+ * anchor props include it, so `<DocsSidebar Link={Link} />` fails to compile
13
+ * over three handlers `next/link` accepts perfectly well at run time. It is a
14
+ * disagreement between two dependencies' declaration files, true of every
15
+ * `next/link` call site in a project with that flag on, and nothing the shape of
16
+ * `DocsLinkProps` can fix without breaking the plain-`<a>` fallback that makes
17
+ * these components host-agnostic.
10
18
  *
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.
19
+ * `docs.Layout` and `DocsSearch` have always absorbed it internally, so it only
20
+ * ever bit someone composing a shell by hand who was told to write
21
+ * `Link={Link as DocsLinkComponent}` and wait for this module. This is it; the
22
+ * cast is retired.
16
23
  *
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.
24
+ * ```tsx
25
+ * 'use client';
26
+ * import { DocsLink } from '@waveso/docs/react/next-link';
27
+ * import { DocsSidebar } from '@waveso/docs/react/sidebar';
28
+ *
29
+ * <DocsSidebar nav={nav} pathname={pathname} Link={DocsLink} />
30
+ * ```
31
+ *
32
+ * ## Why `'use client'`
33
+ *
34
+ * Not for a hook — there is none. {@link DocsLink} is a *function*, and a
35
+ * function cannot be handed from a Server Component to a Client one: React
36
+ * serialises those props and refuses. Without the directive this module's export
37
+ * would be a server reference, and passing it to `DocsSidebar` — which is itself
38
+ * `'use client'` — would fail `next build` with "Functions cannot be passed
39
+ * directly to Client Components". The directive makes it a client reference,
40
+ * which crosses fine.
41
+ */
42
+ /**
43
+ * Module scope, and never inside a render.
44
+ *
45
+ * A fresh component identity for a link remounts every link in the document on
46
+ * every render — which is why `next-search.tsx` and `next-nav.tsx` each build
47
+ * theirs at module scope too, and why this is a constant rather than a factory.
19
48
  */
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
- }
49
+ const DocsLink = wrapNextLink(NextLink);
29
50
  //#endregion
30
- export { wrapNextLink };
51
+ export { DocsLink };
@@ -5,7 +5,11 @@ interface DocsNextNavProps {
5
5
  nav: DocNavNode[];
6
6
  label?: string | undefined;
7
7
  closeLabel?: string | undefined;
8
+ /** Passed through to the tree. See `DocsSidebarProps.expandGroup`. */
9
+ expandGroup?: string | undefined;
10
+ collapseGroup?: string | undefined;
11
+ externalLink?: string | undefined;
8
12
  }
9
- declare function DocsNextNav({ nav, label, closeLabel }: DocsNextNavProps): ReactNode;
13
+ declare function DocsNextNav({ nav, label, closeLabel, expandGroup, collapseGroup, externalLink }: DocsNextNavProps): ReactNode;
10
14
  //#endregion
11
15
  export { DocsNextNav, DocsNextNavProps };
@@ -1,5 +1,5 @@
1
1
  "use client";
2
- import { wrapNextLink } from "./next-link.js";
2
+ import { wrapNextLink } from "./link-adapter.js";
3
3
  import { DocsNav } from "./nav.js";
4
4
  import { jsx } from "react/jsx-runtime";
5
5
  import NextLink from "next/link";
@@ -19,13 +19,16 @@ 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 }) {
22
+ function DocsNextNav({ nav, label, closeLabel, 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
- ...closeLabel === void 0 ? {} : { closeLabel }
28
+ ...closeLabel === void 0 ? {} : { closeLabel },
29
+ ...expandGroup === void 0 ? {} : { expandGroup },
30
+ ...collapseGroup === void 0 ? {} : { collapseGroup },
31
+ ...externalLink === void 0 ? {} : { externalLink }
29
32
  });
30
33
  }
31
34
  //#endregion
@@ -1,5 +1,5 @@
1
1
  "use client";
2
- import { wrapNextLink } from "./next-link.js";
2
+ import { wrapNextLink } from "./link-adapter.js";
3
3
  import { SearchDialog } from "./search-dialog.js";
4
4
  import { useCallback } from "react";
5
5
  import { jsx } from "react/jsx-runtime";
@@ -5,8 +5,15 @@ import { Options } from "minisearch";
5
5
  //#region src/react/search-dialog.d.ts
6
6
  interface SearchDialogProps {
7
7
  /**
8
- * URL of the serialised index, e.g. `/search-index.json`. Whatever
9
- * `writeSearchIndex` wrote, served as a static asset.
8
+ * URL of the serialised index, e.g. `/docs/search-index.json`.
9
+ *
10
+ * `docs.searchIndexUrl` is the value to pass: it is derived from the route's
11
+ * `basePath`, so it is right when the docs are mounted anywhere but the root.
12
+ * `docs.Layout` passes it for you.
13
+ *
14
+ * (This used to say "whatever `writeSearchIndex` wrote". That function was
15
+ * deleted in 0.3.0, along with the build script it needed — the index is a
16
+ * `force-static` route handler now.)
10
17
  */
11
18
  indexUrl: string;
12
19
  /**
@@ -61,6 +68,55 @@ interface SearchDialogProps {
61
68
  minQueryLength?: number | undefined;
62
69
  /** Input debounce in milliseconds. Defaults to 120. */
63
70
  debounceMs?: number | undefined;
71
+ /**
72
+ * Shown before anything is typed. Defaults to
73
+ * `'Start typing to search the documentation.'`
74
+ */
75
+ hintLabel?: string | undefined;
76
+ /**
77
+ * Shown while a query is below {@link SearchDialogProps.minQueryLength}.
78
+ * Defaults to `'Keep typing — {min} characters or more.'`
79
+ *
80
+ * `{min}` is replaced with that number. Said rather than silently done: a
81
+ * dialog that answers nothing and explains nothing reads as broken, and this
82
+ * is the state every reader passes through on the way to their real query.
83
+ */
84
+ shortQueryLabel?: string | undefined;
85
+ /** Shown while the index is being fetched. Defaults to `'Loading the search index…'`. */
86
+ loadingLabel?: string | undefined;
87
+ /**
88
+ * Shown when the index cannot be loaded. Defaults to
89
+ * `'Search is unavailable right now. Try reloading the page.'`
90
+ */
91
+ errorLabel?: string | undefined;
92
+ /**
93
+ * Shown when a query matches nothing. Defaults to `'No results for “{query}”.'`
94
+ *
95
+ * `{query}` is replaced with what the reader typed.
96
+ */
97
+ emptyLabel?: string | undefined;
98
+ /**
99
+ * The live region's announcement, by plural category. `{count}` is the total.
100
+ *
101
+ * Defaults to `{ one: '{count} result', other: '{count} results' }`.
102
+ *
103
+ * ⚠️ KEYED BY CATEGORY RATHER THAN BEING TWO STRINGS, BECAUSE MOST LANGUAGES
104
+ * ARE NOT ENGLISH. Polish takes four forms and Arabic six;
105
+ * `Intl.PluralRules(locale).select(count)` picks, and an unlisted category
106
+ * falls back to `other`. Two props called "singular" and "plural" would have
107
+ * made this package announce a wrong number of results, correctly, in most of
108
+ * the world.
109
+ */
110
+ resultCountLabels?: Partial<Record<Intl.LDMLPluralRule, string>> | undefined;
111
+ /**
112
+ * Language tag for the plural rules above. Defaults to the document's own
113
+ * `<html lang>`, then to `'en'`.
114
+ *
115
+ * Read at announcement time rather than at render, so it costs nothing on a
116
+ * site that never changes it and needs no prop on a site that sets `lang`
117
+ * correctly — which is every site that should be setting these labels at all.
118
+ */
119
+ locale?: string | undefined;
64
120
  /** Extra class names for the trigger button, e.g. a navbar's own layout. */
65
121
  className?: string | undefined;
66
122
  /**
@@ -95,6 +151,6 @@ interface SearchDialogProps {
95
151
  * portalled to `document.body`, so a navbar's stacking context cannot trap
96
152
  * it behind the page.
97
153
  */
98
- declare function SearchDialog({ indexUrl, navigate, Link, triggerLabel, placeholder, dialogLabel, pageSize, minQueryLength, debounceMs, className, miniSearchOptions }: SearchDialogProps): ReactNode;
154
+ declare function SearchDialog({ indexUrl, navigate, Link, triggerLabel, placeholder, dialogLabel, pageSize, minQueryLength, debounceMs, className, miniSearchOptions, hintLabel, shortQueryLabel, loadingLabel, errorLabel, emptyLabel, resultCountLabels, locale }: SearchDialogProps): ReactNode;
99
155
  //#endregion
100
156
  export { SearchDialog, SearchDialogProps };
@@ -26,7 +26,7 @@ const FOCUSABLE_SELECTOR = [
26
26
  * portalled to `document.body`, so a navbar's stacking context cannot trap
27
27
  * it behind the page.
28
28
  */
29
- function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", placeholder = "Search documentation", dialogLabel = "Search documentation", pageSize = 20, minQueryLength = 2, debounceMs = 120, className, miniSearchOptions }) {
29
+ function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", placeholder = "Search documentation", dialogLabel = "Search documentation", pageSize = 20, minQueryLength = 2, debounceMs = 120, className, miniSearchOptions, hintLabel, shortQueryLabel, loadingLabel, errorLabel, emptyLabel, resultCountLabels, locale }) {
30
30
  const [isOpen, setIsOpen] = useState(false);
31
31
  const [query, setQuery] = useState("");
32
32
  const [hits, setHits] = useState([]);
@@ -336,7 +336,16 @@ function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", place
336
336
  status,
337
337
  query: query.trim(),
338
338
  hitCount: hits.length,
339
- minQueryLength
339
+ minQueryLength,
340
+ labels: {
341
+ ...hintLabel === void 0 ? {} : { hint: hintLabel },
342
+ ...shortQueryLabel === void 0 ? {} : { shortQuery: shortQueryLabel },
343
+ ...loadingLabel === void 0 ? {} : { loading: loadingLabel },
344
+ ...errorLabel === void 0 ? {} : { error: errorLabel },
345
+ ...emptyLabel === void 0 ? {} : { empty: emptyLabel }
346
+ },
347
+ resultCountLabels,
348
+ locale
340
349
  })
341
350
  ]
342
351
  })
@@ -382,24 +391,59 @@ function SearchResultOption({ hit, id, isActive, setSize, posInSet, onActivate,
382
391
  })
383
392
  });
384
393
  }
394
+ /**
395
+ * The wording, so a caller that overrides none of it costs nothing.
396
+ *
397
+ * `Record<keyof …, string>` rather than `as const`: literal types here make
398
+ * every override a type error, and `Required<StatusLabels>` keeps the
399
+ * `| undefined` that `exactOptionalPropertyTypes` needs on the props.
400
+ */
401
+ const DEFAULT_STATUS_LABELS = {
402
+ hint: "Start typing to search the documentation.",
403
+ shortQuery: "Keep typing — {min} characters or more.",
404
+ loading: "Loading the search index…",
405
+ error: "Search is unavailable right now. Try reloading the page.",
406
+ empty: "No results for “{query}”."
407
+ };
408
+ const DEFAULT_RESULT_COUNT_LABELS = {
409
+ one: "{count} result",
410
+ other: "{count} results"
411
+ };
412
+ /**
413
+ * The announcement for `count` hits, in the document's own language.
414
+ *
415
+ * `Intl.PluralRules` rather than an `=== 1` check: Polish takes four plural
416
+ * forms and Arabic six, and a package that ships an English singular/plural pair
417
+ * announces a wrong number of results — correctly, and confidently — in most of
418
+ * the world. An unlisted category falls back to `other`, which is the one every
419
+ * language has.
420
+ */
421
+ function announceCount(count, labels, locale) {
422
+ const tag = locale ?? (typeof document === "undefined" ? "" : document.documentElement.lang) ?? "";
423
+ let category = "other";
424
+ try {
425
+ category = new Intl.PluralRules(tag === "" ? "en" : tag).select(count);
426
+ } catch {}
427
+ return (labels[category] ?? labels.other ?? "{count}").replace("{count}", String(count));
428
+ }
385
429
  /** Loading, failure and empty states, plus a live region for hit counts. */
386
- function SearchStatus({ status, query, hitCount, minQueryLength }) {
430
+ function SearchStatus({ status, query, hitCount, minQueryLength, labels, resultCountLabels, locale }) {
387
431
  let message = null;
388
432
  let modifier = "";
389
433
  if (status === "error") {
390
- message = "Search is unavailable right now. Try reloading the page.";
434
+ message = labels.error ?? DEFAULT_STATUS_LABELS.error;
391
435
  modifier = " wave-docs-search-status-error";
392
436
  } else if (query === "") {
393
- message = "Start typing to search the documentation.";
437
+ message = labels.hint ?? DEFAULT_STATUS_LABELS.hint;
394
438
  modifier = " wave-docs-search-status-hint";
395
439
  } else if (query.length < minQueryLength) {
396
- message = `Keep typing — ${minQueryLength} characters or more.`;
440
+ message = (labels.shortQuery ?? DEFAULT_STATUS_LABELS.shortQuery).replace("{min}", String(minQueryLength));
397
441
  modifier = " wave-docs-search-status-hint";
398
442
  } else if (status !== "ready") {
399
- message = "Loading the search index…";
443
+ message = labels.loading ?? DEFAULT_STATUS_LABELS.loading;
400
444
  modifier = " wave-docs-search-status-loading";
401
445
  } else if (hitCount === 0) {
402
- message = `No results for “${query}”.`;
446
+ message = (labels.empty ?? DEFAULT_STATUS_LABELS.empty).replace("{query}", query);
403
447
  modifier = " wave-docs-search-status-empty";
404
448
  }
405
449
  return /* @__PURE__ */ jsxs(Fragment$1, { children: [message === null ? null : /* @__PURE__ */ jsx("p", {
@@ -409,7 +453,7 @@ function SearchStatus({ status, query, hitCount, minQueryLength }) {
409
453
  className: "wave-docs-search-announcer",
410
454
  role: "status",
411
455
  "aria-live": "polite",
412
- children: query === "" || status !== "ready" ? "" : `${hitCount} ${hitCount === 1 ? "result" : "results"}`
456
+ children: query === "" || status !== "ready" ? "" : announceCount(hitCount, resultCountLabels ?? DEFAULT_RESULT_COUNT_LABELS, locale)
413
457
  })] });
414
458
  }
415
459
  /**