@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.
- package/CHANGELOG.md +84 -0
- package/README.md +111 -22
- package/dist/docs-error.d.ts +74 -0
- package/dist/docs-error.js +40 -0
- package/dist/frontmatter.d.ts +39 -7
- package/dist/frontmatter.js +51 -24
- package/dist/highlighter.d.ts +2 -2
- package/dist/highlighter.js +3 -2
- package/dist/map-pooled.d.ts +26 -0
- package/dist/map-pooled.js +45 -0
- package/dist/meta.d.ts +7 -3
- package/dist/meta.js +61 -15
- package/dist/next.d.ts +41 -19
- package/dist/next.js +117 -21
- package/dist/plugins/rehype-capture-toc.js +26 -15
- package/dist/plugins/rehype-code-language.d.ts +24 -0
- package/dist/plugins/rehype-code-language.js +48 -0
- package/dist/plugins/rehype-fallback-heading-ids.d.ts +6 -0
- package/dist/plugins/rehype-fallback-heading-ids.js +51 -0
- package/dist/plugins/rehype-flatten-roots.d.ts +7 -0
- package/dist/plugins/rehype-flatten-roots.js +39 -0
- package/dist/plugins/remark-doc-links.d.ts +12 -1
- package/dist/plugins/remark-doc-links.js +147 -20
- package/dist/react/markdown-components.js +71 -6
- package/dist/react/search-dialog.d.ts +23 -7
- package/dist/react/search-dialog.js +46 -29
- package/dist/react/toc.js +28 -5
- package/dist/react/youtube.js +6 -4
- package/dist/render.d.ts +43 -9
- package/dist/render.js +112 -50
- package/dist/search-index.d.ts +32 -14
- package/dist/search-index.js +45 -51
- package/dist/search-options.d.ts +32 -1
- package/dist/search-options.js +66 -3
- package/dist/section-boundary.d.ts +17 -0
- package/dist/section-boundary.js +43 -0
- package/dist/source.d.ts +13 -1
- package/dist/source.js +152 -56
- package/dist/styles.css +236 -90
- package/dist/types.d.ts +41 -27
- 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
|
-
* @
|
|
71
|
-
*
|
|
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
|
|
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
|
|
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
|
-
* @
|
|
79
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
60
|
-
if (
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
179
|
-
for (const alias of file.frontmatter.aliases ?? [])
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
453
|
+
parsed = new URL(siteUrl);
|
|
360
454
|
} catch {
|
|
361
|
-
throw
|
|
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 };
|