@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.
- package/CHANGELOG.md +194 -0
- package/README.md +592 -88
- package/dist/code-frame.d.ts +29 -0
- package/dist/code-frame.js +41 -0
- package/dist/code-meta.d.ts +48 -0
- package/dist/code-meta.js +72 -0
- package/dist/docs-content-id.d.ts +19 -0
- package/dist/docs-content-id.js +19 -0
- package/dist/docs-error.d.ts +19 -0
- package/dist/docs-error.js +28 -0
- package/dist/errors.d.ts +94 -0
- package/dist/errors.js +45 -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 +182 -35
- package/dist/next.js +177 -49
- package/dist/plugins/rehype-capture-toc.js +52 -20
- package/dist/plugins/rehype-code-frame.d.ts +10 -0
- package/dist/plugins/rehype-code-frame.js +88 -0
- package/dist/plugins/rehype-code-language.d.ts +24 -0
- package/dist/plugins/rehype-code-language.js +54 -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/code-runtime.d.ts +14 -0
- package/dist/react/code-runtime.js +161 -0
- package/dist/react/doc-content.d.ts +39 -2
- package/dist/react/doc-content.js +42 -10
- package/dist/react/layout.d.ts +44 -0
- package/dist/react/layout.js +65 -0
- package/dist/react/markdown-components.js +71 -6
- package/dist/react/nav.d.ts +28 -0
- package/dist/react/nav.js +70 -0
- package/dist/react/nearest-scroll-top.d.ts +45 -0
- package/dist/react/nearest-scroll-top.js +44 -0
- package/dist/react/next-link.d.ts +34 -0
- package/dist/react/next-link.js +30 -0
- package/dist/react/next-nav.d.ts +11 -0
- package/dist/react/next-nav.js +32 -0
- package/dist/react/next-search.d.ts +22 -0
- package/dist/react/next-search.js +52 -0
- package/dist/react/search-dialog.d.ts +35 -7
- package/dist/react/search-dialog.js +55 -33
- package/dist/react/shell-labels.d.ts +43 -0
- package/dist/react/shell-labels.js +27 -0
- package/dist/react/sidebar.d.ts +38 -3
- package/dist/react/sidebar.js +104 -12
- package/dist/react/skip-link.d.ts +1 -9
- package/dist/react/skip-link.js +6 -5
- package/dist/react/toc.d.ts +12 -4
- package/dist/react/toc.js +46 -12
- package/dist/react/youtube.d.ts +31 -5
- package/dist/react/youtube.js +76 -52
- package/dist/render.d.ts +78 -10
- package/dist/render.js +137 -54
- package/dist/route-path.d.ts +46 -0
- package/dist/route-path.js +51 -0
- package/dist/search-index.d.ts +22 -21
- package/dist/search-index.js +27 -78
- 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/sitemap-limit.d.ts +34 -0
- package/dist/sitemap-limit.js +37 -0
- package/dist/source.d.ts +12 -22
- package/dist/source.js +165 -72
- package/dist/styles.css +1117 -125
- package/dist/types.d.ts +52 -29
- package/package.json +70 -34
package/dist/next.js
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
|
+
import { docsError } from "./docs-error.js";
|
|
2
|
+
import { DOCS_CONTENT_ID } from "./docs-content-id.js";
|
|
3
|
+
import { mapPooled } from "./map-pooled.js";
|
|
1
4
|
import { createMarkdownComponents } from "./react/markdown-components.js";
|
|
2
5
|
import { DocContent } from "./react/doc-content.js";
|
|
3
|
-
import "./react/
|
|
6
|
+
import { DocsToc } from "./react/toc.js";
|
|
7
|
+
import { wrapNextLink } from "./react/next-link.js";
|
|
4
8
|
import { createDocsRenderer } from "./render.js";
|
|
5
|
-
import {
|
|
9
|
+
import { toAliasRoute } from "./route-path.js";
|
|
10
|
+
import { createDocsSource, resolveDocsConfig } from "./source.js";
|
|
11
|
+
import { sitemapLimitWarning } from "./sitemap-limit.js";
|
|
6
12
|
import { stat } from "node:fs/promises";
|
|
7
|
-
import {
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
14
|
+
import { Fragment, cache, createElement } from "react";
|
|
8
15
|
//#region src/next.ts
|
|
9
16
|
/**
|
|
10
17
|
* The Next.js App Router adapter.
|
|
@@ -38,16 +45,39 @@ import { createElement } from "react";
|
|
|
38
45
|
* export const generateMetadata = docs.generateMetadata;
|
|
39
46
|
* ```
|
|
40
47
|
*
|
|
41
|
-
* `next` is an *optional* peer dependency, so
|
|
42
|
-
* and only from the code paths that render. That is what lets
|
|
48
|
+
* `next` is an *optional* peer dependency, so **Next's own modules** are
|
|
49
|
+
* imported lazily and only from the code paths that render. That is what lets
|
|
43
50
|
* {@link createDocsSitemap} and {@link createDocsRedirects} be called from
|
|
44
51
|
* `next.config.ts` — which Node loads outside the Next runtime — without
|
|
45
|
-
* dragging
|
|
52
|
+
* dragging Next's client runtime into the config load.
|
|
53
|
+
*
|
|
54
|
+
* React itself is *not* excluded: this module statically imports `react` and
|
|
55
|
+
* the package's own React layer, so importing it from `next.config.ts` costs
|
|
56
|
+
* around 210 ms and ~870 modules (measured against 24 ms for an empty config).
|
|
57
|
+
* That is a startup cost, not a correctness problem, and it is stated here
|
|
58
|
+
* rather than claimed away — an earlier version of this note said React stayed
|
|
59
|
+
* out of the config load, which was never true.
|
|
46
60
|
*/
|
|
61
|
+
/**
|
|
62
|
+
* Pages rendered at once by {@link DocsRoute.renderAll}.
|
|
63
|
+
*
|
|
64
|
+
* High enough that the pipeline — CPU-bound and effectively synchronous — never
|
|
65
|
+
* idles, low enough that an async `imageResolver` cannot put an entire site's
|
|
66
|
+
* worth of trees and network calls in flight simultaneously.
|
|
67
|
+
*/
|
|
68
|
+
const RENDER_CONCURRENCY = 16;
|
|
47
69
|
function isRecord(value) {
|
|
48
70
|
return typeof value === "object" && value !== null;
|
|
49
71
|
}
|
|
50
72
|
/**
|
|
73
|
+
* A React component type: a function, or an object tagged with `$$typeof`
|
|
74
|
+
* (`forwardRef`, `memo`, lazy…). Anything else that reaches `createElement`
|
|
75
|
+
* throws four frames inside React.
|
|
76
|
+
*/
|
|
77
|
+
function isComponentLike(value) {
|
|
78
|
+
return typeof value === "function" || isRecord(value) && "$$typeof" in value;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
51
81
|
* Pull the default export out of a lazily-imported module.
|
|
52
82
|
*
|
|
53
83
|
* This is the one place a cast is unavoidable — the import crosses a boundary
|
|
@@ -56,8 +86,9 @@ function isRecord(value) {
|
|
|
56
86
|
* than as `undefined is not a function` four frames inside React.
|
|
57
87
|
*/
|
|
58
88
|
function readDefaultExport(mod, specifier) {
|
|
59
|
-
|
|
60
|
-
if (
|
|
89
|
+
let value = isRecord(mod) && "default" in mod ? mod.default : mod;
|
|
90
|
+
if (!isComponentLike(value) && isRecord(value) && "default" in value) value = value.default;
|
|
91
|
+
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
92
|
return value;
|
|
62
93
|
}
|
|
63
94
|
/**
|
|
@@ -72,33 +103,16 @@ async function importNext(load, specifier) {
|
|
|
72
103
|
try {
|
|
73
104
|
return await load();
|
|
74
105
|
} catch (error) {
|
|
75
|
-
throw
|
|
106
|
+
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
107
|
}
|
|
77
108
|
}
|
|
78
109
|
async function loadNotFound() {
|
|
79
110
|
const mod = await importNext(() => import("next/navigation"), "next/navigation");
|
|
80
111
|
const value = isRecord(mod) ? mod.notFound : void 0;
|
|
81
|
-
if (typeof value !== "function") throw
|
|
112
|
+
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
113
|
return value;
|
|
83
114
|
}
|
|
84
115
|
/**
|
|
85
|
-
* Adapt `next/link` to {@link DocsLinkProps}.
|
|
86
|
-
*
|
|
87
|
-
* `next/link` widens `href` to `string | UrlObject` and `prefetch` to
|
|
88
|
-
* `boolean | null`; the React layer promises neither, because it must also run
|
|
89
|
-
* with a plain `<a>`. One wrapper keeps that mismatch in a single
|
|
90
|
-
* place instead of at every call site.
|
|
91
|
-
*/
|
|
92
|
-
function wrapNextLink(NextLink) {
|
|
93
|
-
return function DocsNextLink({ href, prefetch, children, ...rest }) {
|
|
94
|
-
return createElement(NextLink, {
|
|
95
|
-
...rest,
|
|
96
|
-
href,
|
|
97
|
-
...prefetch === void 0 ? {} : { prefetch }
|
|
98
|
-
}, children);
|
|
99
|
-
};
|
|
100
|
-
}
|
|
101
|
-
/**
|
|
102
116
|
* Adapt `next/image` to {@link DocsImageProps}.
|
|
103
117
|
*
|
|
104
118
|
* Every prop is named rather than spread. `next/image` types `width`/`height`
|
|
@@ -155,13 +169,26 @@ function createDocsRoute(options) {
|
|
|
155
169
|
const config = resolveDocsConfig(options);
|
|
156
170
|
const source = createDocsSource(options);
|
|
157
171
|
const siteUrl = normalizeSiteUrl(options.siteUrl);
|
|
158
|
-
const
|
|
159
|
-
const rescanPerRequest = options.rescanPerRequest ?? process.env.NODE_ENV !== "production";
|
|
172
|
+
const rescanPerRequest = process.env.NODE_ENV !== "production";
|
|
160
173
|
let renderer = null;
|
|
161
174
|
const knownRoutes = /* @__PURE__ */ new Set();
|
|
175
|
+
const draftRoutes = /* @__PURE__ */ new Set();
|
|
176
|
+
const aliasRoutes = /* @__PURE__ */ new Map();
|
|
162
177
|
let routesLoaded = null;
|
|
163
178
|
/**
|
|
164
|
-
* Drop the cached scan so the next query reads the disk again
|
|
179
|
+
* Drop the cached scan so the next query reads the disk again — at most once
|
|
180
|
+
* per request.
|
|
181
|
+
*
|
|
182
|
+
* `React.cache` is doing real work here, not memoising for speed. Next runs
|
|
183
|
+
* `generateMetadata` and `Page` concurrently, and a layout calling
|
|
184
|
+
* `source.nav()` is a third caller; each used to invalidate independently,
|
|
185
|
+
* so each discarded the others' in-flight scan. Measured on a 401-file tree:
|
|
186
|
+
* 22 readdir + 824 readFile per request, against 11 + 412 for a single scan,
|
|
187
|
+
* at 39 ms — which is also why the old docstring's "single-digit
|
|
188
|
+
* milliseconds" was wrong. Inside a request the first caller invalidates and
|
|
189
|
+
* the rest see the memo; outside one (a sitemap built from `next.config.ts`,
|
|
190
|
+
* a script) `cache` does not memoise at all, so those callers keep the old
|
|
191
|
+
* invalidate-every-time behaviour, which is what they want.
|
|
165
192
|
*
|
|
166
193
|
* `knownRoutes` is added to, never cleared: it is shared with the renderer,
|
|
167
194
|
* and emptying it while a concurrent render is asserting links would fail
|
|
@@ -169,26 +196,76 @@ function createDocsRoute(options) {
|
|
|
169
196
|
* deleted page only makes dev *more* permissive than the production build,
|
|
170
197
|
* which is the right direction to be wrong in.
|
|
171
198
|
*/
|
|
172
|
-
const invalidate = () => {
|
|
199
|
+
const invalidate = cache(() => {
|
|
173
200
|
source.invalidate();
|
|
174
201
|
routesLoaded = null;
|
|
175
|
-
};
|
|
176
|
-
|
|
202
|
+
});
|
|
203
|
+
/**
|
|
204
|
+
* Record each page's own route in `into`, and each of its aliases in
|
|
205
|
+
* `aliasRoutes`.
|
|
206
|
+
*
|
|
207
|
+
* The two are kept apart on purpose. An alias used to be added to
|
|
208
|
+
* `knownRoutes` on the reasoning that a permanent redirect resolves — but it
|
|
209
|
+
* only resolves once `createDocsRedirects` is wired into `next.config.ts`,
|
|
210
|
+
* which the quick start never does, and `generateStaticParams` does not emit
|
|
211
|
+
* it either. So a page linking a sibling's alias built green and 404'd for
|
|
212
|
+
* every reader, with `dynamicParams = false` making it a hard 404. The
|
|
213
|
+
* renderer now names the alias's target instead, which is better advice than
|
|
214
|
+
* the acceptance ever was: the author gets told where the page actually is.
|
|
215
|
+
*/
|
|
216
|
+
const collectRoutes = (into, files) => {
|
|
177
217
|
for (const file of files) {
|
|
178
|
-
|
|
179
|
-
for (const alias of file.frontmatter.aliases ?? [])
|
|
218
|
+
into.add(file.href);
|
|
219
|
+
for (const alias of file.frontmatter.aliases ?? []) aliasRoutes.set(toAliasRoute(alias, config.basePath, file.relativePath), file.href);
|
|
180
220
|
}
|
|
221
|
+
};
|
|
222
|
+
const loadRoutes = () => routesLoaded ??= Promise.all([source.all(), source.drafts()]).then(([published, drafts]) => {
|
|
223
|
+
collectRoutes(knownRoutes, published);
|
|
224
|
+
collectRoutes(draftRoutes, drafts);
|
|
181
225
|
});
|
|
182
226
|
const loadRenderer = () => renderer ??= createDocsRenderer({
|
|
183
227
|
config,
|
|
184
228
|
knownRoutes,
|
|
229
|
+
draftRoutes,
|
|
230
|
+
aliasRoutes,
|
|
185
231
|
...options.highlighter === void 0 ? {} : { highlighter: options.highlighter },
|
|
186
232
|
...options.langs === void 0 ? {} : { langs: options.langs },
|
|
187
233
|
...options.themes === void 0 ? {} : { themes: options.themes },
|
|
234
|
+
...options.excludeLangs === void 0 ? {} : { excludeLangs: options.excludeLangs },
|
|
188
235
|
...options.titleHeading === void 0 ? {} : { titleHeading: options.titleHeading },
|
|
236
|
+
...options.remarkPlugins === void 0 ? {} : { remarkPlugins: options.remarkPlugins },
|
|
237
|
+
...options.rehypePlugins === void 0 ? {} : { rehypePlugins: options.rehypePlugins },
|
|
189
238
|
...options.linkResolver === void 0 ? {} : { linkResolver: options.linkResolver },
|
|
190
239
|
...options.imageResolver === void 0 ? {} : { imageResolver: options.imageResolver }
|
|
191
240
|
});
|
|
241
|
+
/** Re-read the disk on the route's schedule before delegating. */
|
|
242
|
+
const rescanned = (read) => {
|
|
243
|
+
return (...args) => {
|
|
244
|
+
if (rescanPerRequest) invalidate();
|
|
245
|
+
return read(...args);
|
|
246
|
+
};
|
|
247
|
+
};
|
|
248
|
+
/**
|
|
249
|
+
* The source handed to layouts.
|
|
250
|
+
*
|
|
251
|
+
* `docs.source.nav()` is the documented way to feed `DocsSidebar`, and it was
|
|
252
|
+
* the one reader that never invalidated — so in dev, the request after adding
|
|
253
|
+
* or renaming a page rendered the *new* body beside the *old* sidebar, and
|
|
254
|
+
* only the request after that agreed with itself. Everything else on the
|
|
255
|
+
* route already rescanned; this closes the last hole.
|
|
256
|
+
*/
|
|
257
|
+
const requestScopedSource = {
|
|
258
|
+
config: source.config,
|
|
259
|
+
all: rescanned(() => source.all()),
|
|
260
|
+
drafts: rescanned(() => source.drafts()),
|
|
261
|
+
find: rescanned((segments) => source.find(segments)),
|
|
262
|
+
nav: rescanned(() => source.nav()),
|
|
263
|
+
slugs: rescanned(() => source.slugs()),
|
|
264
|
+
invalidate: () => {
|
|
265
|
+
source.invalidate();
|
|
266
|
+
routesLoaded = null;
|
|
267
|
+
}
|
|
268
|
+
};
|
|
192
269
|
/**
|
|
193
270
|
* `find` returns drafts regardless of config so a preview route can opt in;
|
|
194
271
|
* a public route must not.
|
|
@@ -211,30 +288,58 @@ function createDocsRoute(options) {
|
|
|
211
288
|
const files = await source.all();
|
|
212
289
|
await loadRoutes();
|
|
213
290
|
const renderer = loadRenderer();
|
|
214
|
-
return
|
|
291
|
+
return mapPooled(files, RENDER_CONCURRENCY, (file) => renderer.render(file));
|
|
292
|
+
};
|
|
293
|
+
const searchIndexUrl = `${config.basePath}/search-index.json`;
|
|
294
|
+
/**
|
|
295
|
+
* Fail loudly when the search-index route was not frozen at build time.
|
|
296
|
+
*
|
|
297
|
+
* The signal is `NEXT_PHASE`, which Next sets to `phase-production-build`
|
|
298
|
+
* while prerendering — verified in both output modes, and `undefined` under
|
|
299
|
+
* `next start`. It is internal and undocumented, which is why the CI smoke
|
|
300
|
+
* build asserts the prerendered body exists: if this ever changes meaning it
|
|
301
|
+
* fails there, in this repository, rather than in a consumer's deploy.
|
|
302
|
+
*
|
|
303
|
+
* The alternative was a `console.error`, and it is not one. A warning in a
|
|
304
|
+
* serverless log is unread, and the observable symptom — a search dialog
|
|
305
|
+
* stuck on "could not load the index" — arrives days later with nothing
|
|
306
|
+
* connecting it to a missing line in a route file.
|
|
307
|
+
*/
|
|
308
|
+
const assertPrerendered = () => {
|
|
309
|
+
if (process.env.NODE_ENV === "production" && process.env.NEXT_PHASE !== "phase-production-build") throw docsError("search-index-dynamic", `the search index was requested at runtime instead of being built into your deployment. Add \`export const dynamic = 'force-static'\` to app${searchIndexUrl}/route.ts — it has to be a literal, like \`dynamicParams\`. Without it Next re-renders every page of your corpus per request, from markdown that is not in the deployment bundle. (Building the index somewhere else on purpose? Use \`docs.renderAll()\` with \`extractSearchRecords\` and \`buildSearchIndex\` from \`@waveso/docs/search-index\` instead of calling this handler.)`);
|
|
310
|
+
};
|
|
311
|
+
const searchIndex = async () => {
|
|
312
|
+
assertPrerendered();
|
|
313
|
+
const { buildSearchIndex, extractSearchRecords } = await import("./search-index.js");
|
|
314
|
+
const json = buildSearchIndex((await renderAll()).flatMap((doc) => extractSearchRecords(doc)), options.miniSearchOptions ?? {});
|
|
315
|
+
return new Response(json, { headers: {
|
|
316
|
+
"content-type": "application/json",
|
|
317
|
+
"cache-control": "public, max-age=0, must-revalidate",
|
|
318
|
+
etag: `"${createHash("sha1").update(json).digest("hex")}"`
|
|
319
|
+
} });
|
|
215
320
|
};
|
|
216
321
|
async function renderRoute(segments) {
|
|
217
322
|
const doc = await getPage(segments);
|
|
218
323
|
if (doc === void 0) return (await loadNotFound())();
|
|
219
324
|
const components = await loadNextComponents();
|
|
220
|
-
return createElement("
|
|
221
|
-
className: "wave-docs-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
tabIndex: -1
|
|
225
|
-
}
|
|
325
|
+
return createElement(Fragment, null, createElement("main", {
|
|
326
|
+
className: "wave-docs-layout__main",
|
|
327
|
+
id: DOCS_CONTENT_ID,
|
|
328
|
+
tabIndex: -1
|
|
226
329
|
}, createElement(DocContent, {
|
|
227
330
|
hast: doc.hast,
|
|
228
331
|
components: {
|
|
229
332
|
...components,
|
|
230
333
|
...options.components
|
|
231
334
|
}
|
|
232
|
-
}));
|
|
335
|
+
})), doc.toc.length === 0 ? null : createElement("aside", { className: "wave-docs-layout__toc" }, createElement(DocsToc, { entries: doc.toc })));
|
|
233
336
|
}
|
|
234
337
|
return {
|
|
235
|
-
source,
|
|
338
|
+
source: requestScopedSource,
|
|
236
339
|
getPage,
|
|
237
340
|
renderAll,
|
|
341
|
+
searchIndex,
|
|
342
|
+
searchIndexUrl,
|
|
238
343
|
dynamicParams: false,
|
|
239
344
|
async Page({ params }) {
|
|
240
345
|
const { slug } = await params;
|
|
@@ -243,6 +348,22 @@ function createDocsRoute(options) {
|
|
|
243
348
|
async IndexPage() {
|
|
244
349
|
return renderRoute([]);
|
|
245
350
|
},
|
|
351
|
+
async Layout({ children, title, actions, search, labels }) {
|
|
352
|
+
const { DocsLayoutShell } = await import("./react/layout.js");
|
|
353
|
+
const searchProps = search === false ? false : {
|
|
354
|
+
...options.miniSearchOptions === void 0 ? {} : { miniSearchOptions: options.miniSearchOptions },
|
|
355
|
+
...search === true || search === void 0 ? {} : search
|
|
356
|
+
};
|
|
357
|
+
return createElement(DocsLayoutShell, {
|
|
358
|
+
children,
|
|
359
|
+
nav: await requestScopedSource.nav(),
|
|
360
|
+
searchIndexUrl,
|
|
361
|
+
search: searchProps,
|
|
362
|
+
...title === void 0 ? {} : { title },
|
|
363
|
+
...actions === void 0 ? {} : { actions },
|
|
364
|
+
...labels === void 0 ? {} : { labels }
|
|
365
|
+
});
|
|
366
|
+
},
|
|
246
367
|
async generateStaticParams() {
|
|
247
368
|
if (rescanPerRequest) invalidate();
|
|
248
369
|
return (await source.slugs()).filter((segments) => segments.length > 0).map((segments) => ({ slug: segments }));
|
|
@@ -287,7 +408,11 @@ function createDocsRoute(options) {
|
|
|
287
408
|
*/
|
|
288
409
|
async function createDocsSitemap(options) {
|
|
289
410
|
const siteUrl = requireSiteUrl(options.siteUrl);
|
|
290
|
-
const
|
|
411
|
+
const source = createDocsSource(options);
|
|
412
|
+
if (process.env.NODE_ENV !== "production") source.invalidate();
|
|
413
|
+
const files = await source.all();
|
|
414
|
+
const oversized = sitemapLimitWarning(files.length);
|
|
415
|
+
if (oversized !== void 0) console.warn(oversized);
|
|
291
416
|
const readDate = options.lastModified ?? readMtime;
|
|
292
417
|
return Promise.all(files.map(async (file) => {
|
|
293
418
|
const lastModified = await readDate(file);
|
|
@@ -338,9 +463,9 @@ async function createDocsRedirects(config) {
|
|
|
338
463
|
for (const file of files) for (const alias of file.frontmatter.aliases ?? []) {
|
|
339
464
|
const route = toAliasRoute(alias, resolved.basePath, file.relativePath);
|
|
340
465
|
const page = routes.get(route);
|
|
341
|
-
if (page !== void 0) throw
|
|
466
|
+
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
467
|
const other = claimed.get(route);
|
|
343
|
-
if (other !== void 0) throw
|
|
468
|
+
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
469
|
claimed.set(route, file);
|
|
345
470
|
redirects.push({
|
|
346
471
|
source: route,
|
|
@@ -355,11 +480,14 @@ function normalizeSiteUrl(siteUrl) {
|
|
|
355
480
|
}
|
|
356
481
|
/** Fail at config time, not with a malformed `<link rel="canonical">`. */
|
|
357
482
|
function requireSiteUrl(siteUrl) {
|
|
483
|
+
let parsed;
|
|
358
484
|
try {
|
|
359
|
-
|
|
485
|
+
parsed = new URL(siteUrl);
|
|
360
486
|
} catch {
|
|
361
|
-
throw
|
|
487
|
+
throw docsError("invalid-config", `@waveso/docs: '${siteUrl}' is not an absolute URL. Pass an origin such as 'https://example.com'.`);
|
|
362
488
|
}
|
|
489
|
+
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.`);
|
|
490
|
+
return parsed.toString();
|
|
363
491
|
}
|
|
364
492
|
//#endregion
|
|
365
493
|
export { createDocsRedirects, createDocsRoute, createDocsSitemap };
|
|
@@ -1,14 +1,35 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { isFootnotes, isTransparentContainer } from "../section-boundary.js";
|
|
2
2
|
import { toString } from "hast-util-to-string";
|
|
3
3
|
//#region src/plugins/rehype-capture-toc.ts
|
|
4
|
-
/**
|
|
5
|
-
|
|
4
|
+
/**
|
|
5
|
+
* `h2` and `h3`. `h1` is the page title, and h4–h6 are too deep to navigate.
|
|
6
|
+
*
|
|
7
|
+
* Measured on a synthetic API page — 8 methods, 3 overloads each, 3
|
|
8
|
+
* subsections apiece — capturing h2–h6 gave **104 entries and 6,797 bytes** of
|
|
9
|
+
* flight payload against **32 entries and 2,321 bytes** capped at h3. A rail
|
|
10
|
+
* with a hundred entries is not a table of contents; it is the page again, in
|
|
11
|
+
* a narrower column.
|
|
12
|
+
*
|
|
13
|
+
* Stripe, Linear, Mintlify, Fumadocs and Docusaurus all cap at h2+h3 —
|
|
14
|
+
* Docusaurus's defaults are literally 2 and 3.
|
|
15
|
+
*
|
|
16
|
+
* Nothing becomes unreachable by cutting here. `rehype-slug` and
|
|
17
|
+
* `rehype-autolink-headings` still give every h4–h6 an id and a permalink, so
|
|
18
|
+
* they are still linkable and still land in the search index, which opens
|
|
19
|
+
* sections on its own walk. There is deliberately no `maxDepth` option: the
|
|
20
|
+
* escape hatch is `rehypePlugins`, where a plugin writing its own
|
|
21
|
+
* `file.data.toc` is about forty lines.
|
|
22
|
+
*/
|
|
23
|
+
const HEADING = /^h([23])$/;
|
|
6
24
|
/**
|
|
7
25
|
* Drop the permalink anchor `rehype-autolink-headings` appends.
|
|
8
26
|
*
|
|
9
|
-
* This
|
|
10
|
-
*
|
|
11
|
-
*
|
|
27
|
+
* ⚠️ LOAD-BEARING NOW. This used to run *before* autolinking, so there was
|
|
28
|
+
* nothing to drop and this was insurance against an order that might change.
|
|
29
|
+
* The order changed: the capture is dead last, after Shiki and after the
|
|
30
|
+
* consumer's own plugins, so every heading really does carry an appended
|
|
31
|
+
* anchor by the time this walks it. Delete this and every TOC entry gains a
|
|
32
|
+
* trailing `#`.
|
|
12
33
|
*/
|
|
13
34
|
function isPermalink(child) {
|
|
14
35
|
if (child.type !== "element" || child.tagName !== "a") return false;
|
|
@@ -16,17 +37,6 @@ function isPermalink(child) {
|
|
|
16
37
|
const ariaHidden = child.properties.ariaHidden;
|
|
17
38
|
return ariaHidden === true || ariaHidden === "true" || Array.isArray(className) && className.includes("heading-anchor");
|
|
18
39
|
}
|
|
19
|
-
/**
|
|
20
|
-
* The GFM footnote block `mdast-util-to-hast` appends.
|
|
21
|
-
*
|
|
22
|
-
* It carries a generated `<h2 id="footnote-label">Footnotes</h2>` that is
|
|
23
|
-
* machinery rather than a section of the page — and is visually hidden, so a
|
|
24
|
-
* TOC entry for it points the reader at nothing they can see. `search-index.ts`
|
|
25
|
-
* skips the same subtree; the two must agree about which sections exist.
|
|
26
|
-
*/
|
|
27
|
-
function isFootnotes(node) {
|
|
28
|
-
return node.properties.dataFootnotes !== void 0;
|
|
29
|
-
}
|
|
30
40
|
function headingText(node) {
|
|
31
41
|
return node.children.filter((child) => !isPermalink(child)).map((child) => toString(child)).join("").trim();
|
|
32
42
|
}
|
|
@@ -40,8 +50,7 @@ const rehypeCaptureToc = () => {
|
|
|
40
50
|
const toc = [];
|
|
41
51
|
/** Open ancestors, outermost first. */
|
|
42
52
|
const stack = [];
|
|
43
|
-
|
|
44
|
-
if (isFootnotes(node)) return SKIP;
|
|
53
|
+
const capture = (node) => {
|
|
45
54
|
const level = HEADING.exec(node.tagName)?.[1];
|
|
46
55
|
if (level === void 0) return;
|
|
47
56
|
const id = node.properties.id;
|
|
@@ -61,7 +70,30 @@ const rehypeCaptureToc = () => {
|
|
|
61
70
|
if (parent === void 0) toc.push(entry);
|
|
62
71
|
else parent.children.push(entry);
|
|
63
72
|
stack.push(entry);
|
|
64
|
-
}
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
75
|
+
* Block-level nodes in document order, stepping into wrappers a section may
|
|
76
|
+
* legitimately sit inside — and no further.
|
|
77
|
+
*
|
|
78
|
+
* ⚠️ THE BOUND IS THE POINT, AND IT IS SHARED WITH THE SEARCH INDEX. A
|
|
79
|
+
* whole-tree walk gave a TOC entry to any heading with an id however deeply
|
|
80
|
+
* wrapped, while `extractSearchRecords` opens a section only inside an
|
|
81
|
+
* {@link isTransparentContainer} — so a `## ` written inside a list item or
|
|
82
|
+
* a table cell got a TOC entry with no searchable section behind it, and its
|
|
83
|
+
* prose was folded into the section above under the wrong breadcrumb. One
|
|
84
|
+
* exported predicate is what stops the two drifting apart again.
|
|
85
|
+
*/
|
|
86
|
+
const walk = (nodes) => {
|
|
87
|
+
for (const node of nodes) {
|
|
88
|
+
if (node.type !== "element" || isFootnotes(node)) continue;
|
|
89
|
+
if (isTransparentContainer(node)) {
|
|
90
|
+
walk(node.children);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
capture(node);
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
walk(tree.children);
|
|
65
97
|
file.data.toc = toc;
|
|
66
98
|
};
|
|
67
99
|
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Plugin } from "unified";
|
|
2
|
+
import { Root } from "hast";
|
|
3
|
+
//#region src/plugins/rehype-code-frame.d.ts
|
|
4
|
+
interface RehypeCodeFrameOptions {
|
|
5
|
+
/** Accessible name when a fence has no title. */
|
|
6
|
+
copyLabel?: string | undefined;
|
|
7
|
+
}
|
|
8
|
+
declare const rehypeCodeFrame: Plugin<[RehypeCodeFrameOptions?], Root>;
|
|
9
|
+
//#endregion
|
|
10
|
+
export { RehypeCodeFrameOptions, rehypeCodeFrame };
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { CODE_COPY_ATTRIBUTE, CODE_FRAME_ATTRIBUTE } from "../code-frame.js";
|
|
2
|
+
import { parseCodeMeta } from "../code-meta.js";
|
|
3
|
+
import { CONTINUE, SKIP, visit } from "unist-util-visit";
|
|
4
|
+
//#region src/plugins/rehype-code-frame.ts
|
|
5
|
+
/** `language-ts` on the `<code>`, already folded to lower case by step 12. */
|
|
6
|
+
const LANGUAGE_CLASS = /^language-(.+)$/;
|
|
7
|
+
const rehypeCodeFrame = (options = {}) => {
|
|
8
|
+
const copyLabel = options.copyLabel ?? "Copy code";
|
|
9
|
+
return (tree, file) => {
|
|
10
|
+
const path = file.data.docLinkContext?.relativePath ?? file.path ?? "a document";
|
|
11
|
+
visit(tree, "element", (node, index, parent) => {
|
|
12
|
+
if (node.tagName !== "pre") return CONTINUE;
|
|
13
|
+
if (parent === void 0 || index === void 0) return CONTINUE;
|
|
14
|
+
const code = node.children[0];
|
|
15
|
+
if (code === void 0 || code.type !== "element") return CONTINUE;
|
|
16
|
+
if (code.tagName !== "code") return CONTINUE;
|
|
17
|
+
const meta = readMeta(code);
|
|
18
|
+
const { title } = parseCodeMeta(meta, path);
|
|
19
|
+
const language = readLanguage(code);
|
|
20
|
+
const children = [];
|
|
21
|
+
if (title !== void 0) children.push({
|
|
22
|
+
type: "element",
|
|
23
|
+
tagName: "figcaption",
|
|
24
|
+
properties: { className: ["wave-docs-code__title"] },
|
|
25
|
+
children: [{
|
|
26
|
+
type: "text",
|
|
27
|
+
value: title
|
|
28
|
+
}]
|
|
29
|
+
});
|
|
30
|
+
children.push(copyButton(title === void 0 ? copyLabel : `${copyLabel} from ${title}`), node);
|
|
31
|
+
parent.children[index] = {
|
|
32
|
+
type: "element",
|
|
33
|
+
tagName: "figure",
|
|
34
|
+
properties: {
|
|
35
|
+
className: ["wave-docs-code"],
|
|
36
|
+
[CODE_FRAME_ATTRIBUTE]: "",
|
|
37
|
+
...language === void 0 ? {} : { "data-lang": language }
|
|
38
|
+
},
|
|
39
|
+
children
|
|
40
|
+
};
|
|
41
|
+
return SKIP;
|
|
42
|
+
});
|
|
43
|
+
};
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* A real `<button type="button">`, so Enter and Space work with no key
|
|
47
|
+
* handling of ours and the control is announced as a button.
|
|
48
|
+
*
|
|
49
|
+
* It sits before the `<pre>`, which is what makes the tab order read "copy
|
|
50
|
+
* this block" → "the scrollable code region" rather than the reverse.
|
|
51
|
+
*/
|
|
52
|
+
function copyButton(label) {
|
|
53
|
+
return {
|
|
54
|
+
type: "element",
|
|
55
|
+
tagName: "button",
|
|
56
|
+
properties: {
|
|
57
|
+
type: "button",
|
|
58
|
+
className: ["wave-docs-code__copy"],
|
|
59
|
+
[CODE_COPY_ATTRIBUTE]: "",
|
|
60
|
+
"aria-label": label
|
|
61
|
+
},
|
|
62
|
+
children: [{
|
|
63
|
+
type: "element",
|
|
64
|
+
tagName: "span",
|
|
65
|
+
properties: { "aria-hidden": "true" },
|
|
66
|
+
children: [{
|
|
67
|
+
type: "text",
|
|
68
|
+
value: "⧉"
|
|
69
|
+
}]
|
|
70
|
+
}]
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
/** `code.data.meta` — read, never written. */
|
|
74
|
+
function readMeta(code) {
|
|
75
|
+
const meta = code.data?.meta;
|
|
76
|
+
return typeof meta === "string" ? meta : void 0;
|
|
77
|
+
}
|
|
78
|
+
function readLanguage(code) {
|
|
79
|
+
const classNames = code.properties.className;
|
|
80
|
+
if (!Array.isArray(classNames)) return void 0;
|
|
81
|
+
for (const name of classNames) {
|
|
82
|
+
if (typeof name !== "string") continue;
|
|
83
|
+
const found = LANGUAGE_CLASS.exec(name)?.[1];
|
|
84
|
+
if (found !== void 0) return found;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
//#endregion
|
|
88
|
+
export { rehypeCodeFrame };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Plugin } from "unified";
|
|
2
|
+
import { Root } from "hast";
|
|
3
|
+
//#region src/plugins/rehype-code-language.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* The tag an excluded `<pre>` wears while Shiki walks the tree.
|
|
6
|
+
*
|
|
7
|
+
* Exported so a test can prove it never reaches the output: a leaked sentinel
|
|
8
|
+
* is an unstyled block with an invented tag name, which React renders happily
|
|
9
|
+
* and nobody notices.
|
|
10
|
+
*/
|
|
11
|
+
declare const EXCLUDED_PRE_TAG = "wave-docs-excluded-pre";
|
|
12
|
+
interface RehypeCodeLanguageOptions {
|
|
13
|
+
/** Lower-cased fence languages Shiki must leave alone, e.g. `['mermaid']`. */
|
|
14
|
+
exclude?: readonly string[];
|
|
15
|
+
}
|
|
16
|
+
declare const rehypeNormalizeCodeLanguage: Plugin<[RehypeCodeLanguageOptions?], Root>;
|
|
17
|
+
/**
|
|
18
|
+
* Undo the disguise. Must run after `rehypeShikiFromHighlighter`, and it is a
|
|
19
|
+
* separate plugin rather than a flag on the first one because unified gives a
|
|
20
|
+
* plugin one position in the pipeline and this needs the other side of Shiki.
|
|
21
|
+
*/
|
|
22
|
+
declare const rehypeRestoreExcludedCode: Plugin<[], Root>;
|
|
23
|
+
//#endregion
|
|
24
|
+
export { EXCLUDED_PRE_TAG, RehypeCodeLanguageOptions, rehypeNormalizeCodeLanguage, rehypeRestoreExcludedCode };
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { CONTINUE, SKIP, visit } from "unist-util-visit";
|
|
2
|
+
//#region src/plugins/rehype-code-language.ts
|
|
3
|
+
/** `language-ts` on a `<code>` — how a fence's language survives to hast. */
|
|
4
|
+
const LANGUAGE_CLASS = /^language-(.+)$/;
|
|
5
|
+
/**
|
|
6
|
+
* The tag an excluded `<pre>` wears while Shiki walks the tree.
|
|
7
|
+
*
|
|
8
|
+
* Exported so a test can prove it never reaches the output: a leaked sentinel
|
|
9
|
+
* is an unstyled block with an invented tag name, which React renders happily
|
|
10
|
+
* and nobody notices.
|
|
11
|
+
*/
|
|
12
|
+
const EXCLUDED_PRE_TAG = "wave-docs-excluded-pre";
|
|
13
|
+
const rehypeNormalizeCodeLanguage = (options = {}) => {
|
|
14
|
+
const excluded = new Set((options.exclude ?? []).map((lang) => lang.toLowerCase()));
|
|
15
|
+
return (tree) => {
|
|
16
|
+
visit(tree, "element", (node) => {
|
|
17
|
+
if (node.tagName !== "pre") return CONTINUE;
|
|
18
|
+
const code = node.children[0];
|
|
19
|
+
if (code === void 0 || code.type !== "element") return CONTINUE;
|
|
20
|
+
const classNames = code.properties.className;
|
|
21
|
+
if (!Array.isArray(classNames)) return SKIP;
|
|
22
|
+
let language;
|
|
23
|
+
code.properties.className = classNames.map((name) => {
|
|
24
|
+
if (typeof name !== "string") return name;
|
|
25
|
+
const found = LANGUAGE_CLASS.exec(name)?.[1];
|
|
26
|
+
if (found === void 0) return name;
|
|
27
|
+
language = found.toLowerCase();
|
|
28
|
+
return `language-${language}`;
|
|
29
|
+
});
|
|
30
|
+
if (language !== void 0 && excluded.has(language)) node.tagName = EXCLUDED_PRE_TAG;
|
|
31
|
+
return SKIP;
|
|
32
|
+
});
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Undo the disguise. Must run after `rehypeShikiFromHighlighter`, and it is a
|
|
37
|
+
* separate plugin rather than a flag on the first one because unified gives a
|
|
38
|
+
* plugin one position in the pipeline and this needs the other side of Shiki.
|
|
39
|
+
*/
|
|
40
|
+
const rehypeRestoreExcludedCode = () => {
|
|
41
|
+
return (tree) => {
|
|
42
|
+
visit(tree, "element", (node) => {
|
|
43
|
+
if (node.tagName === "wave-docs-excluded-pre") {
|
|
44
|
+
node.tagName = "pre";
|
|
45
|
+
node.properties = {
|
|
46
|
+
...node.properties,
|
|
47
|
+
tabIndex: 0
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
};
|
|
52
|
+
};
|
|
53
|
+
//#endregion
|
|
54
|
+
export { EXCLUDED_PRE_TAG, rehypeNormalizeCodeLanguage, rehypeRestoreExcludedCode };
|