@waveso/docs 0.3.0 → 0.5.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 +201 -0
- package/README.md +160 -39
- package/dist/errors.d.ts +2 -0
- package/dist/highlighter.js +2 -1
- package/dist/meta.js +6 -9
- package/dist/next.d.ts +54 -14
- package/dist/next.js +115 -18
- package/dist/plugins/rehype-code-frame.d.ts +13 -1
- package/dist/plugins/rehype-code-frame.js +2 -1
- package/dist/plugins/remark-doc-links.d.ts +51 -1
- package/dist/plugins/remark-doc-links.js +27 -16
- 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/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-nav.d.ts +5 -1
- package/dist/react/next-nav.js +5 -2
- package/dist/react/search-dialog.d.ts +92 -5
- package/dist/react/search-dialog.js +182 -43
- 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 +11 -0
- package/dist/render.js +38 -11
- 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 +80 -10
- package/dist/styles.css +62 -13
- package/dist/types.d.ts +30 -0
- package/package.json +1 -1
package/dist/search-options.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { SearchRecord } from "./types.js";
|
|
2
|
-
import { Options } from "minisearch";
|
|
2
|
+
import { Options, SearchOptions } from "minisearch";
|
|
3
3
|
//#region src/search-options.d.ts
|
|
4
4
|
/**
|
|
5
5
|
* Split text into index terms.
|
|
@@ -45,5 +45,67 @@ declare const SEARCH_INDEX_OPTIONS: Options<SearchRecord>;
|
|
|
45
45
|
* OR. Nothing below that merges: a `boost` override replaces the whole map.
|
|
46
46
|
*/
|
|
47
47
|
declare function mergeSearchOptions(overrides?: Partial<Options<SearchRecord>>): Options<SearchRecord>;
|
|
48
|
+
/**
|
|
49
|
+
* {@link SearchOptions} with every function-valued member removed.
|
|
50
|
+
*
|
|
51
|
+
* `prefix` and `fuzzy` survive as the boolean and the number they usually are;
|
|
52
|
+
* their function overloads do not, because a predicate cannot be serialised.
|
|
53
|
+
*/
|
|
54
|
+
type SerializableSearchQueryOptions = Omit<SearchOptions, 'filter' | 'boostTerm' | 'boostDocument' | 'tokenize' | 'processTerm' | 'prefix' | 'fuzzy'> & {
|
|
55
|
+
prefix?: boolean;
|
|
56
|
+
fuzzy?: boolean | number;
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* MiniSearch overrides that can be handed from a Server Component to a Client
|
|
60
|
+
* Component — which is to say, the ones with no functions in them.
|
|
61
|
+
*
|
|
62
|
+
* React serialises a Client Component's props, and a function is not
|
|
63
|
+
* serialisable: `docs.Layout` forwarding `{ processTerm }` into `DocsSearch`
|
|
64
|
+
* fails `next build` outright with *"Functions cannot be passed directly to
|
|
65
|
+
* Client Components"*. This type is what stops that being expressible.
|
|
66
|
+
*
|
|
67
|
+
* ⚠️ THE OMIT LIST IS NOT THE GUARANTEE — {@link findFunctionValuedOptions} IS.
|
|
68
|
+
* MiniSearch is free to add a function-valued option in a minor, and the day it
|
|
69
|
+
* does this list is quietly incomplete while still compiling. The runtime walk
|
|
70
|
+
* has no such failure mode: it finds a function wherever it is, including in
|
|
71
|
+
* options this package has never heard of. The type is here to fail earlier and
|
|
72
|
+
* more legibly, not to be the last line of defence.
|
|
73
|
+
*
|
|
74
|
+
* The escape hatch for real function tuning is a client boundary of the host's
|
|
75
|
+
* own, which is the only place the two halves can share a module reference:
|
|
76
|
+
*
|
|
77
|
+
* ```tsx
|
|
78
|
+
* // app/docs/search.tsx
|
|
79
|
+
* 'use client';
|
|
80
|
+
* import { DocsSearch } from '@waveso/docs/react/next-search';
|
|
81
|
+
* import { processTerm } from '@/lib/search-terms';
|
|
82
|
+
*
|
|
83
|
+
* export function Search({ indexUrl }: { indexUrl: string }) {
|
|
84
|
+
* return <DocsSearch indexUrl={indexUrl} miniSearchOptions={{ processTerm }} />;
|
|
85
|
+
* }
|
|
86
|
+
* ```
|
|
87
|
+
*
|
|
88
|
+
* That component takes the boundary with it, so the function is a module import
|
|
89
|
+
* on both sides rather than a prop crossing between them — exactly how
|
|
90
|
+
* {@link tokenizeSearchText} reaches the client today.
|
|
91
|
+
*/
|
|
92
|
+
type SerializableSearchOptions = Omit<Partial<Options<SearchRecord>>, 'extractField' | 'stringifyField' | 'tokenize' | 'processTerm' | 'logger' | 'searchOptions' | 'autoSuggestOptions'> & {
|
|
93
|
+
searchOptions?: SerializableSearchQueryOptions;
|
|
94
|
+
autoSuggestOptions?: SerializableSearchQueryOptions;
|
|
95
|
+
};
|
|
96
|
+
/**
|
|
97
|
+
* Dotted paths of every function reachable from `options`, in encounter order.
|
|
98
|
+
*
|
|
99
|
+
* The load-bearing half of the boundary check, and deliberately structural
|
|
100
|
+
* rather than a key list: it answers for `processTerm`, for
|
|
101
|
+
* `searchOptions.filter`, and for whatever MiniSearch adds next, because it
|
|
102
|
+
* asks what the values *are* rather than what they are called.
|
|
103
|
+
*
|
|
104
|
+
* `seen` makes a cyclic options object an empty answer rather than a stack
|
|
105
|
+
* overflow. Nothing in MiniSearch's surface is cyclic, but a hang during
|
|
106
|
+
* `next build` is a far worse failure than a wrong one, and the guard is a
|
|
107
|
+
* line.
|
|
108
|
+
*/
|
|
109
|
+
declare function findFunctionValuedOptions(options: object, prefix?: string, seen?: WeakSet<object>): string[];
|
|
48
110
|
//#endregion
|
|
49
|
-
export { SEARCH_INDEX_OPTIONS, mergeSearchOptions, tokenizeSearchText };
|
|
111
|
+
export { SEARCH_INDEX_OPTIONS, SerializableSearchOptions, SerializableSearchQueryOptions, findFunctionValuedOptions, mergeSearchOptions, tokenizeSearchText };
|
package/dist/search-options.js
CHANGED
|
@@ -99,5 +99,29 @@ function mergeSearchOptions(overrides = {}) {
|
|
|
99
99
|
}
|
|
100
100
|
};
|
|
101
101
|
}
|
|
102
|
+
/**
|
|
103
|
+
* Dotted paths of every function reachable from `options`, in encounter order.
|
|
104
|
+
*
|
|
105
|
+
* The load-bearing half of the boundary check, and deliberately structural
|
|
106
|
+
* rather than a key list: it answers for `processTerm`, for
|
|
107
|
+
* `searchOptions.filter`, and for whatever MiniSearch adds next, because it
|
|
108
|
+
* asks what the values *are* rather than what they are called.
|
|
109
|
+
*
|
|
110
|
+
* `seen` makes a cyclic options object an empty answer rather than a stack
|
|
111
|
+
* overflow. Nothing in MiniSearch's surface is cyclic, but a hang during
|
|
112
|
+
* `next build` is a far worse failure than a wrong one, and the guard is a
|
|
113
|
+
* line.
|
|
114
|
+
*/
|
|
115
|
+
function findFunctionValuedOptions(options, prefix = "", seen = /* @__PURE__ */ new WeakSet()) {
|
|
116
|
+
if (seen.has(options)) return [];
|
|
117
|
+
seen.add(options);
|
|
118
|
+
const found = [];
|
|
119
|
+
for (const [key, value] of Object.entries(options)) {
|
|
120
|
+
const path = prefix === "" ? key : `${prefix}.${key}`;
|
|
121
|
+
if (typeof value === "function") found.push(path);
|
|
122
|
+
else if (typeof value === "object" && value !== null) found.push(...findFunctionValuedOptions(value, path, seen));
|
|
123
|
+
}
|
|
124
|
+
return found;
|
|
125
|
+
}
|
|
102
126
|
//#endregion
|
|
103
|
-
export { SEARCH_INDEX_OPTIONS, mergeSearchOptions, tokenizeSearchText };
|
|
127
|
+
export { SEARCH_INDEX_OPTIONS, findFunctionValuedOptions, mergeSearchOptions, tokenizeSearchText };
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
//#region src/semaphore.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* A counting semaphore, for bounding how much of a process resource is in use.
|
|
4
|
+
*
|
|
5
|
+
* Private — deliberately not an entry point in `package.json`.
|
|
6
|
+
*
|
|
7
|
+
* {@link mapPooled} bounds a fan-out over a list, which is the easy case: the
|
|
8
|
+
* list is known, so the pool can pull from it. A recursive tree walk has no
|
|
9
|
+
* list — `scanDir` calls itself once per subdirectory, so a per-call pool
|
|
10
|
+
* bounds each directory and multiplies across the depth, which is not a bound
|
|
11
|
+
* at all. That is what this is for.
|
|
12
|
+
*
|
|
13
|
+
* ⚠️ NEVER ACQUIRE A SLOT WHILE HOLDING ONE. Nested acquisition deadlocks the
|
|
14
|
+
* moment every slot is held by a caller waiting for a slot, and no amount of
|
|
15
|
+
* timeout rescues it. Guard the leaf operation — the `readFile`, the `readdir`
|
|
16
|
+
* — and never the recursive call around it. In `source.ts` this is why the
|
|
17
|
+
* traversal itself is ungated: only the filesystem calls take slots, so the
|
|
18
|
+
* descriptors are bounded exactly while the walk stays as parallel as it was.
|
|
19
|
+
*/
|
|
20
|
+
/** @see createSemaphore */
|
|
21
|
+
interface Semaphore {
|
|
22
|
+
/**
|
|
23
|
+
* Run `fn` once a slot is free, and give the slot back when it settles.
|
|
24
|
+
*
|
|
25
|
+
* Rejections propagate untouched, and release the slot on the way out.
|
|
26
|
+
*/
|
|
27
|
+
run<T>(fn: () => Promise<T>): Promise<T>;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* A semaphore admitting `limit` concurrent callers.
|
|
31
|
+
*
|
|
32
|
+
* Waiters are woken in arrival order, so a long queue cannot starve its head.
|
|
33
|
+
*
|
|
34
|
+
* A limit below one falls back to one, for the same reason {@link mapPooled}
|
|
35
|
+
* clamps: a caller passing `0` — or a negative, from arithmetic on a config
|
|
36
|
+
* value — would otherwise admit nobody, and the symptom is a build that hangs
|
|
37
|
+
* rather than one that fails.
|
|
38
|
+
*
|
|
39
|
+
* ⚠️ `Math.max(1, limit)` IS NOT ENOUGH, BECAUSE `Math.max(1, NaN)` IS `NaN`.
|
|
40
|
+
* With `max` set to `NaN`, `active >= max` is false forever and the semaphore
|
|
41
|
+
* admits everyone — no hang, no error, and no bound, which is the one outcome
|
|
42
|
+
* worse than either. Non-finite means one.
|
|
43
|
+
*/
|
|
44
|
+
declare function createSemaphore(limit: number): Semaphore;
|
|
45
|
+
//#endregion
|
|
46
|
+
export { Semaphore, createSemaphore };
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
//#region src/semaphore.ts
|
|
2
|
+
/**
|
|
3
|
+
* A semaphore admitting `limit` concurrent callers.
|
|
4
|
+
*
|
|
5
|
+
* Waiters are woken in arrival order, so a long queue cannot starve its head.
|
|
6
|
+
*
|
|
7
|
+
* A limit below one falls back to one, for the same reason {@link mapPooled}
|
|
8
|
+
* clamps: a caller passing `0` — or a negative, from arithmetic on a config
|
|
9
|
+
* value — would otherwise admit nobody, and the symptom is a build that hangs
|
|
10
|
+
* rather than one that fails.
|
|
11
|
+
*
|
|
12
|
+
* ⚠️ `Math.max(1, limit)` IS NOT ENOUGH, BECAUSE `Math.max(1, NaN)` IS `NaN`.
|
|
13
|
+
* With `max` set to `NaN`, `active >= max` is false forever and the semaphore
|
|
14
|
+
* admits everyone — no hang, no error, and no bound, which is the one outcome
|
|
15
|
+
* worse than either. Non-finite means one.
|
|
16
|
+
*/
|
|
17
|
+
function createSemaphore(limit) {
|
|
18
|
+
const requested = Math.floor(limit);
|
|
19
|
+
const max = Number.isFinite(requested) && requested >= 1 ? requested : 1;
|
|
20
|
+
const waiting = [];
|
|
21
|
+
let active = 0;
|
|
22
|
+
/**
|
|
23
|
+
* Hand the slot to the next waiter rather than releasing and re-taking it.
|
|
24
|
+
*
|
|
25
|
+
* The naive form — decrement here, let the woken waiter increment itself — is
|
|
26
|
+
* *equivalent*, and this was written that way first on the assumption that it
|
|
27
|
+
* was not. It is equivalent because the decrement and the wake happen in one
|
|
28
|
+
* synchronous step: no microtask can be interposed between them, and anything
|
|
29
|
+
* enqueued earlier runs before this function rather than inside it, so there
|
|
30
|
+
* is no window for a caller to read `active` below the limit. Mutation-tested
|
|
31
|
+
* — swapping in the naive form fails nothing, which is the honest result.
|
|
32
|
+
*
|
|
33
|
+
* It stays this way because that argument is about the scheduler rather than
|
|
34
|
+
* about this code. One `await` between the decrement and the wake and the
|
|
35
|
+
* bound is gone, with nothing able to observe it. Handing the slot over makes
|
|
36
|
+
* `active` a count of owned slots at every point, whoever holds them, so the
|
|
37
|
+
* invariant is local and needs no argument at all.
|
|
38
|
+
*/
|
|
39
|
+
const release = () => {
|
|
40
|
+
const next = waiting.shift();
|
|
41
|
+
if (next !== void 0) {
|
|
42
|
+
next();
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
active -= 1;
|
|
46
|
+
};
|
|
47
|
+
return { async run(fn) {
|
|
48
|
+
if (active >= max) await new Promise((resolve) => {
|
|
49
|
+
waiting.push(resolve);
|
|
50
|
+
});
|
|
51
|
+
else active += 1;
|
|
52
|
+
try {
|
|
53
|
+
return await fn();
|
|
54
|
+
} finally {
|
|
55
|
+
release();
|
|
56
|
+
}
|
|
57
|
+
} };
|
|
58
|
+
}
|
|
59
|
+
//#endregion
|
|
60
|
+
export { createSemaphore };
|
package/dist/source.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { docsError } from "./docs-error.js";
|
|
2
2
|
import { parseFrontmatter } from "./frontmatter.js";
|
|
3
3
|
import { orderNavEntries, readDocsMeta } from "./meta.js";
|
|
4
|
+
import { createSemaphore } from "./semaphore.js";
|
|
4
5
|
import { encodeSegments, toAliasRoute } from "./route-path.js";
|
|
5
6
|
import { readFile, readdir, realpath, stat } from "node:fs/promises";
|
|
6
7
|
import path from "node:path";
|
|
@@ -12,6 +13,48 @@ const PAGE_EXTENSION = ".md";
|
|
|
12
13
|
/** A directory whose `index.md` is the directory's own route. */
|
|
13
14
|
const INDEX_NAME = "index";
|
|
14
15
|
/**
|
|
16
|
+
* Filesystem calls in flight across the whole process, at most.
|
|
17
|
+
*
|
|
18
|
+
* ⚠️ THE SCAN USED TO OPEN EVERY MARKDOWN FILE AT ONCE. `scanDir` recursed into
|
|
19
|
+
* its subdirectories in parallel and read that directory's pages with a bare
|
|
20
|
+
* `Promise.all`, so the number of `readFile` calls in flight equalled the number
|
|
21
|
+
* of markdown files in the entire tree. On a 1,200-page corpus and the common
|
|
22
|
+
* 1,024-descriptor soft limit, `next build` died with a bare `EMFILE: too many
|
|
23
|
+
* open files` — no error code, no mention that this was the docs scan, and
|
|
24
|
+
* nothing pointing at the fix. Exactly the large content set this package is
|
|
25
|
+
* for, and the failure got worse as the site grew.
|
|
26
|
+
*
|
|
27
|
+
* 64 is far under every default soft limit — 1,024 on Linux and CI images, 256
|
|
28
|
+
* on an unconfigured macOS shell — so the descriptors stop scaling with the
|
|
29
|
+
* corpus while staying safe on the tightest of those.
|
|
30
|
+
*
|
|
31
|
+
* ⚠️ IT IS NOT FREE, AND THE COST IS NOT THE CONCURRENCY. Measured over 1,201
|
|
32
|
+
* pages in 49 directories, nine runs, medians: **88 ms ungated, 106 ms at a
|
|
33
|
+
* bound of 16, 102 ms at 64, 101 ms at 128 and at 256.** Flat from 64 upwards,
|
|
34
|
+
* which says the ~14 ms is the gate's own per-call overhead — about 3,600
|
|
35
|
+
* `run` closures — and not the reduced parallelism. libuv's filesystem pool is
|
|
36
|
+
* four threads by default, so there was never 1,201-way parallelism to lose.
|
|
37
|
+
*
|
|
38
|
+
* So the number is chosen for the tightest descriptor limit rather than for
|
|
39
|
+
* speed, because above 64 there is no speed left to buy. 14 ms sits against a
|
|
40
|
+
* build that highlights those same 1,201 pages with Shiki, which is three
|
|
41
|
+
* orders of magnitude more.
|
|
42
|
+
*/
|
|
43
|
+
const SCAN_CONCURRENCY = 64;
|
|
44
|
+
/**
|
|
45
|
+
* The one gate, for the whole process.
|
|
46
|
+
*
|
|
47
|
+
* Module scope rather than per-scan, because descriptors are a process resource:
|
|
48
|
+
* two routes scanning two content directories at once would each stay under a
|
|
49
|
+
* per-scan bound and together exceed the only one that matters.
|
|
50
|
+
*
|
|
51
|
+
* ⚠️ LEAF CALLS ONLY — NEVER THE RECURSION. Gating `scanDir` itself deadlocks as
|
|
52
|
+
* soon as every slot is held by a directory waiting for a slot to read its
|
|
53
|
+
* children. Bounding the `readFile`/`readdir`/`stat`/`realpath` calls bounds the
|
|
54
|
+
* descriptors exactly, and leaves the walk as parallel as it was.
|
|
55
|
+
*/
|
|
56
|
+
const fsGate = createSemaphore(SCAN_CONCURRENCY);
|
|
57
|
+
/**
|
|
15
58
|
* Apply {@link DocsConfig} defaults and resolve `contentDir` against
|
|
16
59
|
* `process.cwd()` — the project root under both `next build` and `vite build`.
|
|
17
60
|
*
|
|
@@ -85,7 +128,7 @@ function buildSource(config) {
|
|
|
85
128
|
const load = () => {
|
|
86
129
|
cached ??= scan(config).catch((err) => {
|
|
87
130
|
cached = null;
|
|
88
|
-
throw err;
|
|
131
|
+
throw describeScanFailure(err, config.contentDir);
|
|
89
132
|
});
|
|
90
133
|
return cached;
|
|
91
134
|
};
|
|
@@ -143,9 +186,32 @@ function buildSource(config) {
|
|
|
143
186
|
}
|
|
144
187
|
};
|
|
145
188
|
}
|
|
189
|
+
/**
|
|
190
|
+
* Say that a descriptor exhaustion happened here, and that it was not us.
|
|
191
|
+
*
|
|
192
|
+
* `EMFILE`/`ENFILE` arrive from Node as a bare `Error` with no hint of what was
|
|
193
|
+
* being read, and the message a reader used to get was
|
|
194
|
+
* `EMFILE: too many open files, open '<contentDir>/p829.md'` and nothing else.
|
|
195
|
+
* That is a filename out of a corpus of a thousand, from a stack of `dist/`
|
|
196
|
+
* frames, during a `next build` that mentions no package.
|
|
197
|
+
*
|
|
198
|
+
* The scan itself is bounded at {@link SCAN_CONCURRENCY} now, so reaching this
|
|
199
|
+
* means the limit is lower than that or something else in the process has the
|
|
200
|
+
* descriptors — which is the useful thing to be told, and the opposite of what a
|
|
201
|
+
* reader concludes from an error naming one of their own markdown files.
|
|
202
|
+
*
|
|
203
|
+
* Only these two codes, and only when the failure is not already ours: a
|
|
204
|
+
* `broken-symlink` or an `invalid-frontmatter` that came out of the scan is
|
|
205
|
+
* already a better message than anything this could add.
|
|
206
|
+
*/
|
|
207
|
+
function describeScanFailure(err, contentDir) {
|
|
208
|
+
const code = err?.code;
|
|
209
|
+
if (code !== "EMFILE" && code !== "ENFILE") return err;
|
|
210
|
+
return docsError("descriptor-limit", `the process ran out of file descriptors while scanning ${contentDir}. The scan holds at most ${String(SCAN_CONCURRENCY)} open at a time, so something else has them — raise the limit (\`ulimit -n\`, or \`LimitNOFILE\` under systemd) rather than shrinking the corpus. If it persists with a limit well above that, it is a bug in this package: https://github.com/wavedotso/wave-docs/issues`, { cause: err });
|
|
211
|
+
}
|
|
146
212
|
async function scan(config) {
|
|
147
213
|
await assertContentDir(config.contentDir);
|
|
148
|
-
const root = await scanDir(config.contentDir, [], "", config, /* @__PURE__ */ new Set([await realpath(config.contentDir)]));
|
|
214
|
+
const root = await scanDir(config.contentDir, [], "", config, /* @__PURE__ */ new Set([await fsGate.run(() => realpath(config.contentDir))]));
|
|
149
215
|
const files = [];
|
|
150
216
|
const bySlug = /* @__PURE__ */ new Map();
|
|
151
217
|
collect(root, files, bySlug);
|
|
@@ -157,13 +223,13 @@ async function scan(config) {
|
|
|
157
223
|
}
|
|
158
224
|
async function assertContentDir(contentDir) {
|
|
159
225
|
try {
|
|
160
|
-
if ((await stat(contentDir)).isDirectory()) return;
|
|
226
|
+
if ((await fsGate.run(() => stat(contentDir))).isDirectory()) return;
|
|
161
227
|
} catch {}
|
|
162
228
|
throw docsError("missing-content-dir", `Docs content directory not found: ${contentDir}\nSet \`contentDir\` to a directory of markdown files; relative paths resolve against the working directory (${process.cwd()}).`);
|
|
163
229
|
}
|
|
164
230
|
const SKIP = { kind: "skip" };
|
|
165
231
|
async function scanDir(absPath, segments, name, config, ancestors) {
|
|
166
|
-
const [meta, entries] = await Promise.all([readDocsMeta(absPath), readdir(absPath, { withFileTypes: true })]);
|
|
232
|
+
const [meta, entries] = await Promise.all([fsGate.run(() => readDocsMeta(absPath)), fsGate.run(() => readdir(absPath, { withFileTypes: true }))]);
|
|
167
233
|
const classified = await Promise.all(entries.map((entry) => ({
|
|
168
234
|
entry,
|
|
169
235
|
name: entry.name.normalize("NFC")
|
|
@@ -206,7 +272,7 @@ async function classifyEntry(dirPath, entry, name) {
|
|
|
206
272
|
if (!entry.isSymbolicLink()) return SKIP;
|
|
207
273
|
let target;
|
|
208
274
|
try {
|
|
209
|
-
target = await stat(absPath);
|
|
275
|
+
target = await fsGate.run(() => stat(absPath));
|
|
210
276
|
} catch {
|
|
211
277
|
if (!isPageFile(name)) return SKIP;
|
|
212
278
|
throw docsError("broken-symlink", `@waveso/docs: ${absPath} is a broken symbolic link, and it names a markdown page — so skipping it would delete a route nobody asked to delete. Point it at an existing file, or remove the link.`);
|
|
@@ -223,20 +289,23 @@ async function classifyEntry(dirPath, entry, name) {
|
|
|
223
289
|
kind: "dir",
|
|
224
290
|
absPath,
|
|
225
291
|
name,
|
|
226
|
-
realPath: await realpath(absPath)
|
|
292
|
+
realPath: await fsGate.run(() => realpath(absPath))
|
|
227
293
|
};
|
|
228
294
|
return SKIP;
|
|
229
295
|
}
|
|
230
296
|
async function readPage(filePath, name, dirSegments, config) {
|
|
231
|
-
const raw = await readFile(filePath, "utf8");
|
|
297
|
+
const raw = await fsGate.run(() => readFile(filePath, "utf8"));
|
|
232
298
|
const relativePath = toPosix(path.relative(config.contentDir, filePath));
|
|
233
299
|
let data;
|
|
234
300
|
let content;
|
|
301
|
+
let frontmatterLines = 0;
|
|
235
302
|
try {
|
|
236
|
-
const
|
|
303
|
+
const source = raw.charCodeAt(0) === 65279 ? raw.slice(1) : raw;
|
|
304
|
+
const file = new VFile({ value: source });
|
|
237
305
|
matter(file, { strip: true });
|
|
238
306
|
data = file.data.matter;
|
|
239
307
|
content = String(file);
|
|
308
|
+
if (source.endsWith(content)) frontmatterLines = source.slice(0, source.length - content.length).split("\n").length - 1;
|
|
240
309
|
} catch (err) {
|
|
241
310
|
const reason = err instanceof Error ? err.message : String(err);
|
|
242
311
|
throw docsError("invalid-frontmatter", `Could not parse the frontmatter block in ${relativePath}: ${reason}`, { cause: err });
|
|
@@ -253,7 +322,8 @@ async function readPage(filePath, name, dirSegments, config) {
|
|
|
253
322
|
filePath,
|
|
254
323
|
relativePath,
|
|
255
324
|
frontmatter,
|
|
256
|
-
content
|
|
325
|
+
content,
|
|
326
|
+
frontmatterLines
|
|
257
327
|
}
|
|
258
328
|
};
|
|
259
329
|
}
|
|
@@ -409,7 +479,7 @@ function toHref(basePath, segments) {
|
|
|
409
479
|
return `${basePath}/${encodeSegments(segments)}`;
|
|
410
480
|
}
|
|
411
481
|
function normalizeBasePath(basePath) {
|
|
412
|
-
const trimmed = basePath.trim().replace(/\/+$/, "");
|
|
482
|
+
const trimmed = basePath.trim().replace(/\/{2,}/g, "/").replace(/\/+$/, "");
|
|
413
483
|
if (trimmed === "") return "";
|
|
414
484
|
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
|
415
485
|
}
|
package/dist/styles.css
CHANGED
|
@@ -1844,6 +1844,19 @@
|
|
|
1844
1844
|
z-index: 50;
|
|
1845
1845
|
display: flex;
|
|
1846
1846
|
justify-content: center;
|
|
1847
|
+
/*
|
|
1848
|
+
* ⚠️ `flex-start`, OR THE DIALOG IS ALWAYS 32rem TALL. A flex container
|
|
1849
|
+
* defaults to `align-items: stretch`, so the dialog stretched to the full
|
|
1850
|
+
* viewport height and `max-height` then capped it — at a constant. Measured
|
|
1851
|
+
* before this line: 514px with no query, 514px with eight results, 514px
|
|
1852
|
+
* with none, of which 392px was an empty results area. The reader typed
|
|
1853
|
+
* into a box floating at the top of a large blank rectangle.
|
|
1854
|
+
*
|
|
1855
|
+
* Aligned to the start, the dialog is as tall as its content and the
|
|
1856
|
+
* `max-height` below goes back to being what it says it is — a ceiling
|
|
1857
|
+
* where the list starts scrolling, not a height.
|
|
1858
|
+
*/
|
|
1859
|
+
align-items: flex-start;
|
|
1847
1860
|
/* Not centred: a dialog that grows downward from a fixed top does not
|
|
1848
1861
|
* shift under the reader as results stream in. */
|
|
1849
1862
|
/* `dvh`, not `vh`: on a phone `vh` is the viewport with the URL bar
|
|
@@ -1930,12 +1943,29 @@
|
|
|
1930
1943
|
border-color: var(--wave-docs-border-strong);
|
|
1931
1944
|
}
|
|
1932
1945
|
|
|
1946
|
+
/*
|
|
1947
|
+
* `1 1 auto` and not `0 1 auto`: once the dialog hits its `max-height` this
|
|
1948
|
+
* is the part that must take the remaining space and scroll, rather than the
|
|
1949
|
+
* list overflowing a box sized to the input. With the dialog content-sized
|
|
1950
|
+
* there is no free space to grow into, so it only bites at the ceiling —
|
|
1951
|
+
* which is exactly when it should.
|
|
1952
|
+
*/
|
|
1933
1953
|
.wave-docs-search-results {
|
|
1934
1954
|
flex: 1 1 auto;
|
|
1935
1955
|
overflow-y: auto;
|
|
1936
1956
|
padding: 0.375rem;
|
|
1937
1957
|
}
|
|
1938
1958
|
|
|
1959
|
+
/*
|
|
1960
|
+
* An empty results container has no padding to contribute. Without this it
|
|
1961
|
+
* adds 12px of nothing under the input on every keystroke that matches
|
|
1962
|
+
* nothing — small, and precisely the sort of gap that reads as a broken
|
|
1963
|
+
* layout rather than as an empty state.
|
|
1964
|
+
*/
|
|
1965
|
+
.wave-docs-search-results:empty {
|
|
1966
|
+
display: none;
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1939
1969
|
.wave-docs-search-result {
|
|
1940
1970
|
border-radius: var(--wave-docs-radius-sm);
|
|
1941
1971
|
}
|
|
@@ -1969,20 +1999,39 @@
|
|
|
1969
1999
|
line-height: 1.4;
|
|
1970
2000
|
}
|
|
1971
2001
|
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
*
|
|
1980
|
-
|
|
2002
|
+
/*
|
|
2003
|
+
* Where the hit lands, as a route.
|
|
2004
|
+
*
|
|
2005
|
+
* ⚠️ RENAMED FROM `…-breadcrumb` / `…-crumb`, WHICH IS BREAKING AND
|
|
2006
|
+
* DELIBERATE. This used to be a trail of human names — "Installation ›
|
|
2007
|
+
* Requirements" — and a page's own record, whose heading IS its page title,
|
|
2008
|
+
* got no line at all rather than repeat itself. That left a list where some
|
|
2009
|
+
* rows had two lines and some had one.
|
|
2010
|
+
*
|
|
2011
|
+
* The first repair gave those rows the route and kept the trail on the
|
|
2012
|
+
* others, which was worse: one slot carrying two different kinds of thing,
|
|
2013
|
+
* so "Styling" under one row was a page and "/docs/styling" under the next
|
|
2014
|
+
* was an address. Now every row shows the route, always, and the class is
|
|
2015
|
+
* named for what it holds — a breadcrumb it is not.
|
|
2016
|
+
*
|
|
2017
|
+
* The words live in the option's `aria-label`, because a route read aloud is
|
|
2018
|
+
* punctuation.
|
|
2019
|
+
*/
|
|
2020
|
+
.wave-docs-search-result-location {
|
|
2021
|
+
display: block;
|
|
2022
|
+
/* One line, ellipsised. A real site's routes reach
|
|
2023
|
+
* `/docs/api/reference/authentication#rotating-keys`, and a wrapped route
|
|
2024
|
+
* makes the row two different heights depending on its depth — the raggedness
|
|
2025
|
+
* this whole change exists to remove. */
|
|
2026
|
+
overflow: hidden;
|
|
1981
2027
|
white-space: nowrap;
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
2028
|
+
text-overflow: ellipsis;
|
|
2029
|
+
color: var(--wave-docs-fg-subtle);
|
|
2030
|
+
font-family: var(--wave-docs-font-mono);
|
|
2031
|
+
/* Smaller than the trail was: a monospace face reads larger at the same
|
|
2032
|
+
* size, and this line is a reference rather than something to read. */
|
|
2033
|
+
font-size: 0.6875rem;
|
|
2034
|
+
line-height: 1.5;
|
|
1986
2035
|
}
|
|
1987
2036
|
|
|
1988
2037
|
.wave-docs-search-status {
|
package/dist/types.d.ts
CHANGED
|
@@ -67,6 +67,24 @@ interface DocFile<TFrontmatter extends DocFrontmatter = DocFrontmatter> {
|
|
|
67
67
|
frontmatter: TFrontmatter;
|
|
68
68
|
/** Markdown body with the frontmatter block removed. */
|
|
69
69
|
content: string;
|
|
70
|
+
/**
|
|
71
|
+
* How many lines the frontmatter block took up, so an error can name the line
|
|
72
|
+
* in the file rather than in the body.
|
|
73
|
+
*
|
|
74
|
+
* ⚠️ WITHOUT IT, EVERY LINK ERROR POINTED INTO THE FRONTMATTER. `content` has
|
|
75
|
+
* the block removed, so remark counts `node.position.start.line` from the
|
|
76
|
+
* first line of the *body* — and `broken-link`, `draft-link` and `alias-link`
|
|
77
|
+
* all report `relativePath:line`, the exact `file:line` form a terminal and an
|
|
78
|
+
* editor turn into a jump. A page with four frontmatter fields is six lines
|
|
79
|
+
* out: a link on line 10 was reported at line 4, which is the middle of the
|
|
80
|
+
* block that was deleted.
|
|
81
|
+
*
|
|
82
|
+
* Set by `@waveso/docs/source`. Optional and treated as `0` when absent,
|
|
83
|
+
* because a host loading content itself — the documented reason `./render` is
|
|
84
|
+
* an entry point — hands over a body with no block in front of it and has
|
|
85
|
+
* nothing to offset.
|
|
86
|
+
*/
|
|
87
|
+
frontmatterLines?: number | undefined;
|
|
70
88
|
}
|
|
71
89
|
/** A link to a documentation page. */
|
|
72
90
|
interface DocNavPage {
|
|
@@ -257,6 +275,18 @@ href: string, from: DocLinkContext) => string | undefined;
|
|
|
257
275
|
* blindly prefixes what it is handed produces `/cdn//logo.png` and
|
|
258
276
|
* `/cdn/https://example.com/a.png`. Branch on them, or return `undefined` to
|
|
259
277
|
* leave the src as authored.
|
|
278
|
+
*
|
|
279
|
+
* ⚠️ IT IS A FILE PATH, NOT AN HREF. `src` is percent-decoded, so
|
|
280
|
+
* `./getting%20started.png` — what GitHub's editor writes when you drag in a
|
|
281
|
+
* file whose name has a space — arrives as `getting started.png`, the name that
|
|
282
|
+
* is actually on disk. Any `?query` and `#fragment` are split off before the
|
|
283
|
+
* call and re-attached to whatever you return, so `./diagram.png?v=2` arrives
|
|
284
|
+
* as `diagram.png` and the reader still gets the `?v=2`. Return a src carrying
|
|
285
|
+
* a query or a fragment of your own and yours is kept instead, because two `?`
|
|
286
|
+
* in one URL is not a URL.
|
|
287
|
+
*
|
|
288
|
+
* All three used to be handed over raw, and `readFile(join(dir, src))` — the
|
|
289
|
+
* implementation the README gives — threw `ENOENT` on every one of them.
|
|
260
290
|
*/
|
|
261
291
|
type ImageResolver = (src: string, from: DocLinkContext) => Promise<{
|
|
262
292
|
src: string;
|
package/package.json
CHANGED