@waveso/docs 0.1.0 → 0.2.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 (41) hide show
  1. package/CHANGELOG.md +84 -0
  2. package/README.md +111 -22
  3. package/dist/docs-error.d.ts +74 -0
  4. package/dist/docs-error.js +40 -0
  5. package/dist/frontmatter.d.ts +39 -7
  6. package/dist/frontmatter.js +51 -24
  7. package/dist/highlighter.d.ts +2 -2
  8. package/dist/highlighter.js +3 -2
  9. package/dist/map-pooled.d.ts +26 -0
  10. package/dist/map-pooled.js +45 -0
  11. package/dist/meta.d.ts +7 -3
  12. package/dist/meta.js +61 -15
  13. package/dist/next.d.ts +41 -19
  14. package/dist/next.js +117 -21
  15. package/dist/plugins/rehype-capture-toc.js +26 -15
  16. package/dist/plugins/rehype-code-language.d.ts +24 -0
  17. package/dist/plugins/rehype-code-language.js +48 -0
  18. package/dist/plugins/rehype-fallback-heading-ids.d.ts +6 -0
  19. package/dist/plugins/rehype-fallback-heading-ids.js +51 -0
  20. package/dist/plugins/rehype-flatten-roots.d.ts +7 -0
  21. package/dist/plugins/rehype-flatten-roots.js +39 -0
  22. package/dist/plugins/remark-doc-links.d.ts +12 -1
  23. package/dist/plugins/remark-doc-links.js +147 -20
  24. package/dist/react/markdown-components.js +71 -6
  25. package/dist/react/search-dialog.d.ts +23 -7
  26. package/dist/react/search-dialog.js +46 -29
  27. package/dist/react/toc.js +28 -5
  28. package/dist/react/youtube.js +6 -4
  29. package/dist/render.d.ts +43 -9
  30. package/dist/render.js +112 -50
  31. package/dist/search-index.d.ts +32 -14
  32. package/dist/search-index.js +45 -51
  33. package/dist/search-options.d.ts +32 -1
  34. package/dist/search-options.js +66 -3
  35. package/dist/section-boundary.d.ts +17 -0
  36. package/dist/section-boundary.js +43 -0
  37. package/dist/source.d.ts +13 -1
  38. package/dist/source.js +152 -56
  39. package/dist/styles.css +236 -90
  40. package/dist/types.d.ts +41 -27
  41. package/package.json +13 -12
@@ -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
@@ -6,24 +6,32 @@ import { ReactNode } from "react";
6
6
  //#region src/next.d.ts
7
7
  interface DocsRouteOptions<TFrontmatter extends DocFrontmatter = DocFrontmatter> extends DocsConfig<TFrontmatter> {
8
8
  /** Overrides merged over the Next-flavoured defaults (`next/link` + `next/image`). */
9
- components?: MarkdownComponents;
9
+ components?: MarkdownComponents | undefined;
10
10
  /** Reuse an existing Shiki highlighter. */
11
- highlighter?: DocsHighlighter | Promise<DocsHighlighter>;
11
+ highlighter?: DocsHighlighter | Promise<DocsHighlighter> | undefined;
12
12
  /** Grammars to load, when building the default highlighter. */
13
- langs?: readonly DocsLang[];
13
+ langs?: readonly DocsLang[] | undefined;
14
14
  /** Theme pair. */
15
- themes?: DocsThemes;
15
+ themes?: DocsThemes | undefined;
16
+ /**
17
+ * Fence languages Shiki must not touch, e.g. `['mermaid']`.
18
+ *
19
+ * The `<pre><code class="language-mermaid">` then reaches your `pre`/`code`
20
+ * component untouched, which is what lets you render a diagram instead of a
21
+ * monochrome block of DSL.
22
+ */
23
+ excludeLangs?: readonly string[] | undefined;
16
24
  /**
17
25
  * Prepend an `<h1>` from `frontmatter.title` when the markdown has none.
18
26
  * Defaults to `true`; turn it off if your layout renders the title itself.
19
27
  */
20
- titleHeading?: boolean;
28
+ titleHeading?: boolean | undefined;
21
29
  /**
22
30
  * `id` of the rendered `<article>`, which is also what
23
31
  * `@waveso/docs/react/skip-link` targets by default. Defaults to
24
32
  * `'docs-content'`. Pass `false` to render no id at all.
25
33
  */
26
- contentId?: string | false;
34
+ contentId?: string | false | undefined;
27
35
  /**
28
36
  * Re-read the content directory on every request.
29
37
  *
@@ -34,15 +42,15 @@ interface DocsRouteOptions<TFrontmatter extends DocFrontmatter = DocFrontmatter>
34
42
  * found. A rescan of a few hundred small files costs single-digit
35
43
  * milliseconds; a production build reads the tree once, as it should.
36
44
  */
37
- rescanPerRequest?: boolean;
45
+ rescanPerRequest?: boolean | undefined;
38
46
  /** Replaces the built-in markdown-link resolution. */
39
- linkResolver?: LinkResolver;
47
+ linkResolver?: LinkResolver | undefined;
40
48
  /**
41
49
  * Resolves image `src` to a public URL and intrinsic dimensions. Without one,
42
50
  * markdown images render as a plain `<img>`: `next/image` refuses to render
43
51
  * without dimensions, and markdown carries none.
44
52
  */
45
- imageResolver?: ImageResolver;
53
+ imageResolver?: ImageResolver | undefined;
46
54
  /**
47
55
  * Absolute site origin, e.g. `'https://example.com'`.
48
56
  *
@@ -50,7 +58,7 @@ interface DocsRouteOptions<TFrontmatter extends DocFrontmatter = DocFrontmatter>
50
58
  * root-relative path, which Next resolves against `metadataBase` — so if you
51
59
  * set neither, you ship pages with no usable canonical.
52
60
  */
53
- siteUrl?: string;
61
+ siteUrl?: string | undefined;
54
62
  }
55
63
  /** Props Next hands a page in the App Router. */
56
64
  interface DocsPageProps {
@@ -142,13 +150,17 @@ interface DocsRoute<TFrontmatter extends DocFrontmatter = DocFrontmatter> {
142
150
  * is documented in one place and typed as `false`, not so it can be
143
151
  * forwarded.
144
152
  *
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.
153
+ * Declaring it is not optional. Next defaults `dynamicParams` to `true`, so
154
+ * a URL `generateStaticParams` never listed is still invoked on demand: the
155
+ * route runs on a server at request time to produce a 404 that was already
156
+ * knowable at build time. `output: 'export'` refuses to build without it at
157
+ * all. With the full page set known ahead of time there is nothing to render
158
+ * on demand anyway, and a prerendered 404 is both faster and cacheable.
159
+ *
160
+ * (An earlier version of this note claimed an unlisted URL reached
161
+ * `fs.readFile` and returned HTTP 500. It does not: `find()` is a lookup in
162
+ * the map built by the directory walk, so a miss is just `undefined`. The
163
+ * export is still required, for the reasons above.)
152
164
  */
153
165
  dynamicParams: false;
154
166
  /**
@@ -191,9 +203,9 @@ interface DocsSitemapOptions<TFrontmatter extends DocFrontmatter = DocFrontmatte
191
203
  */
192
204
  siteUrl: string;
193
205
  /** Applied to every entry. Omitted by default — Google ignores it anyway. */
194
- changeFrequency?: DocsSitemapEntry['changeFrequency'];
206
+ changeFrequency?: DocsSitemapEntry['changeFrequency'] | undefined;
195
207
  /** Applied to every entry. Omitted by default. */
196
- priority?: number;
208
+ priority?: number | undefined;
197
209
  /**
198
210
  * Override the last-modified date per page.
199
211
  *
@@ -202,6 +214,16 @@ interface DocsSitemapOptions<TFrontmatter extends DocFrontmatter = DocFrontmatte
202
214
  * noise. Wire this to your git history if the dates are load-bearing.
203
215
  */
204
216
  lastModified?: (file: DocFile<TFrontmatter>) => Date | undefined | Promise<Date | undefined>;
217
+ /**
218
+ * Re-read the content directory before building the sitemap.
219
+ *
220
+ * Defaults to `true` outside `NODE_ENV=production`, matching
221
+ * {@link DocsRouteOptions.rescanPerRequest}. `createDocsSource` memoises by
222
+ * config, so without this the first scan of the process is the only one —
223
+ * and `app/sitemap.ts` in `next dev` would keep serving the page set as it
224
+ * stood when the server booted.
225
+ */
226
+ rescanPerRequest?: boolean | undefined;
205
227
  }
206
228
  /**
207
229
  * Sitemap entries for every published page, for `app/sitemap.ts`.
package/dist/next.js CHANGED
@@ -1,10 +1,12 @@
1
+ import { docsError } from "./docs-error.js";
2
+ import { mapPooled } from "./map-pooled.js";
1
3
  import { createMarkdownComponents } from "./react/markdown-components.js";
2
4
  import { DocContent } from "./react/doc-content.js";
3
5
  import "./react/skip-link.js";
4
6
  import { createDocsRenderer } from "./render.js";
5
7
  import { createDocsSource, resolveDocsConfig, toAliasRoute } from "./source.js";
6
8
  import { stat } from "node:fs/promises";
7
- import { createElement } from "react";
9
+ import { cache, createElement } from "react";
8
10
  //#region src/next.ts
9
11
  /**
10
12
  * The Next.js App Router adapter.
@@ -38,16 +40,41 @@ import { createElement } from "react";
38
40
  * export const generateMetadata = docs.generateMetadata;
39
41
  * ```
40
42
  *
41
- * `next` is an *optional* peer dependency, so its modules are imported lazily
42
- * and only from the code paths that render. That is what lets
43
+ * `next` is an *optional* peer dependency, so **Next's own modules** are
44
+ * imported lazily and only from the code paths that render. That is what lets
43
45
  * {@link createDocsSitemap} and {@link createDocsRedirects} be called from
44
46
  * `next.config.ts` — which Node loads outside the Next runtime — without
45
- * dragging React and Next's client runtime into the config load.
47
+ * dragging Next's client runtime into the config load.
48
+ *
49
+ * React itself is *not* excluded: this module statically imports `react` and
50
+ * the package's own React layer, so importing it from `next.config.ts` costs
51
+ * around 210 ms and ~870 modules (measured against 24 ms for an empty config).
52
+ * That is a startup cost, not a correctness problem, and it is stated here
53
+ * rather than claimed away — an earlier version of this note said React stayed
54
+ * out of the config load, which was never true.
55
+ */
56
+ /**
57
+ * Pages rendered at once by {@link DocsRoute.renderAll}.
58
+ *
59
+ * High enough that the pipeline — CPU-bound and effectively synchronous — never
60
+ * idles, low enough that an async `imageResolver` cannot put an entire site's
61
+ * worth of trees and network calls in flight simultaneously.
46
62
  */
63
+ const RENDER_CONCURRENCY = 16;
64
+ /** Google's per-sitemap URL cap. */
65
+ const SITEMAP_URL_LIMIT = 5e4;
47
66
  function isRecord(value) {
48
67
  return typeof value === "object" && value !== null;
49
68
  }
50
69
  /**
70
+ * A React component type: a function, or an object tagged with `$$typeof`
71
+ * (`forwardRef`, `memo`, lazy…). Anything else that reaches `createElement`
72
+ * throws four frames inside React.
73
+ */
74
+ function isComponentLike(value) {
75
+ return typeof value === "function" || isRecord(value) && "$$typeof" in value;
76
+ }
77
+ /**
51
78
  * Pull the default export out of a lazily-imported module.
52
79
  *
53
80
  * This is the one place a cast is unavoidable — the import crosses a boundary
@@ -56,8 +83,9 @@ function isRecord(value) {
56
83
  * than as `undefined is not a function` four frames inside React.
57
84
  */
58
85
  function readDefaultExport(mod, specifier) {
59
- const value = isRecord(mod) && "default" in mod ? mod.default : mod;
60
- if (typeof value !== "function" && !isRecord(value)) throw new Error(`@waveso/docs: '${specifier}' has no usable default export. The \`@waveso/docs/next\` entry point requires Next.js 16 — install \`next\`, or build your pages from \`@waveso/docs/react/*\` instead.`);
86
+ let value = isRecord(mod) && "default" in mod ? mod.default : mod;
87
+ if (!isComponentLike(value) && isRecord(value) && "default" in value) value = value.default;
88
+ if (typeof value !== "function" && !isRecord(value)) throw docsError("missing-peer", `@waveso/docs: '${specifier}' has no usable default export. The \`@waveso/docs/next\` entry point requires Next.js 16 — install \`next\`, or build your pages from \`@waveso/docs/react/*\` instead.`);
61
89
  return value;
62
90
  }
63
91
  /**
@@ -72,13 +100,13 @@ async function importNext(load, specifier) {
72
100
  try {
73
101
  return await load();
74
102
  } catch (error) {
75
- throw new Error(`@waveso/docs: could not load '${specifier}'. The \`@waveso/docs/next\` entry point needs Next.js 16, which is an optional peer dependency — install \`next\`. Outside Next, build your pages from \`@waveso/docs/react/*\` and load content yourself with \`@waveso/docs/source\` and \`@waveso/docs/render\`.`, { cause: error });
103
+ throw docsError("missing-peer", `@waveso/docs: could not load '${specifier}'. The \`@waveso/docs/next\` entry point needs Next.js 16, which is an optional peer dependency — install \`next\`. Outside Next, build your pages from \`@waveso/docs/react/*\` and load content yourself with \`@waveso/docs/source\` and \`@waveso/docs/render\`.`, { cause: error });
76
104
  }
77
105
  }
78
106
  async function loadNotFound() {
79
107
  const mod = await importNext(() => import("next/navigation"), "next/navigation");
80
108
  const value = isRecord(mod) ? mod.notFound : void 0;
81
- if (typeof value !== "function") throw new Error("@waveso/docs: 'next/navigation' has no `notFound` export. The `@waveso/docs/next` entry point requires Next.js 16.");
109
+ if (typeof value !== "function") throw docsError("missing-peer", "@waveso/docs: 'next/navigation' has no `notFound` export. The `@waveso/docs/next` entry point requires Next.js 16.");
82
110
  return value;
83
111
  }
84
112
  /**
@@ -159,9 +187,23 @@ function createDocsRoute(options) {
159
187
  const rescanPerRequest = options.rescanPerRequest ?? process.env.NODE_ENV !== "production";
160
188
  let renderer = null;
161
189
  const knownRoutes = /* @__PURE__ */ new Set();
190
+ const draftRoutes = /* @__PURE__ */ new Set();
191
+ const aliasRoutes = /* @__PURE__ */ new Map();
162
192
  let routesLoaded = null;
163
193
  /**
164
- * Drop the cached scan so the next query reads the disk again.
194
+ * Drop the cached scan so the next query reads the disk again — at most once
195
+ * per request.
196
+ *
197
+ * `React.cache` is doing real work here, not memoising for speed. Next runs
198
+ * `generateMetadata` and `Page` concurrently, and a layout calling
199
+ * `source.nav()` is a third caller; each used to invalidate independently,
200
+ * so each discarded the others' in-flight scan. Measured on a 401-file tree:
201
+ * 22 readdir + 824 readFile per request, against 11 + 412 for a single scan,
202
+ * at 39 ms — which is also why the old docstring's "single-digit
203
+ * milliseconds" was wrong. Inside a request the first caller invalidates and
204
+ * the rest see the memo; outside one (a sitemap built from `next.config.ts`,
205
+ * a script) `cache` does not memoise at all, so those callers keep the old
206
+ * invalidate-every-time behaviour, which is what they want.
165
207
  *
166
208
  * `knownRoutes` is added to, never cleared: it is shared with the renderer,
167
209
  * and emptying it while a concurrent render is asserting links would fail
@@ -169,26 +211,74 @@ function createDocsRoute(options) {
169
211
  * deleted page only makes dev *more* permissive than the production build,
170
212
  * which is the right direction to be wrong in.
171
213
  */
172
- const invalidate = () => {
214
+ const invalidate = cache(() => {
173
215
  source.invalidate();
174
216
  routesLoaded = null;
175
- };
176
- const loadRoutes = () => routesLoaded ??= source.all().then((files) => {
217
+ });
218
+ /**
219
+ * Record each page's own route in `into`, and each of its aliases in
220
+ * `aliasRoutes`.
221
+ *
222
+ * The two are kept apart on purpose. An alias used to be added to
223
+ * `knownRoutes` on the reasoning that a permanent redirect resolves — but it
224
+ * only resolves once `createDocsRedirects` is wired into `next.config.ts`,
225
+ * which the quick start never does, and `generateStaticParams` does not emit
226
+ * it either. So a page linking a sibling's alias built green and 404'd for
227
+ * every reader, with `dynamicParams = false` making it a hard 404. The
228
+ * renderer now names the alias's target instead, which is better advice than
229
+ * the acceptance ever was: the author gets told where the page actually is.
230
+ */
231
+ const collectRoutes = (into, files) => {
177
232
  for (const file of files) {
178
- knownRoutes.add(file.href);
179
- for (const alias of file.frontmatter.aliases ?? []) knownRoutes.add(toAliasRoute(alias, config.basePath, file.relativePath));
233
+ into.add(file.href);
234
+ for (const alias of file.frontmatter.aliases ?? []) aliasRoutes.set(toAliasRoute(alias, config.basePath, file.relativePath), file.href);
180
235
  }
236
+ };
237
+ const loadRoutes = () => routesLoaded ??= Promise.all([source.all(), source.drafts()]).then(([published, drafts]) => {
238
+ collectRoutes(knownRoutes, published);
239
+ collectRoutes(draftRoutes, drafts);
181
240
  });
182
241
  const loadRenderer = () => renderer ??= createDocsRenderer({
183
242
  config,
184
243
  knownRoutes,
244
+ draftRoutes,
245
+ aliasRoutes,
185
246
  ...options.highlighter === void 0 ? {} : { highlighter: options.highlighter },
186
247
  ...options.langs === void 0 ? {} : { langs: options.langs },
187
248
  ...options.themes === void 0 ? {} : { themes: options.themes },
249
+ ...options.excludeLangs === void 0 ? {} : { excludeLangs: options.excludeLangs },
188
250
  ...options.titleHeading === void 0 ? {} : { titleHeading: options.titleHeading },
189
251
  ...options.linkResolver === void 0 ? {} : { linkResolver: options.linkResolver },
190
252
  ...options.imageResolver === void 0 ? {} : { imageResolver: options.imageResolver }
191
253
  });
254
+ /** Re-read the disk on the route's schedule before delegating. */
255
+ const rescanned = (read) => {
256
+ return (...args) => {
257
+ if (rescanPerRequest) invalidate();
258
+ return read(...args);
259
+ };
260
+ };
261
+ /**
262
+ * The source handed to layouts.
263
+ *
264
+ * `docs.source.nav()` is the documented way to feed `DocsSidebar`, and it was
265
+ * the one reader that never invalidated — so in dev, the request after adding
266
+ * or renaming a page rendered the *new* body beside the *old* sidebar, and
267
+ * only the request after that agreed with itself. Everything else on the
268
+ * route already rescanned; this closes the last hole.
269
+ */
270
+ const requestScopedSource = {
271
+ config: source.config,
272
+ all: rescanned(() => source.all()),
273
+ drafts: rescanned(() => source.drafts()),
274
+ find: rescanned((segments) => source.find(segments)),
275
+ nav: rescanned(() => source.nav()),
276
+ slugs: rescanned(() => source.slugs()),
277
+ invalidate: () => {
278
+ source.invalidate();
279
+ routesLoaded = null;
280
+ }
281
+ };
192
282
  /**
193
283
  * `find` returns drafts regardless of config so a preview route can opt in;
194
284
  * a public route must not.
@@ -211,7 +301,7 @@ function createDocsRoute(options) {
211
301
  const files = await source.all();
212
302
  await loadRoutes();
213
303
  const renderer = loadRenderer();
214
- return Promise.all(files.map((file) => renderer.render(file)));
304
+ return mapPooled(files, RENDER_CONCURRENCY, (file) => renderer.render(file));
215
305
  };
216
306
  async function renderRoute(segments) {
217
307
  const doc = await getPage(segments);
@@ -232,7 +322,7 @@ function createDocsRoute(options) {
232
322
  }));
233
323
  }
234
324
  return {
235
- source,
325
+ source: requestScopedSource,
236
326
  getPage,
237
327
  renderAll,
238
328
  dynamicParams: false,
@@ -287,7 +377,10 @@ function createDocsRoute(options) {
287
377
  */
288
378
  async function createDocsSitemap(options) {
289
379
  const siteUrl = requireSiteUrl(options.siteUrl);
290
- const files = await createDocsSource(options).all();
380
+ const source = createDocsSource(options);
381
+ if (options.rescanPerRequest ?? process.env.NODE_ENV !== "production") source.invalidate();
382
+ const files = await source.all();
383
+ if (files.length > SITEMAP_URL_LIMIT) console.warn(`@waveso/docs: this sitemap has ${files.length} URLs, above Google's limit of ${SITEMAP_URL_LIMIT}. Split it with Next's \`generateSitemaps\` and slice the array this returns.`);
291
384
  const readDate = options.lastModified ?? readMtime;
292
385
  return Promise.all(files.map(async (file) => {
293
386
  const lastModified = await readDate(file);
@@ -338,9 +431,9 @@ async function createDocsRedirects(config) {
338
431
  for (const file of files) for (const alias of file.frontmatter.aliases ?? []) {
339
432
  const route = toAliasRoute(alias, resolved.basePath, file.relativePath);
340
433
  const page = routes.get(route);
341
- if (page !== void 0) throw new Error(`@waveso/docs: the alias '${alias}' in ${file.relativePath} redirects '${route}', which is already the route of ${page.relativePath}. Remove the alias, or rename the page it collides with.`);
434
+ if (page !== void 0) throw docsError("alias-collision", `@waveso/docs: the alias '${alias}' in ${file.relativePath} redirects '${route}', which is already the route of ${page.relativePath}. Remove the alias, or rename the page it collides with.`);
342
435
  const other = claimed.get(route);
343
- if (other !== void 0) throw new Error(`@waveso/docs: '${route}' is claimed as an alias by both ${other.relativePath} and ${file.relativePath}. An alias can only redirect to one page.`);
436
+ if (other !== void 0) throw docsError("alias-collision", `@waveso/docs: '${route}' is claimed as an alias by both ${other.relativePath} and ${file.relativePath}. An alias can only redirect to one page.`);
344
437
  claimed.set(route, file);
345
438
  redirects.push({
346
439
  source: route,
@@ -355,11 +448,14 @@ function normalizeSiteUrl(siteUrl) {
355
448
  }
356
449
  /** Fail at config time, not with a malformed `<link rel="canonical">`. */
357
450
  function requireSiteUrl(siteUrl) {
451
+ let parsed;
358
452
  try {
359
- return new URL(siteUrl).toString();
453
+ parsed = new URL(siteUrl);
360
454
  } catch {
361
- throw new Error(`@waveso/docs: '${siteUrl}' is not an absolute URL. Pass an origin such as 'https://example.com'.`);
455
+ throw docsError("invalid-config", `@waveso/docs: '${siteUrl}' is not an absolute URL. Pass an origin such as 'https://example.com'.`);
362
456
  }
457
+ if (parsed.pathname !== "/") throw docsError("invalid-config", `@waveso/docs: '${siteUrl}' has a path ('${parsed.pathname}'), and a site URL must be a bare origin — canonical and sitemap URLs are resolved against it, which discards the path. Pass '${parsed.origin}' and move '${parsed.pathname}' into \`basePath\`, which does accept multiple segments.`);
458
+ return parsed.toString();
363
459
  }
364
460
  //#endregion
365
461
  export { createDocsRedirects, createDocsRoute, createDocsSitemap };