@waveso/docs 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/CHANGELOG.md +194 -0
  2. package/README.md +592 -88
  3. package/dist/code-frame.d.ts +29 -0
  4. package/dist/code-frame.js +41 -0
  5. package/dist/code-meta.d.ts +48 -0
  6. package/dist/code-meta.js +72 -0
  7. package/dist/docs-content-id.d.ts +19 -0
  8. package/dist/docs-content-id.js +19 -0
  9. package/dist/docs-error.d.ts +19 -0
  10. package/dist/docs-error.js +28 -0
  11. package/dist/errors.d.ts +94 -0
  12. package/dist/errors.js +45 -0
  13. package/dist/frontmatter.d.ts +39 -7
  14. package/dist/frontmatter.js +51 -24
  15. package/dist/highlighter.d.ts +2 -2
  16. package/dist/highlighter.js +3 -2
  17. package/dist/map-pooled.d.ts +26 -0
  18. package/dist/map-pooled.js +45 -0
  19. package/dist/meta.d.ts +7 -3
  20. package/dist/meta.js +61 -15
  21. package/dist/next.d.ts +182 -35
  22. package/dist/next.js +177 -49
  23. package/dist/plugins/rehype-capture-toc.js +52 -20
  24. package/dist/plugins/rehype-code-frame.d.ts +10 -0
  25. package/dist/plugins/rehype-code-frame.js +88 -0
  26. package/dist/plugins/rehype-code-language.d.ts +24 -0
  27. package/dist/plugins/rehype-code-language.js +54 -0
  28. package/dist/plugins/rehype-fallback-heading-ids.d.ts +6 -0
  29. package/dist/plugins/rehype-fallback-heading-ids.js +51 -0
  30. package/dist/plugins/rehype-flatten-roots.d.ts +7 -0
  31. package/dist/plugins/rehype-flatten-roots.js +39 -0
  32. package/dist/plugins/remark-doc-links.d.ts +12 -1
  33. package/dist/plugins/remark-doc-links.js +147 -20
  34. package/dist/react/code-runtime.d.ts +14 -0
  35. package/dist/react/code-runtime.js +161 -0
  36. package/dist/react/doc-content.d.ts +39 -2
  37. package/dist/react/doc-content.js +42 -10
  38. package/dist/react/layout.d.ts +44 -0
  39. package/dist/react/layout.js +65 -0
  40. package/dist/react/markdown-components.js +71 -6
  41. package/dist/react/nav.d.ts +28 -0
  42. package/dist/react/nav.js +70 -0
  43. package/dist/react/nearest-scroll-top.d.ts +45 -0
  44. package/dist/react/nearest-scroll-top.js +44 -0
  45. package/dist/react/next-link.d.ts +34 -0
  46. package/dist/react/next-link.js +30 -0
  47. package/dist/react/next-nav.d.ts +11 -0
  48. package/dist/react/next-nav.js +32 -0
  49. package/dist/react/next-search.d.ts +22 -0
  50. package/dist/react/next-search.js +52 -0
  51. package/dist/react/search-dialog.d.ts +35 -7
  52. package/dist/react/search-dialog.js +55 -33
  53. package/dist/react/shell-labels.d.ts +43 -0
  54. package/dist/react/shell-labels.js +27 -0
  55. package/dist/react/sidebar.d.ts +38 -3
  56. package/dist/react/sidebar.js +104 -12
  57. package/dist/react/skip-link.d.ts +1 -9
  58. package/dist/react/skip-link.js +6 -5
  59. package/dist/react/toc.d.ts +12 -4
  60. package/dist/react/toc.js +46 -12
  61. package/dist/react/youtube.d.ts +31 -5
  62. package/dist/react/youtube.js +76 -52
  63. package/dist/render.d.ts +78 -10
  64. package/dist/render.js +137 -54
  65. package/dist/route-path.d.ts +46 -0
  66. package/dist/route-path.js +51 -0
  67. package/dist/search-index.d.ts +22 -21
  68. package/dist/search-index.js +27 -78
  69. package/dist/search-options.d.ts +32 -1
  70. package/dist/search-options.js +66 -3
  71. package/dist/section-boundary.d.ts +17 -0
  72. package/dist/section-boundary.js +43 -0
  73. package/dist/sitemap-limit.d.ts +34 -0
  74. package/dist/sitemap-limit.js +37 -0
  75. package/dist/source.d.ts +12 -22
  76. package/dist/source.js +165 -72
  77. package/dist/styles.css +1117 -125
  78. package/dist/types.d.ts +52 -29
  79. package/package.json +70 -34
@@ -0,0 +1,26 @@
1
+ //#region src/map-pooled.d.ts
2
+ /**
3
+ * Bounded-concurrency `Promise.all`.
4
+ *
5
+ * Private — deliberately not an entry point in `package.json`.
6
+ */
7
+ /**
8
+ * Like `Promise.all(items.map(fn))`, but with at most `limit` calls in flight.
9
+ *
10
+ * Results keep input order, and the first rejection rejects the whole call,
11
+ * exactly as `Promise.all` does. Workers also stop pulling new items once one
12
+ * has thrown: a build that is going to fail on page 3 should not render the
13
+ * other 1,997 first. In-flight calls are not cancelled — there is nothing to
14
+ * cancel them with — so up to `limit - 1` may still settle after the rejection.
15
+ *
16
+ * `Promise.all` over a whole documentation set is fine only while the mapped
17
+ * function is effectively synchronous — which is true of the markdown pipeline
18
+ * today, and stops being true the moment a consumer wires the async
19
+ * `imageResolver` the docs recommend. At that point every page's tree is live
20
+ * at once, and a 2,000-page site holds 2,000 hast trees plus 2,000 in-flight
21
+ * resolver calls in memory to produce output that was always going to be
22
+ * written in order.
23
+ */
24
+ declare function mapPooled<TItem, TResult>(items: readonly TItem[], limit: number, fn: (item: TItem, index: number) => Promise<TResult>): Promise<TResult[]>;
25
+ //#endregion
26
+ export { mapPooled };
@@ -0,0 +1,45 @@
1
+ //#region src/map-pooled.ts
2
+ /**
3
+ * Bounded-concurrency `Promise.all`.
4
+ *
5
+ * Private — deliberately not an entry point in `package.json`.
6
+ */
7
+ /**
8
+ * Like `Promise.all(items.map(fn))`, but with at most `limit` calls in flight.
9
+ *
10
+ * Results keep input order, and the first rejection rejects the whole call,
11
+ * exactly as `Promise.all` does. Workers also stop pulling new items once one
12
+ * has thrown: a build that is going to fail on page 3 should not render the
13
+ * other 1,997 first. In-flight calls are not cancelled — there is nothing to
14
+ * cancel them with — so up to `limit - 1` may still settle after the rejection.
15
+ *
16
+ * `Promise.all` over a whole documentation set is fine only while the mapped
17
+ * function is effectively synchronous — which is true of the markdown pipeline
18
+ * today, and stops being true the moment a consumer wires the async
19
+ * `imageResolver` the docs recommend. At that point every page's tree is live
20
+ * at once, and a 2,000-page site holds 2,000 hast trees plus 2,000 in-flight
21
+ * resolver calls in memory to produce output that was always going to be
22
+ * written in order.
23
+ */
24
+ async function mapPooled(items, limit, fn) {
25
+ if (items.length <= limit) return Promise.all(items.map((item, index) => fn(item, index)));
26
+ const results = new Array(items.length);
27
+ let next = 0;
28
+ let failed = false;
29
+ const worker = async () => {
30
+ while (next < items.length && !failed) {
31
+ const index = next++;
32
+ try {
33
+ results[index] = await fn(items[index], index);
34
+ } catch (error) {
35
+ failed = true;
36
+ throw error;
37
+ }
38
+ }
39
+ };
40
+ const workers = Math.max(1, Math.min(limit, items.length));
41
+ await Promise.all(Array.from({ length: workers }, () => worker()));
42
+ return results;
43
+ }
44
+ //#endregion
45
+ export { mapPooled };
package/dist/meta.d.ts CHANGED
@@ -67,9 +67,13 @@ declare function readDocsMeta(dirPath: string): Promise<DocsMeta | undefined>;
67
67
  * @param entries - The directory's children.
68
68
  * @param meta - Its validated `meta.json`, if any.
69
69
  * @param metaPath - Path to that `meta.json`, used in error messages.
70
- * @throws When `pages` names a child that does not exist always a typo, and
71
- * catching it here is the entire reason `meta.json` is validated at build.
70
+ * @param depth - How far this directory sits below the content root, which
71
+ * decides whether its own `index.md` is listed by default. See
72
+ * {@link isListedByDefault}.
73
+ * @throws When `pages` names a child that does not exist, or names one twice —
74
+ * always a typo, and catching it here is the entire reason `meta.json` is
75
+ * validated at build.
72
76
  */
73
- declare function orderNavEntries(entries: readonly MetaDirEntry[], meta: DocsMeta | undefined, metaPath: string): DocNavNode[];
77
+ declare function orderNavEntries(entries: readonly MetaDirEntry[], meta: DocsMeta | undefined, metaPath: string, depth: number): DocNavNode[];
74
78
  //#endregion
75
79
  export { MetaDirEntry, docsMetaSchema, orderNavEntries, parseDocsMeta, readDocsMeta };
package/dist/meta.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { docsError } from "./docs-error.js";
1
2
  import { z } from "zod";
2
3
  import { readFile } from "node:fs/promises";
3
4
  import path from "node:path";
@@ -40,7 +41,7 @@ function parseDocsMeta(raw, filePath) {
40
41
  const result = docsMetaSchema.safeParse(raw);
41
42
  if (result.success) return result.data;
42
43
  const details = result.error.issues.map((issue) => ` - ${issue.path.join(".") || "(root)"}: ${issue.message}`).join("\n");
43
- throw new Error(`Invalid meta.json at ${filePath}:\n${details}`);
44
+ throw docsError("invalid-meta", `Invalid meta.json at ${filePath}:\n${details}`);
44
45
  }
45
46
  /**
46
47
  * Read and validate `<dirPath>/meta.json`. Resolves to `undefined` when the
@@ -60,7 +61,7 @@ async function readDocsMeta(dirPath) {
60
61
  parsed = JSON.parse(raw);
61
62
  } catch (err) {
62
63
  const reason = err instanceof Error ? err.message : String(err);
63
- throw new Error(`Could not parse ${filePath} as JSON: ${reason}`);
64
+ throw docsError("invalid-meta", `Could not parse ${filePath} as JSON: ${reason}`, { cause: err });
64
65
  }
65
66
  return parseDocsMeta(parsed, filePath);
66
67
  }
@@ -75,12 +76,16 @@ async function readDocsMeta(dirPath) {
75
76
  * @param entries - The directory's children.
76
77
  * @param meta - Its validated `meta.json`, if any.
77
78
  * @param metaPath - Path to that `meta.json`, used in error messages.
78
- * @throws When `pages` names a child that does not exist always a typo, and
79
- * catching it here is the entire reason `meta.json` is validated at build.
79
+ * @param depth - How far this directory sits below the content root, which
80
+ * decides whether its own `index.md` is listed by default. See
81
+ * {@link isListedByDefault}.
82
+ * @throws When `pages` names a child that does not exist, or names one twice —
83
+ * always a typo, and catching it here is the entire reason `meta.json` is
84
+ * validated at build.
80
85
  */
81
- function orderNavEntries(entries, meta, metaPath) {
86
+ function orderNavEntries(entries, meta, metaPath, depth) {
82
87
  const pages = meta?.pages;
83
- if (pages === void 0) return dropEmptyGroups(sortEntries(entries.filter((entry) => isListedByDefault(entry))).map((entry) => entry.node));
88
+ if (pages === void 0) return dropEmptyGroups(sortEntries(entries.filter((entry) => isListedByDefault(entry, depth))).map((entry) => entry.node));
84
89
  const byName = indexByName(entries, metaPath);
85
90
  const used = /* @__PURE__ */ new Set();
86
91
  const nodes = [];
@@ -104,29 +109,42 @@ function orderNavEntries(entries, meta, metaPath) {
104
109
  continue;
105
110
  }
106
111
  if (page === REST) {
107
- if (restAt !== -1) throw new Error(`${metaPath} has more than one "..." entry. A directory has a single set of unnamed pages, so only one wildcard can be honoured — remove the extra.`);
112
+ if (restAt !== -1) throw docsError("invalid-meta", `${metaPath} has more than one "..." entry. A directory has a single set of unnamed pages, so only one wildcard can be honoured — remove the extra.`);
108
113
  restAt = nodes.length;
109
114
  continue;
110
115
  }
111
116
  if (page.startsWith(REST)) {
112
117
  const name = page.slice(3);
113
118
  const target = byName.get(name);
114
- if (!target?.inlineChildren) throw new Error(`${metaPath} entry "${page}" expands a directory named "${name}", which is not a subdirectory here. ${describeAvailable(entries, true)}`);
119
+ if (!target?.inlineChildren) throw docsError("invalid-meta", `${metaPath} entry "${page}" expands a directory named "${name}", which is not a subdirectory here. ${describeAvailable(entries, true)}`);
120
+ assertUnused(used, name, page, metaPath);
115
121
  used.add(name);
116
122
  if (target.indexNode !== void 0) nodes.push(target.indexNode);
117
123
  nodes.push(...target.inlineChildren);
118
124
  continue;
119
125
  }
120
126
  const target = byName.get(page);
121
- if (!target) throw new Error(`${metaPath} lists "${page}", which does not exist. ${describeAvailable(entries, false)}`);
127
+ if (!target) throw docsError("invalid-meta", `${metaPath} lists "${page}", which does not exist. ${describeAvailable(entries, false)}`);
128
+ assertUnused(used, page, page, metaPath);
122
129
  used.add(page);
123
130
  if (!target.hidden) nodes.push(target.node);
124
131
  }
125
132
  if (restAt !== -1) {
126
- const rest = sortEntries(entries.filter((entry) => isListedByDefault(entry) && !used.has(entry.name))).map((entry) => entry.node);
133
+ const rest = sortEntries(entries.filter((entry) => isListedByDefault(entry, depth) && !used.has(entry.name))).map((entry) => entry.node);
127
134
  nodes.splice(restAt, 0, ...rest);
128
135
  }
129
- return dropEmptyGroups(nodes);
136
+ return dropDanglingSeparators(dropEmptyGroups(nodes));
137
+ }
138
+ /**
139
+ * A child named twice is a typo, not an instruction.
140
+ *
141
+ * It renders the page twice, both copies highlighted on the page they link to,
142
+ * and the sidebar keys its items positionally — so there is no duplicate-key
143
+ * warning either. Every other ambiguity in this file is a build error; this
144
+ * one only looked deliberate because nothing checked for it.
145
+ */
146
+ function assertUnused(used, name, written, metaPath) {
147
+ if (used.has(name)) throw docsError("invalid-meta", `${metaPath} lists "${written}" more than once. It would appear twice in the sidebar, both copies marked as the current page. Remove the duplicate.`);
130
148
  }
131
149
  /**
132
150
  * Index the directory's children by the name `pages` addresses them with.
@@ -141,7 +159,7 @@ function indexByName(entries, metaPath) {
141
159
  const byName = /* @__PURE__ */ new Map();
142
160
  for (const entry of entries) {
143
161
  const clash = byName.get(entry.name);
144
- if (clash !== void 0) throw new Error(`${metaPath} cannot address "${entry.name}": ${describeEntry(clash)} and ${describeEntry(entry)} both claim that name. Rename one.`);
162
+ if (clash !== void 0) throw docsError("invalid-meta", `${metaPath} cannot address "${entry.name}": ${describeEntry(clash)} and ${describeEntry(entry)} both claim that name. Rename one.`);
145
163
  byName.set(entry.name, entry);
146
164
  }
147
165
  return byName;
@@ -157,9 +175,37 @@ function describeEntry(entry) {
157
175
  function dropEmptyGroups(nodes) {
158
176
  return nodes.filter((node) => node.type !== "group" || node.children.length > 0 || node.href !== void 0);
159
177
  }
160
- /** An entry appears without being named: not the index, not a draft. */
161
- function isListedByDefault(entry) {
162
- return entry.isIndex !== true && entry.hidden !== true;
178
+ /**
179
+ * A separator labels whatever follows it. When that turned out to be nothing —
180
+ * every page in the next group a draft, or two separators in a row after one
181
+ * of them emptied — the heading is left standing over nothing.
182
+ *
183
+ * Runs after {@link dropEmptyGroups}, which is what empties them, and cannot be
184
+ * left to the author to notice: `includeDrafts` is how a docs site is
185
+ * previewed, and it is exactly the mode where the group is not empty.
186
+ */
187
+ function dropDanglingSeparators(nodes) {
188
+ const kept = [];
189
+ for (const node of [...nodes].reverse()) {
190
+ const next = kept.at(-1);
191
+ if (node.type === "separator" && (next === void 0 || next.type === "separator")) continue;
192
+ kept.push(node);
193
+ }
194
+ return kept.reverse();
195
+ }
196
+ /**
197
+ * An entry appears without being named: not a draft, and not the directory's
198
+ * own `index.md` — which is the group heading's link, and would be a second
199
+ * entry for a page the sidebar already shows.
200
+ *
201
+ * The content root is the exception, and the case the rule never considered:
202
+ * nothing encloses it, so its `index.md` is the one page that has no heading
203
+ * to carry its href. Without this, the site's landing page is missing from its
204
+ * own sidebar — a reader who follows any link cannot get back — and every
205
+ * install writes a `meta.json` whose only purpose is to undo the default.
206
+ */
207
+ function isListedByDefault(entry, depth) {
208
+ return entry.hidden !== true && (entry.isIndex !== true || depth === 0);
163
209
  }
164
210
  function sortEntries(entries) {
165
211
  return [...entries].sort(compareEntries);
package/dist/next.d.ts CHANGED
@@ -1,48 +1,55 @@
1
- import { DocFile, DocFrontmatter, DocsConfig, ImageResolver, LinkResolver, RenderedDoc } from "./types.js";
1
+ import { DocFile, DocFrontmatter, DocsConfig, ImageResolver, LinkResolver, RenderedDoc, SearchRecord } from "./types.js";
2
2
  import { DocsHighlighter, DocsLang, DocsTheme, DocsThemes } from "./highlighter.js";
3
3
  import { MarkdownComponents } from "./react/markdown-components.js";
4
+ import { DocsLabels } from "./react/shell-labels.js";
5
+ import { DocsLayoutSearchProps } from "./react/layout.js";
4
6
  import { DocsSource } from "./source.js";
5
7
  import { ReactNode } from "react";
8
+ import { PluggableList } from "unified";
9
+ import { Options } from "minisearch";
6
10
  //#region src/next.d.ts
7
11
  interface DocsRouteOptions<TFrontmatter extends DocFrontmatter = DocFrontmatter> extends DocsConfig<TFrontmatter> {
8
12
  /** Overrides merged over the Next-flavoured defaults (`next/link` + `next/image`). */
9
- components?: MarkdownComponents;
13
+ components?: MarkdownComponents | undefined;
10
14
  /** Reuse an existing Shiki highlighter. */
11
- highlighter?: DocsHighlighter | Promise<DocsHighlighter>;
15
+ highlighter?: DocsHighlighter | Promise<DocsHighlighter> | undefined;
12
16
  /** Grammars to load, when building the default highlighter. */
13
- langs?: readonly DocsLang[];
17
+ langs?: readonly DocsLang[] | undefined;
14
18
  /** Theme pair. */
15
- themes?: DocsThemes;
19
+ themes?: DocsThemes | undefined;
20
+ /**
21
+ * Fence languages Shiki must not touch, e.g. `['mermaid']`.
22
+ *
23
+ * The `<pre><code class="language-mermaid">` then reaches your `pre`/`code`
24
+ * component untouched, which is what lets you render a diagram instead of a
25
+ * monochrome block of DSL.
26
+ */
27
+ excludeLangs?: readonly string[] | undefined;
16
28
  /**
17
29
  * Prepend an `<h1>` from `frontmatter.title` when the markdown has none.
18
30
  * Defaults to `true`; turn it off if your layout renders the title itself.
19
31
  */
20
- titleHeading?: boolean;
32
+ titleHeading?: boolean | undefined;
21
33
  /**
22
- * `id` of the rendered `<article>`, which is also what
23
- * `@waveso/docs/react/skip-link` targets by default. Defaults to
24
- * `'docs-content'`. Pass `false` to render no id at all.
34
+ * Extra remark plugins, attached after `remarkGfm` and before link
35
+ * resolution so anything they emit is folded, contained and asserted like
36
+ * authored markdown.
25
37
  */
26
- contentId?: string | false;
38
+ remarkPlugins?: PluggableList | undefined;
27
39
  /**
28
- * Re-read the content directory on every request.
29
- *
30
- * Defaults to `true` outside `NODE_ENV=production`. Markdown files are not in
31
- * Next's module graph, so nothing re-evaluates a route module when one
32
- * changes: without this, `next dev` serves whatever it read on the first
33
- * request until the server restarts, and a file added afterwards is never
34
- * found. A rescan of a few hundred small files costs single-digit
35
- * milliseconds; a production build reads the tree once, as it should.
40
+ * Extra rehype plugins, attached after heading ids and permalinks exist and
41
+ * before the code steps — so a `<pre>` is still the author's text rather
42
+ * than Shiki's token spans.
36
43
  */
37
- rescanPerRequest?: boolean;
44
+ rehypePlugins?: PluggableList | undefined;
38
45
  /** Replaces the built-in markdown-link resolution. */
39
- linkResolver?: LinkResolver;
46
+ linkResolver?: LinkResolver | undefined;
40
47
  /**
41
48
  * Resolves image `src` to a public URL and intrinsic dimensions. Without one,
42
49
  * markdown images render as a plain `<img>`: `next/image` refuses to render
43
50
  * without dimensions, and markdown carries none.
44
51
  */
45
- imageResolver?: ImageResolver;
52
+ imageResolver?: ImageResolver | undefined;
46
53
  /**
47
54
  * Absolute site origin, e.g. `'https://example.com'`.
48
55
  *
@@ -50,7 +57,65 @@ interface DocsRouteOptions<TFrontmatter extends DocFrontmatter = DocFrontmatter>
50
57
  * root-relative path, which Next resolves against `metadataBase` — so if you
51
58
  * set neither, you ship pages with no usable canonical.
52
59
  */
53
- siteUrl?: string;
60
+ siteUrl?: string | undefined;
61
+ /**
62
+ * MiniSearch overrides for the index {@link DocsRoute.searchIndex} builds.
63
+ *
64
+ * ⚠️ THE IDENTICAL OBJECT MUST REACH THE DIALOG — pass it to `DocsSearch`'s
65
+ * (or `SearchDialog`'s) `miniSearchOptions`. MiniSearch reads `tokenize` and
66
+ * `processTerm` both when indexing and when querying, so applying one here
67
+ * and not there produces an index whose terms no query can spell: zero
68
+ * results, no error, nothing in the console.
69
+ */
70
+ miniSearchOptions?: Partial<Options<SearchRecord>> | undefined;
71
+ }
72
+ /**
73
+ * Props for {@link DocsRoute.Layout}.
74
+ *
75
+ * Four, and the fourth is a boolean. Everything else a docs shell is asked for
76
+ * turned out to be reachable already: an announcement banner renders *above*
77
+ * `<docs.Layout>` in your own `layout.tsx`, because this does not own `<body>`;
78
+ * a content footer goes inside `children`; and sidebar links, social icons and
79
+ * separators are `DocNavNode`s authored in `meta.json`. The header bar is the
80
+ * one region nothing else can reach, which is what `actions` is for.
81
+ *
82
+ * A `slots` map was the alternative, and it can still be added later — two node
83
+ * props can become a slots map, a slots map cannot become two props.
84
+ */
85
+ interface DocsLayoutProps {
86
+ children: ReactNode;
87
+ /**
88
+ * Brand at the header start. A string, or your own logo component.
89
+ *
90
+ * `ReactNode`, so it cannot also serve as the `<title>` or as the header's
91
+ * accessible name; the landmark carries a fixed label instead.
92
+ */
93
+ title?: ReactNode;
94
+ /** Header end, after search: a theme toggle, a version switcher, a link. */
95
+ actions?: ReactNode;
96
+ /**
97
+ * The search trigger. Defaults to on, and the URL is always derived.
98
+ *
99
+ * `false` omits it. An object configures the dialog — `placeholder`,
100
+ * `hotkey`, `miniSearchOptions` and the rest of `DocsSearch`'s surface,
101
+ * minus `indexUrl`.
102
+ *
103
+ * You do not need to pass `miniSearchOptions` here to match what
104
+ * `createDocsRoute` was given: the route's own value is forwarded, so the
105
+ * object that built the index is the object that queries it. Pass one only
106
+ * to override that.
107
+ */
108
+ search?: boolean | DocsLayoutSearchProps | undefined;
109
+ /**
110
+ * The four strings the shell renders itself: the navigation landmark's name,
111
+ * the drawer's open and close buttons, and the skip link.
112
+ *
113
+ * Everything else a reader sees is your markdown or your `title`. This is the
114
+ * whole of what a non-English site has to say — and it is the fifth prop,
115
+ * added deliberately: a documentation shell nobody can translate is not a
116
+ * shell for the whole ecosystem.
117
+ */
118
+ labels?: DocsLabels | undefined;
54
119
  }
55
120
  /** Props Next hands a page in the App Router. */
56
121
  interface DocsPageProps {
@@ -142,13 +207,17 @@ interface DocsRoute<TFrontmatter extends DocFrontmatter = DocFrontmatter> {
142
207
  * is documented in one place and typed as `false`, not so it can be
143
208
  * forwarded.
144
209
  *
145
- * Declaring it is not optional. Next defaults `dynamicParams` to `true`,
146
- * which means a URL that `generateStaticParams` never listed is still
147
- * rendered on demand so `/docs/typo` reaches the source layer,
148
- * `fs.readFile` throws `ENOENT`, and Next answers **HTTP 500**. Google
149
- * treats a 5xx as a crawl failure and retries it; it treats a 404 as an
150
- * answer. With the full page set known at build time there is nothing to
151
- * render on demand anyway.
210
+ * Declaring it is not optional. Next defaults `dynamicParams` to `true`, so
211
+ * a URL `generateStaticParams` never listed is still invoked on demand: the
212
+ * route runs on a server at request time to produce a 404 that was already
213
+ * knowable at build time. `output: 'export'` refuses to build without it at
214
+ * all. With the full page set known ahead of time there is nothing to render
215
+ * on demand anyway, and a prerendered 404 is both faster and cacheable.
216
+ *
217
+ * (An earlier version of this note claimed an unlisted URL reached
218
+ * `fs.readFile` and returned HTTP 500. It does not: `find()` is a lookup in
219
+ * the map built by the directory walk, so a miss is just `undefined`. The
220
+ * export is still required, for the reasons above.)
152
221
  */
153
222
  dynamicParams: false;
154
223
  /**
@@ -163,11 +232,89 @@ interface DocsRoute<TFrontmatter extends DocFrontmatter = DocFrontmatter> {
163
232
  */
164
233
  getPage: (segments: string[]) => Promise<RenderedDoc<TFrontmatter> | undefined>;
165
234
  /**
166
- * Every published page, rendered. The input to
167
- * `extractSearchRecords`/`writeSearchIndex` nothing builds the search index
168
- * for you.
235
+ * Every published page, rendered. The escape hatch behind
236
+ * {@link DocsRoute.searchIndex}, for anyone building their own artifact out
237
+ * of `extractSearchRecords`.
169
238
  */
170
239
  renderAll: () => Promise<Array<RenderedDoc<TFrontmatter>>>;
240
+ /**
241
+ * `GET` handler for `app/<basePath>/search-index.json/route.ts`, serving the
242
+ * MiniSearch index the dialog fetches.
243
+ *
244
+ * ```ts
245
+ * // app/docs/search-index.json/route.ts — the whole file
246
+ * import { docs } from '@/lib/docs';
247
+ *
248
+ * export const GET = docs.searchIndex;
249
+ * export const dynamic = 'force-static'; // a literal, see below
250
+ * ```
251
+ *
252
+ * **`dynamic = 'force-static'` is not optional and must be a literal**, for
253
+ * the same reason as {@link DocsRoute.dynamicParams}: route segment config is
254
+ * parsed out of the module before any of it runs. Without it Next marks the
255
+ * route `ƒ` (Dynamic) and re-renders your entire corpus on every request —
256
+ * from markdown that output tracing did not put in the deployment bundle, so
257
+ * on a serverless host it does not merely get slow, it throws, at the reader,
258
+ * inside the search dialog. The build prints no warning for this, so the
259
+ * handler detects it at runtime and throws with `code:
260
+ * 'search-index-dynamic'` instead of failing quietly.
261
+ *
262
+ * The index is built from the same `renderAll()` → `extractSearchRecords` →
263
+ * `buildSearchIndex` pipeline you could write by hand, with the `charset`-free
264
+ * `application/json` content type, a strong `ETag` and
265
+ * `cache-control: public, max-age=0, must-revalidate` — Next's default for a
266
+ * prerendered route is a year of `s-maxage` with no validator, which on a
267
+ * stable URL means a CDN serving last year's index until someone purges it.
268
+ */
269
+ searchIndex: () => Promise<Response>;
270
+ /**
271
+ * Default export for `app/<basePath>/layout.tsx` — the entire docs shell.
272
+ *
273
+ * ```tsx
274
+ * // app/docs/layout.tsx — the whole file
275
+ * import '@waveso/docs/styles.css';
276
+ * import { docs } from '@/lib/docs';
277
+ *
278
+ * export default docs.Layout;
279
+ * ```
280
+ *
281
+ * Or, with your own chrome in the header:
282
+ *
283
+ * ```tsx
284
+ * export default function DocsLayout({ children }: { children: ReactNode }) {
285
+ * return (
286
+ * <docs.Layout title={<Logo />} actions={<ThemeToggle />}>
287
+ * {children}
288
+ * </docs.Layout>
289
+ * );
290
+ * }
291
+ * ```
292
+ *
293
+ * It owns the skip link, the header, the sidebar column, the mobile drawer
294
+ * and the grid, and it reads `source.nav()` and `searchIndexUrl` itself — so
295
+ * there is no nav to fetch and no URL to pass. It does **not** own the table
296
+ * of contents: a Next layout receives `{children, params}` and cannot know
297
+ * which page is rendering, so `docs.Page` emits the TOC as its second child
298
+ * and the grid places it.
299
+ *
300
+ * Your `layout.tsx` stays a Server Component. The two pieces that need a
301
+ * client — the nav's `usePathname`, the search dialog — carry their own
302
+ * `'use client'` boundaries inside the package.
303
+ *
304
+ * Next passes `{ children, params }`; the extra `params` is ignored, which is
305
+ * why `export default docs.Layout` type-checks as a layout.
306
+ */
307
+ Layout: (props: DocsLayoutProps) => Promise<ReactNode>;
308
+ /**
309
+ * `${basePath}/search-index.json` — hand it to `DocsSearch`'s `indexUrl`.
310
+ *
311
+ * Derived from the route's own `basePath`, so it is right when the docs are
312
+ * mounted at `/`, at `/docs`, or under a nested prefix. It is *not* prefixed
313
+ * with Next's `basePath` config, which Next applies to `<Link>` and to
314
+ * navigation but never to a client `fetch()` — on a site setting that, prefix
315
+ * it yourself.
316
+ */
317
+ searchIndexUrl: string;
171
318
  }
172
319
  /**
173
320
  * Create the route handlers for a documentation tree.
@@ -191,9 +338,9 @@ interface DocsSitemapOptions<TFrontmatter extends DocFrontmatter = DocFrontmatte
191
338
  */
192
339
  siteUrl: string;
193
340
  /** Applied to every entry. Omitted by default — Google ignores it anyway. */
194
- changeFrequency?: DocsSitemapEntry['changeFrequency'];
341
+ changeFrequency?: DocsSitemapEntry['changeFrequency'] | undefined;
195
342
  /** Applied to every entry. Omitted by default. */
196
- priority?: number;
343
+ priority?: number | undefined;
197
344
  /**
198
345
  * Override the last-modified date per page.
199
346
  *
@@ -253,4 +400,4 @@ interface DocsRedirect {
253
400
  */
254
401
  declare function createDocsRedirects(config: DocsConfig): Promise<DocsRedirect[]>;
255
402
  //#endregion
256
- export { type DocsLang, DocsPageMetadata, DocsPageProps, DocsRedirect, DocsRoute, DocsRouteOptions, DocsSitemapEntry, DocsSitemapOptions, type DocsTheme, type DocsThemes, createDocsRedirects, createDocsRoute, createDocsSitemap };
403
+ export { type DocsLang, DocsLayoutProps, DocsPageMetadata, DocsPageProps, DocsRedirect, DocsRoute, DocsRouteOptions, DocsSitemapEntry, DocsSitemapOptions, type DocsTheme, type DocsThemes, createDocsRedirects, createDocsRoute, createDocsSitemap };