@waveso/docs 0.4.0 → 0.6.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 +270 -0
- package/README.md +221 -79
- package/dist/anchors.d.ts +44 -0
- package/dist/anchors.js +76 -0
- package/dist/errors.d.ts +4 -0
- package/dist/highlighter.js +2 -1
- package/dist/link-suggestion.d.ts +31 -0
- package/dist/link-suggestion.js +94 -0
- package/dist/meta.js +6 -9
- package/dist/next.d.ts +54 -14
- package/dist/next.js +135 -20
- package/dist/plugins/rehype-code-frame.d.ts +13 -1
- package/dist/plugins/rehype-code-frame.js +2 -1
- package/dist/plugins/rehype-fallback-heading-ids.js +1 -1
- package/dist/plugins/remark-doc-links.d.ts +83 -1
- package/dist/plugins/remark-doc-links.js +50 -23
- package/dist/plugins/remark-youtube.d.ts +18 -3
- package/dist/plugins/remark-youtube.js +57 -9
- package/dist/react/callout.d.ts +13 -1
- package/dist/react/callout.js +2 -2
- package/dist/react/code-runtime.d.ts +12 -2
- package/dist/react/code-runtime.js +28 -4
- package/dist/react/doc-content.d.ts +12 -1
- package/dist/react/doc-content.js +2 -2
- package/dist/react/layout.d.ts +27 -10
- package/dist/react/layout.js +6 -3
- package/dist/react/link-adapter.d.ts +34 -0
- package/dist/react/link-adapter.js +30 -0
- package/dist/react/markdown-components.d.ts +29 -1
- package/dist/react/markdown-components.js +69 -67
- package/dist/react/nav.d.ts +5 -1
- package/dist/react/nav.js +5 -2
- package/dist/react/next-link.d.ts +6 -28
- package/dist/react/next-link.js +45 -24
- package/dist/react/next-nav.d.ts +5 -1
- package/dist/react/next-nav.js +6 -3
- package/dist/react/next-search.js +1 -1
- package/dist/react/search-dialog.d.ts +59 -3
- package/dist/react/search-dialog.js +53 -9
- package/dist/react/shell-labels.d.ts +135 -21
- package/dist/react/shell-labels.js +47 -6
- package/dist/react/sidebar.d.ts +18 -1
- package/dist/react/sidebar.js +59 -23
- package/dist/react/youtube.d.ts +22 -1
- package/dist/react/youtube.js +22 -4
- package/dist/render.d.ts +12 -1
- package/dist/render.js +107 -21
- package/dist/route-path.js +7 -2
- package/dist/safe-href.d.ts +47 -0
- package/dist/safe-href.js +73 -0
- package/dist/search-index.js +1 -1
- package/dist/search-options.d.ts +64 -2
- package/dist/search-options.js +25 -1
- package/dist/semaphore.d.ts +46 -0
- package/dist/semaphore.js +60 -0
- package/dist/source.js +86 -12
- package/dist/types.d.ts +102 -6
- package/package.json +6 -3
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
//#region src/link-suggestion.ts
|
|
2
|
+
/**
|
|
3
|
+
* "Did you mean …?" for a link that matched no page.
|
|
4
|
+
*
|
|
5
|
+
* Private — deliberately not an entry point.
|
|
6
|
+
*
|
|
7
|
+
* A broken link is almost always a typo, and a typo is a near-miss by
|
|
8
|
+
* construction: `/instalation` is one edit from `/installation`, while `/login`
|
|
9
|
+
* is six from anything in a docs tree. That gap is what makes a suggestion
|
|
10
|
+
* safe to offer and safe to withhold — the same reason `git`, `tsc`, `cargo`
|
|
11
|
+
* and Python 3.12 all do it, and the reason none of them offers one for a word
|
|
12
|
+
* that is nowhere near a real name.
|
|
13
|
+
*
|
|
14
|
+
* ⚠️ A SUGGESTION ONLY, NEVER A DECISION. Nothing here decides whether a link
|
|
15
|
+
* is an error; `render.ts` has already decided that by the time it asks. Using
|
|
16
|
+
* an edit distance to pick between failing and staying silent would be a
|
|
17
|
+
* heuristic holding a build hostage, which is not a thing to do to somebody
|
|
18
|
+
* whose page happens to be called `/setting`.
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
21
|
+
* The ceiling on how far apart two routes may be and still be called a typo.
|
|
22
|
+
*
|
|
23
|
+
* Three is roughly one slip per word in a two-word slug — `instalation`,
|
|
24
|
+
* `gettting-started`, `plugns`. It is a *ceiling*, not the whole rule: the
|
|
25
|
+
* budget is also scaled by the length of what was written, so short routes are
|
|
26
|
+
* held tighter and `/api` is not offered as a fix for `/ui`.
|
|
27
|
+
*
|
|
28
|
+
* Between them, `/docs/instructions` gets no suggestion for
|
|
29
|
+
* `/docs/installation` — five edits apart, similar enough to tempt a generous
|
|
30
|
+
* threshold, and a different word. `render.test.ts` pins that case, because
|
|
31
|
+
* without it this constant could be any number at all.
|
|
32
|
+
*/
|
|
33
|
+
const MAX_DISTANCE = 3;
|
|
34
|
+
/**
|
|
35
|
+
* Levenshtein distance, bounded.
|
|
36
|
+
*
|
|
37
|
+
* Two rows rather than a full matrix: the corpus is every route on the site and
|
|
38
|
+
* this runs per broken link, so the allocation is the only part worth caring
|
|
39
|
+
* about. Returns early once every cell in a row exceeds `limit`, which is the
|
|
40
|
+
* common case — most candidates are nowhere near.
|
|
41
|
+
*/
|
|
42
|
+
function distance(a, b, limit) {
|
|
43
|
+
if (a === b) return 0;
|
|
44
|
+
if (Math.abs(a.length - b.length) > limit) return limit + 1;
|
|
45
|
+
let previous = Array.from({ length: b.length + 1 }, (_, index) => index);
|
|
46
|
+
let current = new Array(b.length + 1);
|
|
47
|
+
for (let i = 1; i <= a.length; i += 1) {
|
|
48
|
+
current[0] = i;
|
|
49
|
+
let best = i;
|
|
50
|
+
for (let j = 1; j <= b.length; j += 1) {
|
|
51
|
+
const substitution = previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1);
|
|
52
|
+
const deletion = previous[j] + 1;
|
|
53
|
+
const insertion = current[j - 1] + 1;
|
|
54
|
+
const cell = Math.min(substitution, deletion, insertion);
|
|
55
|
+
current[j] = cell;
|
|
56
|
+
if (cell < best) best = cell;
|
|
57
|
+
}
|
|
58
|
+
if (best > limit) return limit + 1;
|
|
59
|
+
const swap = previous;
|
|
60
|
+
previous = current;
|
|
61
|
+
current = swap;
|
|
62
|
+
}
|
|
63
|
+
return previous[b.length];
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* The closest route to `target`, or `undefined` if nothing is close enough.
|
|
67
|
+
*
|
|
68
|
+
* Ties break on the shortest candidate and then alphabetically, so the message
|
|
69
|
+
* is the same on every machine — a suggestion that changes between runs reads
|
|
70
|
+
* as a flaky build.
|
|
71
|
+
*/
|
|
72
|
+
function suggestRoute(target, routes) {
|
|
73
|
+
const limit = Math.min(MAX_DISTANCE, Math.max(1, Math.floor(target.length / 3)));
|
|
74
|
+
let best;
|
|
75
|
+
let bestDistance = limit + 1;
|
|
76
|
+
for (const route of routes) {
|
|
77
|
+
if (route === target) continue;
|
|
78
|
+
const measured = distance(target, route, limit);
|
|
79
|
+
if (measured > limit) continue;
|
|
80
|
+
if (measured < bestDistance || measured === bestDistance && best !== void 0 && (route.length < best.length || route.length === best.length && route < best)) {
|
|
81
|
+
best = route;
|
|
82
|
+
bestDistance = measured;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return best;
|
|
86
|
+
}
|
|
87
|
+
/** ` Did you mean '/installation'?`, or `''` when nothing is close. */
|
|
88
|
+
function describeSuggestion(target, routes) {
|
|
89
|
+
if (routes === void 0) return "";
|
|
90
|
+
const suggestion = suggestRoute(target, routes);
|
|
91
|
+
return suggestion === void 0 ? "" : ` Did you mean '${suggestion}'?`;
|
|
92
|
+
}
|
|
93
|
+
//#endregion
|
|
94
|
+
export { describeSuggestion, suggestRoute };
|
package/dist/meta.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { docsError } from "./docs-error.js";
|
|
2
|
+
import { isSafeHref, opensInNewTab } from "./safe-href.js";
|
|
2
3
|
import { z } from "zod";
|
|
3
4
|
import { readFile } from "node:fs/promises";
|
|
4
5
|
import path from "node:path";
|
|
@@ -16,11 +17,6 @@ const SEPARATOR_PATTERN = /^---(.+)---$/;
|
|
|
16
17
|
/** The rest wildcard: everything not named explicitly, in place. */
|
|
17
18
|
const REST = "...";
|
|
18
19
|
/**
|
|
19
|
-
* `<scheme>:` or protocol-relative `//host` — i.e. a URL that leaves the site.
|
|
20
|
-
* Anything else (`/changelog`, `../pricing`) is internal.
|
|
21
|
-
*/
|
|
22
|
-
const ABSOLUTE_HREF_PATTERN = /^(?:[a-zA-Z][a-zA-Z\d+\-.]*:|\/\/)/;
|
|
23
|
-
/**
|
|
24
20
|
* Zod mirror of {@link DocsMeta}.
|
|
25
21
|
*
|
|
26
22
|
* Strict on purpose: `meta.json` is hand-written and unvalidated keys are
|
|
@@ -31,7 +27,7 @@ const docsMetaSchema = z.strictObject({
|
|
|
31
27
|
title: z.string().exactOptional(),
|
|
32
28
|
pages: z.array(z.union([z.string(), z.strictObject({
|
|
33
29
|
title: z.string(),
|
|
34
|
-
href: z.string()
|
|
30
|
+
href: z.string().refine(isSafeHref, { message: "that is not a scheme this package will put in a link. Use http(s), mailto, tel, sms, ftp, irc, xmpp, news, feed, git or matrix — or a path, which needs no scheme at all." })
|
|
35
31
|
})])).exactOptional()
|
|
36
32
|
});
|
|
37
33
|
/**
|
|
@@ -58,7 +54,7 @@ async function readDocsMeta(dirPath) {
|
|
|
58
54
|
}
|
|
59
55
|
let parsed;
|
|
60
56
|
try {
|
|
61
|
-
parsed = JSON.parse(raw);
|
|
57
|
+
parsed = JSON.parse(raw.charCodeAt(0) === 65279 ? raw.slice(1) : raw);
|
|
62
58
|
} catch (err) {
|
|
63
59
|
const reason = err instanceof Error ? err.message : String(err);
|
|
64
60
|
throw docsError("invalid-meta", `Could not parse ${filePath} as JSON: ${reason}`, { cause: err });
|
|
@@ -90,13 +86,14 @@ function orderNavEntries(entries, meta, metaPath, depth) {
|
|
|
90
86
|
const used = /* @__PURE__ */ new Set();
|
|
91
87
|
const nodes = [];
|
|
92
88
|
let restAt = -1;
|
|
93
|
-
for (const
|
|
89
|
+
for (const raw of pages) {
|
|
90
|
+
const page = typeof raw === "string" ? raw.normalize("NFC") : raw;
|
|
94
91
|
if (typeof page !== "string") {
|
|
95
92
|
nodes.push({
|
|
96
93
|
type: "link",
|
|
97
94
|
title: page.title,
|
|
98
95
|
href: page.href,
|
|
99
|
-
external:
|
|
96
|
+
external: opensInNewTab(page.href)
|
|
100
97
|
});
|
|
101
98
|
continue;
|
|
102
99
|
}
|
package/dist/next.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { DocFile, DocFrontmatter, DocsConfig, ImageResolver, LinkResolver, RenderedDoc, SearchRecord } from "./types.js";
|
|
2
2
|
import { DocsHighlighter, DocsLang, DocsTheme, DocsThemes } from "./highlighter.js";
|
|
3
|
-
import {
|
|
3
|
+
import { SerializableSearchOptions } from "./search-options.js";
|
|
4
4
|
import { DocsLabels } from "./react/shell-labels.js";
|
|
5
|
+
import { MarkdownComponents } from "./react/markdown-components.js";
|
|
5
6
|
import { DocsLayoutSearchProps } from "./react/layout.js";
|
|
6
7
|
import { DocsSource } from "./source.js";
|
|
7
8
|
import { ReactNode } from "react";
|
|
@@ -61,13 +62,36 @@ interface DocsRouteOptions<TFrontmatter extends DocFrontmatter = DocFrontmatter>
|
|
|
61
62
|
/**
|
|
62
63
|
* MiniSearch overrides for the index {@link DocsRoute.searchIndex} builds.
|
|
63
64
|
*
|
|
64
|
-
* ⚠️ THE IDENTICAL OBJECT MUST REACH THE DIALOG
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
* and not there produces an index whose terms no query can spell: zero
|
|
65
|
+
* ⚠️ THE IDENTICAL OBJECT MUST REACH THE DIALOG. MiniSearch reads `tokenize`
|
|
66
|
+
* and `processTerm` both when indexing and when querying, so applying one
|
|
67
|
+
* here and not there produces an index whose terms no query can spell: zero
|
|
68
68
|
* results, no error, nothing in the console.
|
|
69
|
+
*
|
|
70
|
+
* {@link DocsRoute.Layout} forwards this for you, which covers every
|
|
71
|
+
* serialisable override — `storeFields`, `boost`, `searchOptions.fuzzy`. It
|
|
72
|
+
* cannot forward a **function**: the dialog is a Client Component and props
|
|
73
|
+
* crossing that boundary are serialised, so `tokenize`, `processTerm` and
|
|
74
|
+
* their kind make `next build` fail. Rather than drop them silently — which
|
|
75
|
+
* is the zero-results failure above, with the warning turned off — `Layout`
|
|
76
|
+
* throws `invalid-config` and names the remedy: pass `search={false}`, render
|
|
77
|
+
* `DocsSearch` from a `'use client'` module of your own, and import the same
|
|
78
|
+
* function there. See {@link SerializableSearchOptions}.
|
|
69
79
|
*/
|
|
70
80
|
miniSearchOptions?: Partial<Options<SearchRecord>> | undefined;
|
|
81
|
+
/**
|
|
82
|
+
* Every string this package renders that is not your content.
|
|
83
|
+
*
|
|
84
|
+
* ⚠️ THE ROUTE IS WHERE THEY BELONG, BECAUSE THEY DO NOT ALL LIVE IN ONE
|
|
85
|
+
* RUNTIME. Four are rendered by the shell, two by the table of contents, nine
|
|
86
|
+
* by the markdown component map, two by a client-side copy runtime — and two
|
|
87
|
+
* are baked into the HTML by a rehype plugin at build time. `docs.Layout` can
|
|
88
|
+
* reach the first four and no more, which is exactly why its own `labels` prop
|
|
89
|
+
* documented itself as the whole set and covered less than a fifth of it.
|
|
90
|
+
*
|
|
91
|
+
* `docs.Layout` forwards these for you, and its `labels` prop still overrides
|
|
92
|
+
* them per-layout. Set them once here.
|
|
93
|
+
*/
|
|
94
|
+
labels?: DocsLabels | undefined;
|
|
71
95
|
}
|
|
72
96
|
/**
|
|
73
97
|
* Props for {@link DocsRoute.Layout}.
|
|
@@ -97,23 +121,39 @@ interface DocsLayoutProps {
|
|
|
97
121
|
* The search trigger. Defaults to on, and the URL is always derived.
|
|
98
122
|
*
|
|
99
123
|
* `false` omits it. An object configures the dialog — `placeholder`,
|
|
100
|
-
* `
|
|
101
|
-
* minus `indexUrl`.
|
|
124
|
+
* `pageSize`, `minQueryLength`, the state messages and the rest of
|
|
125
|
+
* `DocsSearch`'s surface, minus `indexUrl`.
|
|
126
|
+
*
|
|
127
|
+
* (It used to say `hotkey`. There is no such prop and there never was: the
|
|
128
|
+
* shortcut is ⌘K / Ctrl-K and is not configurable. A docstring naming an
|
|
129
|
+
* option that does not exist is worse than one naming none.)
|
|
102
130
|
*
|
|
103
131
|
* You do not need to pass `miniSearchOptions` here to match what
|
|
104
132
|
* `createDocsRoute` was given: the route's own value is forwarded, so the
|
|
105
133
|
* object that built the index is the object that queries it. Pass one only
|
|
106
134
|
* to override that.
|
|
135
|
+
*
|
|
136
|
+
* Serialisable overrides only, in both directions. This is a Server
|
|
137
|
+
* Component handing props to a Client one, so a `tokenize` or a
|
|
138
|
+
* `processTerm` here does not compile and one on the route throws rather
|
|
139
|
+
* than being quietly dropped — {@link SerializableSearchOptions} has the
|
|
140
|
+
* `'use client'` recipe for those.
|
|
107
141
|
*/
|
|
108
142
|
search?: boolean | DocsLayoutSearchProps | undefined;
|
|
109
143
|
/**
|
|
110
|
-
*
|
|
111
|
-
*
|
|
144
|
+
* Overrides for the strings the shell renders, over the route's.
|
|
145
|
+
*
|
|
146
|
+
* ⚠️ THIS DOCSTRING USED TO CLAIM TO BE "THE WHOLE OF WHAT A NON-ENGLISH SITE
|
|
147
|
+
* HAS TO SAY" AND REACHED FOUR STRINGS OF TWENTY-TWO. It could not have
|
|
148
|
+
* reached the rest: the table of contents is rendered by `docs.Page`, the
|
|
149
|
+
* callout headings by a component map built when the route is created, and the
|
|
150
|
+
* copy button is baked into the HTML by a rehype plugin at build time. None of
|
|
151
|
+
* those is downstream of a layout prop.
|
|
112
152
|
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
153
|
+
* So the set lives on {@link DocsRouteOptions.labels}, which is upstream of
|
|
154
|
+
* all four runtimes, and this overrides it key by key — for a site with two
|
|
155
|
+
* shells, or a section in another language. Whole-object replacement would
|
|
156
|
+
* mean naming one string cost you the other twenty-one.
|
|
117
157
|
*/
|
|
118
158
|
labels?: DocsLabels | undefined;
|
|
119
159
|
}
|
|
@@ -400,4 +440,4 @@ interface DocsRedirect {
|
|
|
400
440
|
*/
|
|
401
441
|
declare function createDocsRedirects(config: DocsConfig): Promise<DocsRedirect[]>;
|
|
402
442
|
//#endregion
|
|
403
|
-
export { type DocsLang, DocsLayoutProps, DocsPageMetadata, DocsPageProps, DocsRedirect, DocsRoute, DocsRouteOptions, DocsSitemapEntry, DocsSitemapOptions, type DocsTheme, type DocsThemes, createDocsRedirects, createDocsRoute, createDocsSitemap };
|
|
443
|
+
export { type DocsLang, DocsLayoutProps, type DocsLayoutSearchProps, DocsPageMetadata, DocsPageProps, DocsRedirect, DocsRoute, DocsRouteOptions, DocsSitemapEntry, DocsSitemapOptions, type DocsTheme, type DocsThemes, type SerializableSearchOptions, createDocsRedirects, createDocsRoute, createDocsSitemap };
|
package/dist/next.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
+
import { assertAnchors } from "./anchors.js";
|
|
1
2
|
import { docsError } from "./docs-error.js";
|
|
2
3
|
import { DOCS_CONTENT_ID } from "./docs-content-id.js";
|
|
4
|
+
import { describeSuggestion } from "./link-suggestion.js";
|
|
3
5
|
import { mapPooled } from "./map-pooled.js";
|
|
6
|
+
import { findFunctionValuedOptions } from "./search-options.js";
|
|
4
7
|
import { createMarkdownComponents } from "./react/markdown-components.js";
|
|
5
8
|
import { DocContent } from "./react/doc-content.js";
|
|
6
9
|
import { DocsToc } from "./react/toc.js";
|
|
7
|
-
import { wrapNextLink } from "./react/
|
|
10
|
+
import { wrapNextLink } from "./react/link-adapter.js";
|
|
8
11
|
import { createDocsRenderer } from "./render.js";
|
|
9
12
|
import { toAliasRoute } from "./route-path.js";
|
|
10
13
|
import { createDocsSource, resolveDocsConfig } from "./source.js";
|
|
@@ -119,9 +122,14 @@ async function loadNotFound() {
|
|
|
119
122
|
* as `number | \`${number}\``, and spreading a `ComponentProps<'img'>`-shaped
|
|
120
123
|
* object into it fails with TS2322 because the DOM types allow a bare `string`.
|
|
121
124
|
* The build-time {@link ImageResolver} has already produced real numbers here.
|
|
125
|
+
*
|
|
126
|
+
* ⚠️ SO THE LIST IS THE CONTRACT, AND IT HAS TO MATCH `DocsImageProps`. A prop
|
|
127
|
+
* added there and forgotten here is dropped silently — which is what happened to
|
|
128
|
+
* `decoding` and `fetchPriority`, under a comment in `createImage` promising
|
|
129
|
+
* they survived.
|
|
122
130
|
*/
|
|
123
131
|
function wrapNextImage(NextImage) {
|
|
124
|
-
return function DocsNextImage({ src, alt, width, height, title, className, sizes, loading }) {
|
|
132
|
+
return function DocsNextImage({ src, alt, width, height, title, className, sizes, loading, decoding, fetchPriority }) {
|
|
125
133
|
return createElement(NextImage, {
|
|
126
134
|
src,
|
|
127
135
|
alt,
|
|
@@ -130,35 +138,92 @@ function wrapNextImage(NextImage) {
|
|
|
130
138
|
...title === void 0 ? {} : { title },
|
|
131
139
|
...className === void 0 ? {} : { className },
|
|
132
140
|
...sizes === void 0 ? {} : { sizes },
|
|
133
|
-
...loading === void 0 ? {} : { loading }
|
|
141
|
+
...loading === void 0 ? {} : { loading },
|
|
142
|
+
...decoding === void 0 ? {} : { decoding },
|
|
143
|
+
...fetchPriority === void 0 ? {} : { fetchPriority }
|
|
134
144
|
});
|
|
135
145
|
};
|
|
136
146
|
}
|
|
137
147
|
/**
|
|
138
|
-
*
|
|
148
|
+
* A memo for the component map, one per route.
|
|
139
149
|
*
|
|
140
150
|
* `createMarkdownComponents` returns fresh component identities on every call,
|
|
141
151
|
* and a new identity for `a` remounts every link in the document on every
|
|
142
152
|
* render — so this memo is correctness, not micro-optimisation.
|
|
153
|
+
*
|
|
154
|
+
* ⚠️ PER ROUTE, NOT PER PROCESS, SINCE THE MAP CLOSES OVER THE ROUTE'S LABELS.
|
|
155
|
+
* A single process-wide memo would hand the second route the first route's
|
|
156
|
+
* language. Nothing is lost: `import()` caches the `next/link` and `next/image`
|
|
157
|
+
* modules itself, so all a second route pays for is two wrapper identities —
|
|
158
|
+
* and a route's identities only ever have to be stable against themselves,
|
|
159
|
+
* because the pages that use them come from that same route.
|
|
143
160
|
*/
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
161
|
+
function createComponentsMemo(labels) {
|
|
162
|
+
let memo = null;
|
|
163
|
+
return () => {
|
|
164
|
+
if (memo === null) memo = buildNextComponents(labels).catch((error) => {
|
|
165
|
+
memo = null;
|
|
166
|
+
throw error;
|
|
167
|
+
});
|
|
168
|
+
return memo;
|
|
169
|
+
};
|
|
151
170
|
}
|
|
152
|
-
async function buildNextComponents() {
|
|
171
|
+
async function buildNextComponents(labels) {
|
|
153
172
|
const [linkMod, imageMod] = await Promise.all([importNext(() => import("next/link"), "next/link"), importNext(() => import("next/image"), "next/image")]);
|
|
154
173
|
const NextLink = readDefaultExport(linkMod, "next/link");
|
|
155
174
|
const NextImage = readDefaultExport(imageMod, "next/image");
|
|
156
175
|
return createMarkdownComponents({
|
|
157
176
|
Link: wrapNextLink(NextLink),
|
|
158
|
-
Image: wrapNextImage(NextImage)
|
|
177
|
+
Image: wrapNextImage(NextImage),
|
|
178
|
+
...labels === void 0 ? {} : { labels }
|
|
159
179
|
});
|
|
160
180
|
}
|
|
161
181
|
/**
|
|
182
|
+
* The named subset of `labels`, or `undefined` when none of it is set.
|
|
183
|
+
*
|
|
184
|
+
* `undefined` rather than `{}` is the point: every forwarding site here spreads
|
|
185
|
+
* with `...(x === undefined ? {} : { x })`, so an empty object would still add a
|
|
186
|
+
* prop — and for the two groups that cross a client boundary that is a prop in
|
|
187
|
+
* every page's payload, forever, saying nothing.
|
|
188
|
+
*
|
|
189
|
+
* `map` goes target-key → `DocsLabels` key, so the rename is visible at the call
|
|
190
|
+
* site rather than hidden in a component.
|
|
191
|
+
*/
|
|
192
|
+
function pickLabels(labels, map) {
|
|
193
|
+
if (labels === void 0) return void 0;
|
|
194
|
+
const picked = {};
|
|
195
|
+
let found = false;
|
|
196
|
+
for (const [target, source] of Object.entries(map)) {
|
|
197
|
+
const value = labels[source];
|
|
198
|
+
if (value !== void 0) {
|
|
199
|
+
picked[target] = value;
|
|
200
|
+
found = true;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return found ? picked : void 0;
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* `candidate`, once it is known to hold no functions.
|
|
207
|
+
*
|
|
208
|
+
* The cast at the end is the whole point of the function, and
|
|
209
|
+
* {@link findFunctionValuedOptions} is what earns it: a structural walk over
|
|
210
|
+
* the values, so it answers for options MiniSearch has not shipped yet as well
|
|
211
|
+
* as the five it has. `SerializableSearchOptions` narrows the same thing at
|
|
212
|
+
* compile time, which is friendlier and strictly weaker — a JavaScript caller
|
|
213
|
+
* has no types at all, and an `Omit` list goes stale the minor MiniSearch adds
|
|
214
|
+
* a callback.
|
|
215
|
+
*
|
|
216
|
+
* Throwing beats dropping. Silently forwarding the serialisable half would
|
|
217
|
+
* rebuild the original defect this channel exists to close — an index built
|
|
218
|
+
* with a `processTerm` the query does not share returns nothing, reports
|
|
219
|
+
* nothing, and looks like an empty corpus.
|
|
220
|
+
*/
|
|
221
|
+
function serializableSearchOptions(candidate) {
|
|
222
|
+
const functions = findFunctionValuedOptions(candidate);
|
|
223
|
+
if (functions.length > 0) throw docsError("invalid-config", `the search dialog cannot be given MiniSearch functions from a server component: ${functions.map((name) => `\`miniSearchOptions.${name}\``).join(", ")}. \`docs.Layout\` renders the dialog as a client component, so its props are serialised on the way across and React rejects a function with "Functions cannot be passed directly to Client Components" while prerendering. Keep the function on \`createDocsRoute\` so the index is still built with it, pass \`search={false}\` to \`docs.Layout\`, and render the dialog yourself from a \`'use client'\` module that imports the same function — \`<DocsSearch indexUrl={docs.searchIndexUrl} miniSearchOptions={{ processTerm }} />\` — putting that component in \`actions\`. Serialisable overrides (\`storeFields\`, \`boost\`, \`searchOptions.fuzzy\`) need none of this and are forwarded as before.`);
|
|
224
|
+
return candidate;
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
162
227
|
* Create the route handlers for a documentation tree.
|
|
163
228
|
*
|
|
164
229
|
* Call it once at module scope in each of the two route files. The filesystem
|
|
@@ -170,6 +235,28 @@ function createDocsRoute(options) {
|
|
|
170
235
|
const source = createDocsSource(options);
|
|
171
236
|
const siteUrl = normalizeSiteUrl(options.siteUrl);
|
|
172
237
|
const rescanPerRequest = process.env.NODE_ENV !== "production";
|
|
238
|
+
const routeLabels = options.labels;
|
|
239
|
+
const codeLabels = pickLabels(routeLabels, {
|
|
240
|
+
copyLabel: "copyCode",
|
|
241
|
+
copyFromLabel: "copyCodeFrom"
|
|
242
|
+
});
|
|
243
|
+
const contentLabels = pickLabels(routeLabels, {
|
|
244
|
+
externalLink: "externalLink",
|
|
245
|
+
table: "table",
|
|
246
|
+
calloutNote: "calloutNote",
|
|
247
|
+
calloutTip: "calloutTip",
|
|
248
|
+
calloutImportant: "calloutImportant",
|
|
249
|
+
calloutWarning: "calloutWarning",
|
|
250
|
+
calloutCaution: "calloutCaution",
|
|
251
|
+
youtubeTitle: "youtubeTitle",
|
|
252
|
+
youtubePlay: "youtubePlay",
|
|
253
|
+
youtubeHide: "youtubeHide"
|
|
254
|
+
});
|
|
255
|
+
const copyLabels = pickLabels(routeLabels, {
|
|
256
|
+
copied: "copied",
|
|
257
|
+
copyFailed: "copyFailed"
|
|
258
|
+
});
|
|
259
|
+
const loadComponents = createComponentsMemo(contentLabels);
|
|
173
260
|
let renderer = null;
|
|
174
261
|
const knownRoutes = /* @__PURE__ */ new Set();
|
|
175
262
|
const draftRoutes = /* @__PURE__ */ new Set();
|
|
@@ -232,6 +319,7 @@ function createDocsRoute(options) {
|
|
|
232
319
|
...options.langs === void 0 ? {} : { langs: options.langs },
|
|
233
320
|
...options.themes === void 0 ? {} : { themes: options.themes },
|
|
234
321
|
...options.excludeLangs === void 0 ? {} : { excludeLangs: options.excludeLangs },
|
|
322
|
+
...codeLabels === void 0 ? {} : { codeLabels },
|
|
235
323
|
...options.titleHeading === void 0 ? {} : { titleHeading: options.titleHeading },
|
|
236
324
|
...options.remarkPlugins === void 0 ? {} : { remarkPlugins: options.remarkPlugins },
|
|
237
325
|
...options.rehypePlugins === void 0 ? {} : { rehypePlugins: options.rehypePlugins },
|
|
@@ -288,7 +376,23 @@ function createDocsRoute(options) {
|
|
|
288
376
|
const files = await source.all();
|
|
289
377
|
await loadRoutes();
|
|
290
378
|
const renderer = loadRenderer();
|
|
291
|
-
|
|
379
|
+
const rendered = await mapPooled(files, RENDER_CONCURRENCY, (file) => renderer.render(file));
|
|
380
|
+
assertAnchors(rendered, (from, link, known) => {
|
|
381
|
+
reportAnchor(`@waveso/docs: ${from} links to '${link.href}', and '${link.route}' has no '#${link.fragment}'.${describeSuggestion(link.fragment, known)} Heading ids come from the heading text, so renaming a heading renames its anchor.`);
|
|
382
|
+
});
|
|
383
|
+
return rendered;
|
|
384
|
+
};
|
|
385
|
+
/**
|
|
386
|
+
* A cross-page anchor failure, at the configured severity.
|
|
387
|
+
*
|
|
388
|
+
* No line number, unlike the same-page check: positions are stripped from a
|
|
389
|
+
* returned tree, so the page and the link are what there is to name. Both
|
|
390
|
+
* halves share `onBrokenAnchors`, because to an author they are one mistake.
|
|
391
|
+
*/
|
|
392
|
+
const reportAnchor = (message) => {
|
|
393
|
+
if (config.onBrokenAnchors === "ignore") return;
|
|
394
|
+
if (config.onBrokenAnchors === "throw") throw docsError("broken-anchor", message);
|
|
395
|
+
console.warn(message);
|
|
292
396
|
};
|
|
293
397
|
const searchIndexUrl = `${config.basePath}/search-index.json`;
|
|
294
398
|
/**
|
|
@@ -321,7 +425,7 @@ function createDocsRoute(options) {
|
|
|
321
425
|
async function renderRoute(segments) {
|
|
322
426
|
const doc = await getPage(segments);
|
|
323
427
|
if (doc === void 0) return (await loadNotFound())();
|
|
324
|
-
const components = await
|
|
428
|
+
const components = await loadComponents();
|
|
325
429
|
return createElement(Fragment, null, createElement("main", {
|
|
326
430
|
className: "wave-docs-layout__main",
|
|
327
431
|
id: DOCS_CONTENT_ID,
|
|
@@ -331,8 +435,13 @@ function createDocsRoute(options) {
|
|
|
331
435
|
components: {
|
|
332
436
|
...components,
|
|
333
437
|
...options.components
|
|
334
|
-
}
|
|
335
|
-
|
|
438
|
+
},
|
|
439
|
+
...copyLabels === void 0 ? {} : { labels: copyLabels }
|
|
440
|
+
})), doc.toc.length === 0 ? null : createElement("aside", { className: "wave-docs-layout__toc" }, createElement(DocsToc, {
|
|
441
|
+
entries: doc.toc,
|
|
442
|
+
...routeLabels?.toc === void 0 ? {} : { label: routeLabels.toc },
|
|
443
|
+
...routeLabels?.backToTop === void 0 ? {} : { topLabel: routeLabels.backToTop }
|
|
444
|
+
})));
|
|
336
445
|
}
|
|
337
446
|
return {
|
|
338
447
|
source: requestScopedSource,
|
|
@@ -350,9 +459,15 @@ function createDocsRoute(options) {
|
|
|
350
459
|
},
|
|
351
460
|
async Layout({ children, title, actions, search, labels }) {
|
|
352
461
|
const { DocsLayoutShell } = await import("./react/layout.js");
|
|
462
|
+
const host = search === true || search === void 0 || search === false ? void 0 : search;
|
|
463
|
+
const requestedOptions = host?.miniSearchOptions ?? options.miniSearchOptions;
|
|
353
464
|
const searchProps = search === false ? false : {
|
|
354
|
-
...
|
|
355
|
-
...
|
|
465
|
+
...host,
|
|
466
|
+
...requestedOptions === void 0 ? {} : { miniSearchOptions: serializableSearchOptions(requestedOptions) }
|
|
467
|
+
};
|
|
468
|
+
const shellLabels = options.labels === void 0 && labels === void 0 ? void 0 : {
|
|
469
|
+
...options.labels,
|
|
470
|
+
...labels
|
|
356
471
|
};
|
|
357
472
|
return createElement(DocsLayoutShell, {
|
|
358
473
|
children,
|
|
@@ -361,7 +476,7 @@ function createDocsRoute(options) {
|
|
|
361
476
|
search: searchProps,
|
|
362
477
|
...title === void 0 ? {} : { title },
|
|
363
478
|
...actions === void 0 ? {} : { actions },
|
|
364
|
-
...
|
|
479
|
+
...shellLabels === void 0 ? {} : { labels: shellLabels }
|
|
365
480
|
});
|
|
366
481
|
},
|
|
367
482
|
async generateStaticParams() {
|
|
@@ -2,8 +2,20 @@ import { Plugin } from "unified";
|
|
|
2
2
|
import { Root } from "hast";
|
|
3
3
|
//#region src/plugins/rehype-code-frame.d.ts
|
|
4
4
|
interface RehypeCodeFrameOptions {
|
|
5
|
-
/**
|
|
5
|
+
/**
|
|
6
|
+
* Accessible name of the copy button on a fence with no title.
|
|
7
|
+
* Default `'Copy code'`.
|
|
8
|
+
*/
|
|
6
9
|
copyLabel?: string | undefined;
|
|
10
|
+
/**
|
|
11
|
+
* The same button on a titled fence. Default `'Copy code from {title}'`.
|
|
12
|
+
*
|
|
13
|
+
* `{title}` is replaced with the fence's own `title="…"`. Two controls both
|
|
14
|
+
* called "Copy code" are indistinguishable in a screen reader's element list,
|
|
15
|
+
* which is why the titled form exists at all — and a translator needs to be
|
|
16
|
+
* able to move the title within the sentence, which concatenation forbade.
|
|
17
|
+
*/
|
|
18
|
+
copyFromLabel?: string | undefined;
|
|
7
19
|
}
|
|
8
20
|
declare const rehypeCodeFrame: Plugin<[RehypeCodeFrameOptions?], Root>;
|
|
9
21
|
//#endregion
|
|
@@ -6,6 +6,7 @@ import { CONTINUE, SKIP, visit } from "unist-util-visit";
|
|
|
6
6
|
const LANGUAGE_CLASS = /^language-(.+)$/;
|
|
7
7
|
const rehypeCodeFrame = (options = {}) => {
|
|
8
8
|
const copyLabel = options.copyLabel ?? "Copy code";
|
|
9
|
+
const copyFromLabel = options.copyFromLabel ?? "Copy code from {title}";
|
|
9
10
|
return (tree, file) => {
|
|
10
11
|
const path = file.data.docLinkContext?.relativePath ?? file.path ?? "a document";
|
|
11
12
|
visit(tree, "element", (node, index, parent) => {
|
|
@@ -27,7 +28,7 @@ const rehypeCodeFrame = (options = {}) => {
|
|
|
27
28
|
value: title
|
|
28
29
|
}]
|
|
29
30
|
});
|
|
30
|
-
children.push(copyButton(title === void 0 ? copyLabel :
|
|
31
|
+
children.push(copyButton(title === void 0 ? copyLabel : copyFromLabel.replace("{title}", title)), node);
|
|
31
32
|
parent.children[index] = {
|
|
32
33
|
type: "element",
|
|
33
34
|
tagName: "figure",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
+
import { visit } from "unist-util-visit";
|
|
1
2
|
import rehypeSlug from "rehype-slug";
|
|
2
3
|
import { unified } from "unified";
|
|
3
|
-
import { visit } from "unist-util-visit";
|
|
4
4
|
//#region src/plugins/rehype-fallback-heading-ids.ts
|
|
5
5
|
const HEADING = /^h[1-6]$/;
|
|
6
6
|
/** `-1`, `-2` … — what `github-slugger` returns for a repeated empty slug. */
|
|
@@ -26,6 +26,36 @@ interface DocLinkRef {
|
|
|
26
26
|
* never be a member of.
|
|
27
27
|
*/
|
|
28
28
|
asset?: true;
|
|
29
|
+
/**
|
|
30
|
+
* An absolute link at a root mount, which cannot be proved to be ours.
|
|
31
|
+
*
|
|
32
|
+
* ⚠️ RECORDED RATHER THAN SKIPPED, WHICH IS THE CHANGE. Under
|
|
33
|
+
* `basePath: '/docs'` an absolute link either starts with `/docs` — so it is
|
|
34
|
+
* a documentation route and is checked — or it does not, and belongs to the
|
|
35
|
+
* host's application. Under `basePath: '/'` that test cannot be made:
|
|
36
|
+
* `/setup` may be a page here and `/login` almost certainly is not, and
|
|
37
|
+
* nothing in the markdown says which.
|
|
38
|
+
*
|
|
39
|
+
* So it is collected and marked, and checked like any other link — because a
|
|
40
|
+
* root mount is what you choose when the origin serves documentation and
|
|
41
|
+
* nothing else, which makes an unknown absolute link a typo. An origin that
|
|
42
|
+
* serves something else names what is its own through
|
|
43
|
+
* `DocsConfig.externalRoutes`.
|
|
44
|
+
*/
|
|
45
|
+
unverifiable?: true;
|
|
46
|
+
/**
|
|
47
|
+
* A bare `#fragment` — a link into the page it is written on.
|
|
48
|
+
*
|
|
49
|
+
* ⚠️ RECORDED SO THE ANCHOR CAN BE CHECKED, AND FLAGGED SO THE ROUTE IS NOT.
|
|
50
|
+
* `isRelativeLink` excludes these and always did, correctly: there is no
|
|
51
|
+
* route to resolve. But that also meant they were never collected, so nothing
|
|
52
|
+
* downstream could see `#missing` at all — and a same-page anchor is the one
|
|
53
|
+
* a writer produces most, every "see below".
|
|
54
|
+
*
|
|
55
|
+
* `assertLinks` skips these; `assertOwnAnchors` is what reads them, and the
|
|
56
|
+
* recorded line is why the message can name one.
|
|
57
|
+
*/
|
|
58
|
+
anchorOnly?: true;
|
|
29
59
|
}
|
|
30
60
|
declare module 'vfile' {
|
|
31
61
|
interface DataMap {
|
|
@@ -41,6 +71,8 @@ interface RemarkDocLinksOptions {
|
|
|
41
71
|
/** Overrides the built-in resolution entirely, for every relative link. */
|
|
42
72
|
resolve?: LinkResolver;
|
|
43
73
|
}
|
|
74
|
+
/** No prefix at all — the docs own the whole origin. */
|
|
75
|
+
declare function isRootMount(basePath: string): boolean;
|
|
44
76
|
/**
|
|
45
77
|
* Fold `.` and `..` against a starting directory.
|
|
46
78
|
*
|
|
@@ -56,6 +88,56 @@ interface RemarkDocLinksOptions {
|
|
|
56
88
|
* are contained by one implementation rather than two.
|
|
57
89
|
*/
|
|
58
90
|
declare function foldSegments(from: readonly string[], path: string): string[] | undefined;
|
|
91
|
+
/**
|
|
92
|
+
* Decode BEFORE folding, never after: `%2E%2E%2F` is `../` in disguise, and
|
|
93
|
+
* `foldSegments` is the only thing that refuses a chain climbing out of the
|
|
94
|
+
* content root.
|
|
95
|
+
*
|
|
96
|
+
* Exported for `route-path.ts`, which needs the decode without the split:
|
|
97
|
+
* an alias may carry a literal `#` or `?` — `c# guide` is a page name — and
|
|
98
|
+
* {@link splitHref} would cut the string there and throw the rest away.
|
|
99
|
+
*/
|
|
100
|
+
declare function decodePath(path: string, href: string): string;
|
|
101
|
+
/**
|
|
102
|
+
* An href, split at `?` and `#`, with the path decoded and the rest left alone.
|
|
103
|
+
*
|
|
104
|
+
* ⚠️ ONE IMPLEMENTATION, BECAUSE THE THIRD COPY WAS WRONG. Four call sites need
|
|
105
|
+
* this exact sequence — split, decode the path, fold, re-attach — and three of
|
|
106
|
+
* them had it inline while `foldImageSrc` in `render.ts` had none of it. An
|
|
107
|
+
* author dragging a file into GitHub's editor gets ``
|
|
108
|
+
* written for them, and that reached the resolver undecoded: `readFile` on a
|
|
109
|
+
* filename with a literal `%20` in it, `ENOENT`, and a build failed on an image
|
|
110
|
+
* that is plainly on disk and that GitHub renders. `./diagram.png?v=2` and
|
|
111
|
+
* `./sprite.svg#icon` baked the query and the fragment into the filename the
|
|
112
|
+
* same way.
|
|
113
|
+
*
|
|
114
|
+
* A query and a fragment are never decoded: `?q=a%26b` carries a literal
|
|
115
|
+
* ampersand that decoding would turn into a separator, and neither is part of
|
|
116
|
+
* any filename.
|
|
117
|
+
*/
|
|
118
|
+
interface HrefParts {
|
|
119
|
+
/** Path, percent-decoded and NFC-normalised — what {@link foldSegments} needs. */
|
|
120
|
+
path: string;
|
|
121
|
+
/**
|
|
122
|
+
* The same span exactly as authored.
|
|
123
|
+
*
|
|
124
|
+
* One caller needs it: {@link normalizeInternalRoute} strips `basePath` off
|
|
125
|
+
* the front by length, and doing that to a decoded path would cut at the
|
|
126
|
+
* wrong offset for any href that percent-encoded part of the prefix.
|
|
127
|
+
*/
|
|
128
|
+
rawPath: string;
|
|
129
|
+
/** `?query` as authored, or `''`. */
|
|
130
|
+
query: string;
|
|
131
|
+
/** `#hash` as authored, or `''`. */
|
|
132
|
+
hash: string;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Split an href into its path, query and fragment; decode only the path.
|
|
136
|
+
*
|
|
137
|
+
* Throws a {@link URIError} if the path is not valid percent-encoding, with the
|
|
138
|
+
* whole href in the message — see {@link decodeSegment}.
|
|
139
|
+
*/
|
|
140
|
+
declare function splitHref(href: string): HrefParts;
|
|
59
141
|
/**
|
|
60
142
|
* The built-in {@link LinkResolver}: markdown file path in, route out.
|
|
61
143
|
*
|
|
@@ -71,4 +153,4 @@ declare function resolveMarkdownLink(href: string, fromDir: readonly string[], b
|
|
|
71
153
|
*/
|
|
72
154
|
declare const remarkDocLinks: Plugin<[RemarkDocLinksOptions], Root>;
|
|
73
155
|
//#endregion
|
|
74
|
-
export { type DocLinkContext, DocLinkRef, RemarkDocLinksOptions, foldSegments, remarkDocLinks, resolveMarkdownLink };
|
|
156
|
+
export { type DocLinkContext, DocLinkRef, HrefParts, RemarkDocLinksOptions, decodePath, foldSegments, isRootMount, remarkDocLinks, resolveMarkdownLink, splitHref };
|