@waveso/docs 0.4.0 → 0.5.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 (48) hide show
  1. package/CHANGELOG.md +173 -0
  2. package/README.md +160 -39
  3. package/dist/errors.d.ts +2 -0
  4. package/dist/highlighter.js +2 -1
  5. package/dist/meta.js +6 -9
  6. package/dist/next.d.ts +54 -14
  7. package/dist/next.js +115 -18
  8. package/dist/plugins/rehype-code-frame.d.ts +13 -1
  9. package/dist/plugins/rehype-code-frame.js +2 -1
  10. package/dist/plugins/remark-doc-links.d.ts +51 -1
  11. package/dist/plugins/remark-doc-links.js +27 -16
  12. package/dist/plugins/remark-youtube.d.ts +18 -3
  13. package/dist/plugins/remark-youtube.js +57 -9
  14. package/dist/react/callout.d.ts +13 -1
  15. package/dist/react/callout.js +2 -2
  16. package/dist/react/code-runtime.d.ts +12 -2
  17. package/dist/react/code-runtime.js +28 -4
  18. package/dist/react/doc-content.d.ts +12 -1
  19. package/dist/react/doc-content.js +2 -2
  20. package/dist/react/layout.d.ts +27 -10
  21. package/dist/react/layout.js +6 -3
  22. package/dist/react/markdown-components.d.ts +29 -1
  23. package/dist/react/markdown-components.js +69 -67
  24. package/dist/react/nav.d.ts +5 -1
  25. package/dist/react/nav.js +5 -2
  26. package/dist/react/next-nav.d.ts +5 -1
  27. package/dist/react/next-nav.js +5 -2
  28. package/dist/react/search-dialog.d.ts +59 -3
  29. package/dist/react/search-dialog.js +53 -9
  30. package/dist/react/shell-labels.d.ts +135 -21
  31. package/dist/react/shell-labels.js +47 -6
  32. package/dist/react/sidebar.d.ts +18 -1
  33. package/dist/react/sidebar.js +59 -23
  34. package/dist/react/youtube.d.ts +22 -1
  35. package/dist/react/youtube.js +22 -4
  36. package/dist/render.d.ts +11 -0
  37. package/dist/render.js +38 -11
  38. package/dist/route-path.js +7 -2
  39. package/dist/safe-href.d.ts +47 -0
  40. package/dist/safe-href.js +73 -0
  41. package/dist/search-index.js +1 -1
  42. package/dist/search-options.d.ts +64 -2
  43. package/dist/search-options.js +25 -1
  44. package/dist/semaphore.d.ts +46 -0
  45. package/dist/semaphore.js +60 -0
  46. package/dist/source.js +80 -10
  47. package/dist/types.d.ts +30 -0
  48. package/package.json +1 -1
@@ -3,11 +3,32 @@ import { ReactNode } from "react";
3
3
  interface YouTubeProps {
4
4
  /** The 11-character video id, e.g. `dQw4w9WgXcQ`. */
5
5
  id?: string | undefined;
6
+ /**
7
+ * Seconds to start at, from the link's `t` or `start`.
8
+ *
9
+ * ⚠️ IT USED TO BE DROPPED, AND THE FACADE AUTOPLAYS. `https://youtu.be/x?t=754`
10
+ * is a link to one moment in a two-hour talk — most of why anyone deep-links a
11
+ * video at all — and it opened at zero and started playing there, leaving the
12
+ * reader to work out that the author had meant somewhere else.
13
+ */
14
+ start?: number | undefined;
15
+ /** Playlist the video was linked inside, from the link's `list`. */
16
+ list?: string | undefined;
6
17
  /**
7
18
  * Accessible name for the player. Markdown carries no video title, so the
8
19
  * fallback is generic — pass a real one where you have it.
9
20
  */
10
21
  title?: string | undefined;
22
+ /**
23
+ * The closed facade's control. Default `'Play video: {title}'`.
24
+ *
25
+ * `{title}` is replaced with {@link YouTubeProps.title}. A placeholder rather
26
+ * than concatenation because a translator has to be able to move the name
27
+ * within the sentence — several languages put it first.
28
+ */
29
+ playLabel?: string | undefined;
30
+ /** The open facade's control. Default `'Hide video: {title}'`. */
31
+ hideLabel?: string | undefined;
11
32
  className?: string | undefined;
12
33
  }
13
34
  /**
@@ -48,6 +69,6 @@ interface YouTubeProps {
48
69
  * `hqdefault.jpg` rather than `maxresdefault.jpg` deliberately: maxres does not
49
70
  * exist for uploads below 1280×720 and 404s to a broken image with no fallback.
50
71
  */
51
- declare function YouTube({ id, title, className }: YouTubeProps): ReactNode;
72
+ declare function YouTube({ id, start, list, title, playLabel, hideLabel, className }: YouTubeProps): ReactNode;
52
73
  //#endregion
53
74
  export { YouTube, YouTubeProps };
@@ -1,6 +1,24 @@
1
1
  import { jsx, jsxs } from "react/jsx-runtime";
2
2
  //#region src/react/youtube.tsx
3
+ /**
4
+ * The embed URL, with whatever the author's link carried.
5
+ *
6
+ * `URLSearchParams` rather than string concatenation: `list` comes out of a
7
+ * document and goes into a URL, and building this by hand is how a crafted
8
+ * "playlist id" adds parameters of its own. `start` is already a number.
9
+ */
10
+ function embedUrl(id, start, list) {
11
+ const params = new URLSearchParams({
12
+ rel: "0",
13
+ autoplay: "1"
14
+ });
15
+ if (start !== void 0 && Number.isFinite(start) && start > 0) params.set("start", String(Math.floor(start)));
16
+ if (list !== void 0 && list !== "") params.set("list", list);
17
+ return `https://www.youtube-nocookie.com/embed/${id}?${params.toString()}`;
18
+ }
3
19
  const DEFAULT_TITLE = "YouTube video player";
20
+ const DEFAULT_PLAY_LABEL = "Play video: {title}";
21
+ const DEFAULT_HIDE_LABEL = "Hide video: {title}";
4
22
  /**
5
23
  * Click-to-load YouTube embed, with **no client JavaScript at all**.
6
24
  *
@@ -39,7 +57,7 @@ const DEFAULT_TITLE = "YouTube video player";
39
57
  * `hqdefault.jpg` rather than `maxresdefault.jpg` deliberately: maxres does not
40
58
  * exist for uploads below 1280×720 and 404s to a broken image with no fallback.
41
59
  */
42
- function YouTube({ id, title, className }) {
60
+ function YouTube({ id, start, list, title, playLabel, hideLabel, className }) {
43
61
  if (!id) return null;
44
62
  const safeId = encodeURIComponent(id);
45
63
  const label = title?.trim() || DEFAULT_TITLE;
@@ -78,17 +96,17 @@ function YouTube({ id, title, className }) {
78
96
  }),
79
97
  /* @__PURE__ */ jsx("span", {
80
98
  className: "wave-docs-sr-only wave-docs-youtube__label-play",
81
- children: `Play video: ${label}`
99
+ children: (playLabel ?? DEFAULT_PLAY_LABEL).replace("{title}", label)
82
100
  }),
83
101
  /* @__PURE__ */ jsx("span", {
84
102
  className: "wave-docs-youtube__label-hide",
85
- children: `Hide video: ${label}`
103
+ children: (hideLabel ?? DEFAULT_HIDE_LABEL).replace("{title}", label)
86
104
  })
87
105
  ]
88
106
  }), /* @__PURE__ */ jsx("iframe", {
89
107
  className: "wave-docs-youtube__frame",
90
108
  loading: "lazy",
91
- src: `https://www.youtube-nocookie.com/embed/${safeId}?rel=0&autoplay=1`,
109
+ src: embedUrl(safeId, start, list),
92
110
  title: label,
93
111
  allow: "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share",
94
112
  allowFullScreen: true
package/dist/render.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { DocFile, DocFrontmatter, ImageResolver, LinkResolver, RenderedDoc, ResolvedDocsConfig } from "./types.js";
2
2
  import { DocsHighlighter, DocsLang, DocsThemes } from "./highlighter.js";
3
+ import { RehypeCodeFrameOptions } from "./plugins/rehype-code-frame.js";
3
4
  import { resolveMarkdownLink } from "./plugins/remark-doc-links.js";
4
5
  import { PluggableList } from "unified";
5
6
  //#region src/render.d.ts
@@ -115,6 +116,16 @@ interface DocsRendererOptions {
115
116
  * than a monochrome block of DSL.
116
117
  */
117
118
  excludeLangs?: readonly string[] | undefined;
119
+ /**
120
+ * Accessible names for the copy button, for a site that is not in English.
121
+ *
122
+ * ⚠️ `rehypeCodeFrame` HAS TAKEN A `copyLabel` SINCE IT WAS WRITTEN AND
123
+ * NOTHING EVER PASSED ONE. The plugin is private, so the option was
124
+ * unreachable from every entry point — the README said the label was
125
+ * configurable and it was not. Baked into the HTML at build time, so
126
+ * overriding it costs no client bytes.
127
+ */
128
+ codeLabels?: RehypeCodeFrameOptions | undefined;
118
129
  }
119
130
  /**
120
131
  * Renders {@link DocFile}s. Build one per process and reuse it.
package/dist/render.js CHANGED
@@ -5,7 +5,7 @@ import { rehypeCodeFrame } from "./plugins/rehype-code-frame.js";
5
5
  import { rehypeNormalizeCodeLanguage, rehypeRestoreExcludedCode } from "./plugins/rehype-code-language.js";
6
6
  import { rehypeFallbackHeadingIds } from "./plugins/rehype-fallback-heading-ids.js";
7
7
  import { rehypeFlattenRoots } from "./plugins/rehype-flatten-roots.js";
8
- import { foldSegments, remarkDocLinks, resolveMarkdownLink } from "./plugins/remark-doc-links.js";
8
+ import { foldSegments, remarkDocLinks, resolveMarkdownLink, splitHref } from "./plugins/remark-doc-links.js";
9
9
  import { remarkUnwrapImages } from "./plugins/remark-unwrap-images.js";
10
10
  import { remarkYouTube } from "./plugins/remark-youtube.js";
11
11
  import rehypeShikiFromHighlighter from "@shikijs/rehype/core";
@@ -96,9 +96,29 @@ function assertResolvedImage(value, src, relativePath) {
96
96
  * author means by `![](./diagram.png)` and what the link path has always done.
97
97
  */
98
98
  function foldImageSrc(src, dirSegments) {
99
- if (isPublicImageSrc(src)) return src;
100
- const segments = foldSegments(dirSegments, src);
101
- return segments === void 0 ? void 0 : segments.join("/");
99
+ if (isPublicImageSrc(src)) return {
100
+ path: src,
101
+ suffix: ""
102
+ };
103
+ const { path, query, hash } = splitHref(src);
104
+ if (path === "") return;
105
+ const segments = foldSegments(dirSegments, path);
106
+ return segments === void 0 ? void 0 : {
107
+ path: segments.join("/"),
108
+ suffix: `${query}${hash}`
109
+ };
110
+ }
111
+ /**
112
+ * Put the authored `?query#hash` back, unless the resolver wrote its own.
113
+ *
114
+ * A resolver returning `/img/diagram.a1b2c3.png?w=800` has said something more
115
+ * specific than the author's `?v=2` — and concatenating the two would produce
116
+ * two `?` in one URL, which is not a URL. Same rule the rest of this package
117
+ * uses when a host and a default disagree: the more specific one wins.
118
+ */
119
+ function withSuffix(src, suffix) {
120
+ if (suffix === "" || /[?#]/.test(src)) return src;
121
+ return `${src}${suffix}`;
102
122
  }
103
123
  /** Route without its `?query` / `#anchor`, for existence checks. */
104
124
  function toRouteKey(href) {
@@ -238,7 +258,7 @@ async function buildProcessor(options, themes, highlighterPromise) {
238
258
  ariaHidden: "true",
239
259
  tabIndex: -1
240
260
  }
241
- }).use(options.rehypePlugins ?? []).use(rehypeNormalizeCodeLanguage, { ...options.excludeLangs === void 0 ? {} : { exclude: options.excludeLangs } }).use(rehypeCodeFrame).use(rehypeShikiFromHighlighter, highlighter, {
261
+ }).use(options.rehypePlugins ?? []).use(rehypeNormalizeCodeLanguage, { ...options.excludeLangs === void 0 ? {} : { exclude: options.excludeLangs } }).use(rehypeCodeFrame, options.codeLabels ?? {}).use(rehypeShikiFromHighlighter, highlighter, {
242
262
  themes,
243
263
  defaultColor: false,
244
264
  fallbackLanguage: "text",
@@ -294,7 +314,13 @@ function createDocsRenderer(options) {
294
314
  await Promise.all(images.map(async (node) => {
295
315
  const src = node.properties.src;
296
316
  if (typeof src !== "string" || src === "") return;
297
- const folded = foldImageSrc(src, context.dirSegments);
317
+ let folded;
318
+ try {
319
+ folded = foldImageSrc(src, context.dirSegments);
320
+ } catch (error) {
321
+ if (!(error instanceof URIError)) throw error;
322
+ throw docsError("invalid-image", `@waveso/docs: image "${src}" in ${file.relativePath} is not valid percent-encoding. Write %25 for a literal percent sign, or name the file as it is on disk.`, { cause: error });
323
+ }
298
324
  if (folded === void 0) throw docsError("invalid-image", `@waveso/docs: image "${src}" in ${file.relativePath} climbs above the content root.`);
299
325
  if (resolve === void 0) {
300
326
  if (isPublicImageSrc(src)) return;
@@ -302,16 +328,16 @@ function createDocsRenderer(options) {
302
328
  }
303
329
  let resolved;
304
330
  try {
305
- resolved = await resolve(folded, context);
331
+ resolved = await resolve(folded.path, context);
306
332
  } catch (error) {
307
333
  throw docsError("invalid-image", `@waveso/docs: the imageResolver threw on image "${src}" in ${file.relativePath}.`, { cause: error });
308
334
  }
309
335
  if (resolved === void 0) {
310
- node.properties.src = folded;
311
- return;
336
+ if (isPublicImageSrc(src)) return;
337
+ throw docsError("invalid-image", `@waveso/docs: the imageResolver returned nothing for image "${src}" in ${file.relativePath}, which is relative to the markdown file — so nothing can serve it: the browser would resolve it against the page route, and the same markdown would request a different file from every page. Return a src for it, or move the image under \`public/\` and write an absolute one such as "/diagram.png".`);
312
338
  }
313
339
  assertResolvedImage(resolved, src, file.relativePath);
314
- node.properties.src = resolved.src;
340
+ node.properties.src = withSuffix(resolved.src, folded.suffix);
315
341
  if (resolved.width !== void 0) node.properties.width = resolved.width;
316
342
  if (resolved.height !== void 0) node.properties.height = resolved.height;
317
343
  }));
@@ -336,8 +362,9 @@ function createDocsRenderer(options) {
336
362
  }
337
363
  return { async render(file) {
338
364
  const processor = await processorPromise;
365
+ const lead = "\n".repeat(file.frontmatterLines ?? 0);
339
366
  const vfile = new VFile({
340
- value: file.content,
367
+ value: lead + file.content,
341
368
  path: file.filePath
342
369
  });
343
370
  vfile.data.docLinkContext = {
@@ -1,5 +1,5 @@
1
1
  import { docsError } from "./docs-error.js";
2
- import { foldSegments } from "./plugins/remark-doc-links.js";
2
+ import { decodePath, foldSegments } from "./plugins/remark-doc-links.js";
3
3
  //#region src/route-path.ts
4
4
  /**
5
5
  * Turning route segments into a URL path.
@@ -39,7 +39,12 @@ const ALIAS_PATTERN_CHARS = /[:()+*?{}]/;
39
39
  * every rejection below names the markdown file at the moment it is read.
40
40
  */
41
41
  function toAliasRoute(alias, basePath, sourceLabel) {
42
- const trimmed = alias.trim();
42
+ let trimmed;
43
+ try {
44
+ trimmed = decodePath(alias.trim(), alias);
45
+ } catch (error) {
46
+ throw docsError("invalid-alias", `@waveso/docs: the alias '${alias}' in ${sourceLabel} is not valid percent-encoding. Write %25 for a literal percent sign, or write the former URL as its readable form.`, { cause: error });
47
+ }
43
48
  if (trimmed.split("/").some((part) => part === "." || part === "..")) throw docsError("invalid-alias", `@waveso/docs: the alias '${alias}' in ${sourceLabel} has a '.' or '..' segment. An alias is a former URL relative to the docs base path, not a path on disk: write \`aliases: [legacy/old-name]\`.`);
44
49
  const pattern = ALIAS_PATTERN_CHARS.exec(trimmed);
45
50
  if (pattern !== null) throw docsError("invalid-alias", `@waveso/docs: the alias '${alias}' in ${sourceLabel} contains '${pattern[0]}', which Next compiles as redirect pattern syntax rather than as part of the URL — the redirect then swallows every page whose route the pattern happens to match, or fails the build. Remove the character; an alias is a literal former URL.`);
@@ -0,0 +1,47 @@
1
+ //#region src/safe-href.d.ts
2
+ /**
3
+ * The one allowlist of schemes this package will put in an `href`.
4
+ *
5
+ * Private — deliberately not an entry point.
6
+ *
7
+ * ⚠️ IT WAS THE MARKDOWN PATH'S ALONE, AND `meta.json` WENT ROUND IT. A hand
8
+ * written nav entry — `{ "title": "Status", "href": "javascript:…" }` — reached
9
+ * `<a href>` through `DocsSidebar` with nothing checking it, while the markdown
10
+ * beside it was filtered by a comment calling the check load-bearing. Both paths
11
+ * end at the same anchor, so both need the same rule, and a rule with two copies
12
+ * is a rule with one that is out of date.
13
+ *
14
+ * Node-safe and browser-safe: two regular expressions and two functions, no
15
+ * imports at all.
16
+ */
17
+ /**
18
+ * Strip the characters a browser ignores inside a URL.
19
+ *
20
+ * `java\nscript:` and `java&#09;script:` are `javascript:` to a parser and not
21
+ * to a naive regular expression, so the test has to run on the string the
22
+ * browser will see rather than on the one that was written.
23
+ */
24
+ declare function normaliseUrl(href: string): string;
25
+ /**
26
+ * Would this href navigate somewhere we are willing to send a reader?
27
+ *
28
+ * Nothing upstream filters it on the markdown side: `remarkDocLinks` skips every
29
+ * href with a scheme, so `assertLinks` never sees one either, and `remarkRehype`
30
+ * runs with `allowDangerousHtml` off but passes a link's own url through
31
+ * untouched. Verified against React 19: it neutralises `javascript:` in every
32
+ * obfuscated form, silently — but it lets `vbscript:` and
33
+ * `data:text/html;base64,…` reach the DOM verbatim. So the allowlist is ours.
34
+ */
35
+ declare function isSafeHref(href: string): boolean;
36
+ /**
37
+ * Does following this href leave the site in a new tab?
38
+ *
39
+ * ⚠️ NOT "HAS A SCHEME". `meta.json` used that test, so a `mailto:` sidebar
40
+ * entry was given `target="_blank"` and announced as "(opens in a new tab)" — a
41
+ * tab that never opens, described to precisely the reader who cannot see that it
42
+ * did not. Only http(s) and protocol-relative navigate; `mailto:` and `tel:`
43
+ * hand off to the OS and leave the page where it is.
44
+ */
45
+ declare function opensInNewTab(href: string): boolean;
46
+ //#endregion
47
+ export { isSafeHref, normaliseUrl, opensInNewTab };
@@ -0,0 +1,73 @@
1
+ //#region src/safe-href.ts
2
+ /**
3
+ * The one allowlist of schemes this package will put in an `href`.
4
+ *
5
+ * Private — deliberately not an entry point.
6
+ *
7
+ * ⚠️ IT WAS THE MARKDOWN PATH'S ALONE, AND `meta.json` WENT ROUND IT. A hand
8
+ * written nav entry — `{ "title": "Status", "href": "javascript:…" }` — reached
9
+ * `<a href>` through `DocsSidebar` with nothing checking it, while the markdown
10
+ * beside it was filtered by a comment calling the check load-bearing. Both paths
11
+ * end at the same anchor, so both need the same rule, and a rule with two copies
12
+ * is a rule with one that is out of date.
13
+ *
14
+ * Node-safe and browser-safe: two regular expressions and two functions, no
15
+ * imports at all.
16
+ */
17
+ /**
18
+ * Any URL with a scheme, or protocol-relative.
19
+ */
20
+ const ABSOLUTE_URL = /^([a-z][a-z0-9+.-]*:|\/\/)/i;
21
+ /**
22
+ * The schemes a link may carry.
23
+ *
24
+ * GitHub's own allowlist, which is the bar to match: documentation links to
25
+ * `sms:`, `ftp:` and `irc:` are ordinary, and an allowlist of three silently
26
+ * deleted them. The point of the check is to stop `javascript:`, `data:` and
27
+ * `vbscript:` reaching an `href`, not to have an opinion about protocols.
28
+ *
29
+ * A scheme not listed here — `vscode:`, `obsidian:`, `slack:` — is refused
30
+ * rather than rendered. That is deliberate: an allowlist that grows on request
31
+ * is safe, one that guesses is not.
32
+ */
33
+ const SAFE_SCHEME = /^(https?|mailto|tel|sms|ftp|ftps|irc|ircs|xmpp|news|nntp|feed|git|matrix):/i;
34
+ /**
35
+ * Strip the characters a browser ignores inside a URL.
36
+ *
37
+ * `java\nscript:` and `java&#09;script:` are `javascript:` to a parser and not
38
+ * to a naive regular expression, so the test has to run on the string the
39
+ * browser will see rather than on the one that was written.
40
+ */
41
+ function normaliseUrl(href) {
42
+ return [...href].filter((char) => (char.codePointAt(0) ?? 0) > 32).join("");
43
+ }
44
+ /**
45
+ * Would this href navigate somewhere we are willing to send a reader?
46
+ *
47
+ * Nothing upstream filters it on the markdown side: `remarkDocLinks` skips every
48
+ * href with a scheme, so `assertLinks` never sees one either, and `remarkRehype`
49
+ * runs with `allowDangerousHtml` off but passes a link's own url through
50
+ * untouched. Verified against React 19: it neutralises `javascript:` in every
51
+ * obfuscated form, silently — but it lets `vbscript:` and
52
+ * `data:text/html;base64,…` reach the DOM verbatim. So the allowlist is ours.
53
+ */
54
+ function isSafeHref(href) {
55
+ const normalised = normaliseUrl(href);
56
+ if (!ABSOLUTE_URL.test(normalised)) return true;
57
+ return normalised.startsWith("//") || SAFE_SCHEME.test(normalised);
58
+ }
59
+ /**
60
+ * Does following this href leave the site in a new tab?
61
+ *
62
+ * ⚠️ NOT "HAS A SCHEME". `meta.json` used that test, so a `mailto:` sidebar
63
+ * entry was given `target="_blank"` and announced as "(opens in a new tab)" — a
64
+ * tab that never opens, described to precisely the reader who cannot see that it
65
+ * did not. Only http(s) and protocol-relative navigate; `mailto:` and `tel:`
66
+ * hand off to the OS and leave the page where it is.
67
+ */
68
+ function opensInNewTab(href) {
69
+ const normalised = normaliseUrl(href);
70
+ return /^https?:\/\//i.test(normalised) || normalised.startsWith("//");
71
+ }
72
+ //#endregion
73
+ export { isSafeHref, normaliseUrl, opensInNewTab };
@@ -1,5 +1,5 @@
1
- import { isFootnotes, isTransparentContainer } from "./section-boundary.js";
2
1
  import { mergeSearchOptions } from "./search-options.js";
2
+ import { isFootnotes, isTransparentContainer } from "./section-boundary.js";
3
3
  import MiniSearch from "minisearch";
4
4
  //#region src/search-index.ts
5
5
  /** `<h1>`…`<h6>` to their numeric depth. */
@@ -1,5 +1,5 @@
1
1
  import { SearchRecord } from "./types.js";
2
- import { Options } from "minisearch";
2
+ import { Options, SearchOptions } from "minisearch";
3
3
  //#region src/search-options.d.ts
4
4
  /**
5
5
  * Split text into index terms.
@@ -45,5 +45,67 @@ declare const SEARCH_INDEX_OPTIONS: Options<SearchRecord>;
45
45
  * OR. Nothing below that merges: a `boost` override replaces the whole map.
46
46
  */
47
47
  declare function mergeSearchOptions(overrides?: Partial<Options<SearchRecord>>): Options<SearchRecord>;
48
+ /**
49
+ * {@link SearchOptions} with every function-valued member removed.
50
+ *
51
+ * `prefix` and `fuzzy` survive as the boolean and the number they usually are;
52
+ * their function overloads do not, because a predicate cannot be serialised.
53
+ */
54
+ type SerializableSearchQueryOptions = Omit<SearchOptions, 'filter' | 'boostTerm' | 'boostDocument' | 'tokenize' | 'processTerm' | 'prefix' | 'fuzzy'> & {
55
+ prefix?: boolean;
56
+ fuzzy?: boolean | number;
57
+ };
58
+ /**
59
+ * MiniSearch overrides that can be handed from a Server Component to a Client
60
+ * Component — which is to say, the ones with no functions in them.
61
+ *
62
+ * React serialises a Client Component's props, and a function is not
63
+ * serialisable: `docs.Layout` forwarding `{ processTerm }` into `DocsSearch`
64
+ * fails `next build` outright with *"Functions cannot be passed directly to
65
+ * Client Components"*. This type is what stops that being expressible.
66
+ *
67
+ * ⚠️ THE OMIT LIST IS NOT THE GUARANTEE — {@link findFunctionValuedOptions} IS.
68
+ * MiniSearch is free to add a function-valued option in a minor, and the day it
69
+ * does this list is quietly incomplete while still compiling. The runtime walk
70
+ * has no such failure mode: it finds a function wherever it is, including in
71
+ * options this package has never heard of. The type is here to fail earlier and
72
+ * more legibly, not to be the last line of defence.
73
+ *
74
+ * The escape hatch for real function tuning is a client boundary of the host's
75
+ * own, which is the only place the two halves can share a module reference:
76
+ *
77
+ * ```tsx
78
+ * // app/docs/search.tsx
79
+ * 'use client';
80
+ * import { DocsSearch } from '@waveso/docs/react/next-search';
81
+ * import { processTerm } from '@/lib/search-terms';
82
+ *
83
+ * export function Search({ indexUrl }: { indexUrl: string }) {
84
+ * return <DocsSearch indexUrl={indexUrl} miniSearchOptions={{ processTerm }} />;
85
+ * }
86
+ * ```
87
+ *
88
+ * That component takes the boundary with it, so the function is a module import
89
+ * on both sides rather than a prop crossing between them — exactly how
90
+ * {@link tokenizeSearchText} reaches the client today.
91
+ */
92
+ type SerializableSearchOptions = Omit<Partial<Options<SearchRecord>>, 'extractField' | 'stringifyField' | 'tokenize' | 'processTerm' | 'logger' | 'searchOptions' | 'autoSuggestOptions'> & {
93
+ searchOptions?: SerializableSearchQueryOptions;
94
+ autoSuggestOptions?: SerializableSearchQueryOptions;
95
+ };
96
+ /**
97
+ * Dotted paths of every function reachable from `options`, in encounter order.
98
+ *
99
+ * The load-bearing half of the boundary check, and deliberately structural
100
+ * rather than a key list: it answers for `processTerm`, for
101
+ * `searchOptions.filter`, and for whatever MiniSearch adds next, because it
102
+ * asks what the values *are* rather than what they are called.
103
+ *
104
+ * `seen` makes a cyclic options object an empty answer rather than a stack
105
+ * overflow. Nothing in MiniSearch's surface is cyclic, but a hang during
106
+ * `next build` is a far worse failure than a wrong one, and the guard is a
107
+ * line.
108
+ */
109
+ declare function findFunctionValuedOptions(options: object, prefix?: string, seen?: WeakSet<object>): string[];
48
110
  //#endregion
49
- export { SEARCH_INDEX_OPTIONS, mergeSearchOptions, tokenizeSearchText };
111
+ export { SEARCH_INDEX_OPTIONS, SerializableSearchOptions, SerializableSearchQueryOptions, findFunctionValuedOptions, mergeSearchOptions, tokenizeSearchText };
@@ -99,5 +99,29 @@ function mergeSearchOptions(overrides = {}) {
99
99
  }
100
100
  };
101
101
  }
102
+ /**
103
+ * Dotted paths of every function reachable from `options`, in encounter order.
104
+ *
105
+ * The load-bearing half of the boundary check, and deliberately structural
106
+ * rather than a key list: it answers for `processTerm`, for
107
+ * `searchOptions.filter`, and for whatever MiniSearch adds next, because it
108
+ * asks what the values *are* rather than what they are called.
109
+ *
110
+ * `seen` makes a cyclic options object an empty answer rather than a stack
111
+ * overflow. Nothing in MiniSearch's surface is cyclic, but a hang during
112
+ * `next build` is a far worse failure than a wrong one, and the guard is a
113
+ * line.
114
+ */
115
+ function findFunctionValuedOptions(options, prefix = "", seen = /* @__PURE__ */ new WeakSet()) {
116
+ if (seen.has(options)) return [];
117
+ seen.add(options);
118
+ const found = [];
119
+ for (const [key, value] of Object.entries(options)) {
120
+ const path = prefix === "" ? key : `${prefix}.${key}`;
121
+ if (typeof value === "function") found.push(path);
122
+ else if (typeof value === "object" && value !== null) found.push(...findFunctionValuedOptions(value, path, seen));
123
+ }
124
+ return found;
125
+ }
102
126
  //#endregion
103
- export { SEARCH_INDEX_OPTIONS, mergeSearchOptions, tokenizeSearchText };
127
+ export { SEARCH_INDEX_OPTIONS, findFunctionValuedOptions, mergeSearchOptions, tokenizeSearchText };
@@ -0,0 +1,46 @@
1
+ //#region src/semaphore.d.ts
2
+ /**
3
+ * A counting semaphore, for bounding how much of a process resource is in use.
4
+ *
5
+ * Private — deliberately not an entry point in `package.json`.
6
+ *
7
+ * {@link mapPooled} bounds a fan-out over a list, which is the easy case: the
8
+ * list is known, so the pool can pull from it. A recursive tree walk has no
9
+ * list — `scanDir` calls itself once per subdirectory, so a per-call pool
10
+ * bounds each directory and multiplies across the depth, which is not a bound
11
+ * at all. That is what this is for.
12
+ *
13
+ * ⚠️ NEVER ACQUIRE A SLOT WHILE HOLDING ONE. Nested acquisition deadlocks the
14
+ * moment every slot is held by a caller waiting for a slot, and no amount of
15
+ * timeout rescues it. Guard the leaf operation — the `readFile`, the `readdir`
16
+ * — and never the recursive call around it. In `source.ts` this is why the
17
+ * traversal itself is ungated: only the filesystem calls take slots, so the
18
+ * descriptors are bounded exactly while the walk stays as parallel as it was.
19
+ */
20
+ /** @see createSemaphore */
21
+ interface Semaphore {
22
+ /**
23
+ * Run `fn` once a slot is free, and give the slot back when it settles.
24
+ *
25
+ * Rejections propagate untouched, and release the slot on the way out.
26
+ */
27
+ run<T>(fn: () => Promise<T>): Promise<T>;
28
+ }
29
+ /**
30
+ * A semaphore admitting `limit` concurrent callers.
31
+ *
32
+ * Waiters are woken in arrival order, so a long queue cannot starve its head.
33
+ *
34
+ * A limit below one falls back to one, for the same reason {@link mapPooled}
35
+ * clamps: a caller passing `0` — or a negative, from arithmetic on a config
36
+ * value — would otherwise admit nobody, and the symptom is a build that hangs
37
+ * rather than one that fails.
38
+ *
39
+ * ⚠️ `Math.max(1, limit)` IS NOT ENOUGH, BECAUSE `Math.max(1, NaN)` IS `NaN`.
40
+ * With `max` set to `NaN`, `active >= max` is false forever and the semaphore
41
+ * admits everyone — no hang, no error, and no bound, which is the one outcome
42
+ * worse than either. Non-finite means one.
43
+ */
44
+ declare function createSemaphore(limit: number): Semaphore;
45
+ //#endregion
46
+ export { Semaphore, createSemaphore };
@@ -0,0 +1,60 @@
1
+ //#region src/semaphore.ts
2
+ /**
3
+ * A semaphore admitting `limit` concurrent callers.
4
+ *
5
+ * Waiters are woken in arrival order, so a long queue cannot starve its head.
6
+ *
7
+ * A limit below one falls back to one, for the same reason {@link mapPooled}
8
+ * clamps: a caller passing `0` — or a negative, from arithmetic on a config
9
+ * value — would otherwise admit nobody, and the symptom is a build that hangs
10
+ * rather than one that fails.
11
+ *
12
+ * ⚠️ `Math.max(1, limit)` IS NOT ENOUGH, BECAUSE `Math.max(1, NaN)` IS `NaN`.
13
+ * With `max` set to `NaN`, `active >= max` is false forever and the semaphore
14
+ * admits everyone — no hang, no error, and no bound, which is the one outcome
15
+ * worse than either. Non-finite means one.
16
+ */
17
+ function createSemaphore(limit) {
18
+ const requested = Math.floor(limit);
19
+ const max = Number.isFinite(requested) && requested >= 1 ? requested : 1;
20
+ const waiting = [];
21
+ let active = 0;
22
+ /**
23
+ * Hand the slot to the next waiter rather than releasing and re-taking it.
24
+ *
25
+ * The naive form — decrement here, let the woken waiter increment itself — is
26
+ * *equivalent*, and this was written that way first on the assumption that it
27
+ * was not. It is equivalent because the decrement and the wake happen in one
28
+ * synchronous step: no microtask can be interposed between them, and anything
29
+ * enqueued earlier runs before this function rather than inside it, so there
30
+ * is no window for a caller to read `active` below the limit. Mutation-tested
31
+ * — swapping in the naive form fails nothing, which is the honest result.
32
+ *
33
+ * It stays this way because that argument is about the scheduler rather than
34
+ * about this code. One `await` between the decrement and the wake and the
35
+ * bound is gone, with nothing able to observe it. Handing the slot over makes
36
+ * `active` a count of owned slots at every point, whoever holds them, so the
37
+ * invariant is local and needs no argument at all.
38
+ */
39
+ const release = () => {
40
+ const next = waiting.shift();
41
+ if (next !== void 0) {
42
+ next();
43
+ return;
44
+ }
45
+ active -= 1;
46
+ };
47
+ return { async run(fn) {
48
+ if (active >= max) await new Promise((resolve) => {
49
+ waiting.push(resolve);
50
+ });
51
+ else active += 1;
52
+ try {
53
+ return await fn();
54
+ } finally {
55
+ release();
56
+ }
57
+ } };
58
+ }
59
+ //#endregion
60
+ export { createSemaphore };