@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
package/dist/render.js
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
|
+
import { collectAnchorIds, splitAnchor } from "./anchors.js";
|
|
1
2
|
import { docsError } from "./docs-error.js";
|
|
2
3
|
import { DEFAULT_DOCS_THEMES, createDocsHighlighter } from "./highlighter.js";
|
|
4
|
+
import { describeSuggestion } from "./link-suggestion.js";
|
|
3
5
|
import { rehypeCaptureToc } from "./plugins/rehype-capture-toc.js";
|
|
4
6
|
import { rehypeCodeFrame } from "./plugins/rehype-code-frame.js";
|
|
5
7
|
import { rehypeNormalizeCodeLanguage, rehypeRestoreExcludedCode } from "./plugins/rehype-code-language.js";
|
|
6
8
|
import { rehypeFallbackHeadingIds } from "./plugins/rehype-fallback-heading-ids.js";
|
|
7
9
|
import { rehypeFlattenRoots } from "./plugins/rehype-flatten-roots.js";
|
|
8
|
-
import { foldSegments, remarkDocLinks, resolveMarkdownLink } from "./plugins/remark-doc-links.js";
|
|
10
|
+
import { foldSegments, remarkDocLinks, resolveMarkdownLink, splitHref } from "./plugins/remark-doc-links.js";
|
|
9
11
|
import { remarkUnwrapImages } from "./plugins/remark-unwrap-images.js";
|
|
10
12
|
import { remarkYouTube } from "./plugins/remark-youtube.js";
|
|
13
|
+
import { visit } from "unist-util-visit";
|
|
11
14
|
import rehypeShikiFromHighlighter from "@shikijs/rehype/core";
|
|
12
15
|
import { rehypeGithubAlerts } from "rehype-github-alerts";
|
|
13
16
|
import rehypeAutolinkHeadings from "rehype-autolink-headings";
|
|
@@ -16,7 +19,6 @@ import remarkGfm from "remark-gfm";
|
|
|
16
19
|
import remarkParse from "remark-parse";
|
|
17
20
|
import remarkRehype from "remark-rehype";
|
|
18
21
|
import { unified } from "unified";
|
|
19
|
-
import { visit } from "unist-util-visit";
|
|
20
22
|
import { VFile } from "vfile";
|
|
21
23
|
//#region src/render.ts
|
|
22
24
|
/**
|
|
@@ -96,11 +98,43 @@ function assertResolvedImage(value, src, relativePath) {
|
|
|
96
98
|
* author means by `` and what the link path has always done.
|
|
97
99
|
*/
|
|
98
100
|
function foldImageSrc(src, dirSegments) {
|
|
99
|
-
if (isPublicImageSrc(src)) return
|
|
100
|
-
|
|
101
|
-
|
|
101
|
+
if (isPublicImageSrc(src)) return {
|
|
102
|
+
path: src,
|
|
103
|
+
suffix: ""
|
|
104
|
+
};
|
|
105
|
+
const { path, query, hash } = splitHref(src);
|
|
106
|
+
if (path === "") return;
|
|
107
|
+
const segments = foldSegments(dirSegments, path);
|
|
108
|
+
return segments === void 0 ? void 0 : {
|
|
109
|
+
path: segments.join("/"),
|
|
110
|
+
suffix: `${query}${hash}`
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Put the authored `?query#hash` back, unless the resolver wrote its own.
|
|
115
|
+
*
|
|
116
|
+
* A resolver returning `/img/diagram.a1b2c3.png?w=800` has said something more
|
|
117
|
+
* specific than the author's `?v=2` — and concatenating the two would produce
|
|
118
|
+
* two `?` in one URL, which is not a URL. Same rule the rest of this package
|
|
119
|
+
* uses when a host and a default disagree: the more specific one wins.
|
|
120
|
+
*/
|
|
121
|
+
function withSuffix(src, suffix) {
|
|
122
|
+
if (suffix === "" || /[?#]/.test(src)) return src;
|
|
123
|
+
return `${src}${suffix}`;
|
|
102
124
|
}
|
|
103
125
|
/** Route without its `?query` / `#anchor`, for existence checks. */
|
|
126
|
+
/**
|
|
127
|
+
* Does this route belong to the host application rather than the docs?
|
|
128
|
+
*
|
|
129
|
+
* Prefix match on a segment boundary, so `/api` covers `/api/keys` and does not
|
|
130
|
+
* accidentally cover `/apiary`.
|
|
131
|
+
*/
|
|
132
|
+
function isExternalRoute(route, external) {
|
|
133
|
+
return external.some((prefix) => {
|
|
134
|
+
const trimmed = prefix.replace(/\/+$/, "");
|
|
135
|
+
return route === trimmed || route.startsWith(`${trimmed}/`);
|
|
136
|
+
});
|
|
137
|
+
}
|
|
104
138
|
function toRouteKey(href) {
|
|
105
139
|
const cut = href.search(/[?#]/);
|
|
106
140
|
return cut === -1 ? href : href.slice(0, cut);
|
|
@@ -238,7 +272,7 @@ async function buildProcessor(options, themes, highlighterPromise) {
|
|
|
238
272
|
ariaHidden: "true",
|
|
239
273
|
tabIndex: -1
|
|
240
274
|
}
|
|
241
|
-
}).use(options.rehypePlugins ?? []).use(rehypeNormalizeCodeLanguage, { ...options.excludeLangs === void 0 ? {} : { exclude: options.excludeLangs } }).use(rehypeCodeFrame).use(rehypeShikiFromHighlighter, highlighter, {
|
|
275
|
+
}).use(options.rehypePlugins ?? []).use(rehypeNormalizeCodeLanguage, { ...options.excludeLangs === void 0 ? {} : { exclude: options.excludeLangs } }).use(rehypeCodeFrame, options.codeLabels ?? {}).use(rehypeShikiFromHighlighter, highlighter, {
|
|
242
276
|
themes,
|
|
243
277
|
defaultColor: false,
|
|
244
278
|
fallbackLanguage: "text",
|
|
@@ -294,7 +328,13 @@ function createDocsRenderer(options) {
|
|
|
294
328
|
await Promise.all(images.map(async (node) => {
|
|
295
329
|
const src = node.properties.src;
|
|
296
330
|
if (typeof src !== "string" || src === "") return;
|
|
297
|
-
|
|
331
|
+
let folded;
|
|
332
|
+
try {
|
|
333
|
+
folded = foldImageSrc(src, context.dirSegments);
|
|
334
|
+
} catch (error) {
|
|
335
|
+
if (!(error instanceof URIError)) throw error;
|
|
336
|
+
throw docsError("invalid-image", `@waveso/docs: image "${src}" in ${file.relativePath} is not valid percent-encoding. Write %25 for a literal percent sign, or name the file as it is on disk.`, { cause: error });
|
|
337
|
+
}
|
|
298
338
|
if (folded === void 0) throw docsError("invalid-image", `@waveso/docs: image "${src}" in ${file.relativePath} climbs above the content root.`);
|
|
299
339
|
if (resolve === void 0) {
|
|
300
340
|
if (isPublicImageSrc(src)) return;
|
|
@@ -302,42 +342,87 @@ function createDocsRenderer(options) {
|
|
|
302
342
|
}
|
|
303
343
|
let resolved;
|
|
304
344
|
try {
|
|
305
|
-
resolved = await resolve(folded, context);
|
|
345
|
+
resolved = await resolve(folded.path, context);
|
|
306
346
|
} catch (error) {
|
|
307
347
|
throw docsError("invalid-image", `@waveso/docs: the imageResolver threw on image "${src}" in ${file.relativePath}.`, { cause: error });
|
|
308
348
|
}
|
|
309
349
|
if (resolved === void 0) {
|
|
310
|
-
|
|
311
|
-
|
|
350
|
+
if (isPublicImageSrc(src)) return;
|
|
351
|
+
throw docsError("invalid-image", `@waveso/docs: the imageResolver returned nothing for image "${src}" in ${file.relativePath}, which is relative to the markdown file — so nothing can serve it: the browser would resolve it against the page route, and the same markdown would request a different file from every page. Return a src for it, or move the image under \`public/\` and write an absolute one such as "/diagram.png".`);
|
|
312
352
|
}
|
|
313
353
|
assertResolvedImage(resolved, src, file.relativePath);
|
|
314
|
-
node.properties.src = resolved.src;
|
|
354
|
+
node.properties.src = withSuffix(resolved.src, folded.suffix);
|
|
315
355
|
if (resolved.width !== void 0) node.properties.width = resolved.width;
|
|
316
356
|
if (resolved.height !== void 0) node.properties.height = resolved.height;
|
|
317
357
|
}));
|
|
318
358
|
}
|
|
319
359
|
/**
|
|
320
|
-
*
|
|
360
|
+
* Act on a link problem at the severity the site chose.
|
|
361
|
+
*
|
|
362
|
+
* ⚠️ `'throw'` IS THE DEFAULT AND SHOULD STAY IT. The link was valid in the
|
|
363
|
+
* editor and on GitHub, so a warning in a build log is a warning nobody
|
|
364
|
+
* reads. The setting exists because a migration may knowingly run against an
|
|
365
|
+
* incomplete corpus for a while and the tool cannot know that — which is the
|
|
366
|
+
* same reason Docusaurus has one.
|
|
367
|
+
*/
|
|
368
|
+
function report(severity, code, message) {
|
|
369
|
+
if (severity === "ignore") return;
|
|
370
|
+
if (severity === "throw") throw docsError(code, message);
|
|
371
|
+
console.warn(docsError(code, message).message);
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* Anchors that point into the page being rendered.
|
|
321
375
|
*
|
|
322
|
-
*
|
|
323
|
-
*
|
|
376
|
+
* ⚠️ HERE, NOT IN THE CORPUS PASS, BECAUSE THIS IS WHERE THE LINE NUMBER IS.
|
|
377
|
+
* The recorded refs carry the source line and the tree still has its
|
|
378
|
+
* positions; both are gone from a returned `RenderedDoc`. A same-page anchor
|
|
379
|
+
* is also the one a writer produces most — every "see below" — so it is worth
|
|
380
|
+
* the better message.
|
|
381
|
+
*
|
|
382
|
+
* Cross-page anchors need the other page's ids and are checked by
|
|
383
|
+
* `renderAll`, which is the first point at which they exist.
|
|
324
384
|
*/
|
|
385
|
+
function assertOwnAnchors(file, tree, refs) {
|
|
386
|
+
if (config.onBrokenAnchors === "ignore") return;
|
|
387
|
+
let ids;
|
|
388
|
+
for (const ref of refs) {
|
|
389
|
+
const anchor = splitAnchor(ref.href ?? ref.raw);
|
|
390
|
+
if (anchor === void 0) continue;
|
|
391
|
+
if (anchor.route !== "" && anchor.route !== file.href) continue;
|
|
392
|
+
ids ??= collectAnchorIds(tree);
|
|
393
|
+
if (ids.has(anchor.fragment)) continue;
|
|
394
|
+
report(config.onBrokenAnchors, "broken-anchor", `@waveso/docs: ${describeLink(file, ref)} links to '${ref.raw}', and this page has no '#${anchor.fragment}'.${describeSuggestion(anchor.fragment, ids)} Heading ids come from the heading text, so renaming a heading renames its anchor.`);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
/** Check every link a page recorded, and act at the configured severity. */
|
|
325
398
|
function assertLinks(file, refs) {
|
|
326
399
|
for (const ref of refs) {
|
|
327
|
-
if (ref.
|
|
328
|
-
if (
|
|
400
|
+
if (ref.anchorOnly) continue;
|
|
401
|
+
if (ref.href === void 0) {
|
|
402
|
+
report(config.onBrokenLinks, "broken-link", `@waveso/docs: ${describeLink(file, ref)} links to '${ref.raw}', which does not resolve to a documentation page. Use a path relative to this file, or an absolute URL for external links.`);
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
if (knownRoutes === void 0 || ref.asset || ref.anchorOnly) continue;
|
|
329
406
|
const route = toRouteKey(ref.href);
|
|
407
|
+
if (isExternalRoute(route, config.externalRoutes)) continue;
|
|
330
408
|
if (knownRoutes.has(route)) continue;
|
|
331
|
-
if (draftRoutes?.has(route))
|
|
409
|
+
if (draftRoutes?.has(route)) {
|
|
410
|
+
report(config.onBrokenLinks, "draft-link", `@waveso/docs: ${describeLink(file, ref)} links to '${ref.raw}', which resolves to '${route}' — a page marked \`draft: true\`, so it is not published and the link would 404. Publish the page, remove the link, or build with \`includeDrafts\`.`);
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
332
413
|
const aliasTarget = aliasRoutes?.get(route);
|
|
333
|
-
if (aliasTarget !== void 0)
|
|
334
|
-
|
|
414
|
+
if (aliasTarget !== void 0) {
|
|
415
|
+
report(config.onBrokenLinks, "alias-link", `@waveso/docs: ${describeLink(file, ref)} links to '${ref.raw}', which resolves to '${route}' — an alias that redirects to '${aliasTarget}'. An alias is not a page: it 404s unless \`createDocsRedirects\` is wired into \`next.config.ts\`, and it is never prerendered. Link to '${aliasTarget}' directly.`);
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
report(config.onBrokenLinks, "broken-link", `@waveso/docs: ${describeLink(file, ref)} links to '${ref.raw}', which resolves to '${route}' — no such page exists.${describeSuggestion(route, knownRoutes)} Fix the link, or add an \`aliases\` entry to the page it used to point at.`);
|
|
335
419
|
}
|
|
336
420
|
}
|
|
337
421
|
return { async render(file) {
|
|
338
422
|
const processor = await processorPromise;
|
|
423
|
+
const lead = "\n".repeat(file.frontmatterLines ?? 0);
|
|
339
424
|
const vfile = new VFile({
|
|
340
|
-
value: file.content,
|
|
425
|
+
value: lead + file.content,
|
|
341
426
|
path: file.filePath
|
|
342
427
|
});
|
|
343
428
|
vfile.data.docLinkContext = {
|
|
@@ -348,7 +433,8 @@ function createDocsRenderer(options) {
|
|
|
348
433
|
const hast = await processor.run(processor.parse(vfile), vfile);
|
|
349
434
|
if (titleHeading && !hasHeadingOne(hast)) hast.children.unshift(titleHeadingNode(file.frontmatter.title));
|
|
350
435
|
await resolveImages(hast, file, imageResolver);
|
|
351
|
-
|
|
436
|
+
assertLinks(file, vfile.data.docLinks ?? []);
|
|
437
|
+
assertOwnAnchors(file, hast, vfile.data.docLinks ?? []);
|
|
352
438
|
return {
|
|
353
439
|
frontmatter: file.frontmatter,
|
|
354
440
|
hast: stripPositions(hast),
|
package/dist/route-path.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { docsError } from "./docs-error.js";
|
|
2
|
-
import { foldSegments } from "./plugins/remark-doc-links.js";
|
|
2
|
+
import { decodePath, foldSegments } from "./plugins/remark-doc-links.js";
|
|
3
3
|
//#region src/route-path.ts
|
|
4
4
|
/**
|
|
5
5
|
* Turning route segments into a URL path.
|
|
@@ -39,7 +39,12 @@ const ALIAS_PATTERN_CHARS = /[:()+*?{}]/;
|
|
|
39
39
|
* every rejection below names the markdown file at the moment it is read.
|
|
40
40
|
*/
|
|
41
41
|
function toAliasRoute(alias, basePath, sourceLabel) {
|
|
42
|
-
|
|
42
|
+
let trimmed;
|
|
43
|
+
try {
|
|
44
|
+
trimmed = decodePath(alias.trim(), alias);
|
|
45
|
+
} catch (error) {
|
|
46
|
+
throw docsError("invalid-alias", `@waveso/docs: the alias '${alias}' in ${sourceLabel} is not valid percent-encoding. Write %25 for a literal percent sign, or write the former URL as its readable form.`, { cause: error });
|
|
47
|
+
}
|
|
43
48
|
if (trimmed.split("/").some((part) => part === "." || part === "..")) throw docsError("invalid-alias", `@waveso/docs: the alias '${alias}' in ${sourceLabel} has a '.' or '..' segment. An alias is a former URL relative to the docs base path, not a path on disk: write \`aliases: [legacy/old-name]\`.`);
|
|
44
49
|
const pattern = ALIAS_PATTERN_CHARS.exec(trimmed);
|
|
45
50
|
if (pattern !== null) throw docsError("invalid-alias", `@waveso/docs: the alias '${alias}' in ${sourceLabel} contains '${pattern[0]}', which Next compiles as redirect pattern syntax rather than as part of the URL — the redirect then swallows every page whose route the pattern happens to match, or fails the build. Remove the character; an alias is a literal former URL.`);
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
//#region src/safe-href.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* The one allowlist of schemes this package will put in an `href`.
|
|
4
|
+
*
|
|
5
|
+
* Private — deliberately not an entry point.
|
|
6
|
+
*
|
|
7
|
+
* ⚠️ IT WAS THE MARKDOWN PATH'S ALONE, AND `meta.json` WENT ROUND IT. A hand
|
|
8
|
+
* written nav entry — `{ "title": "Status", "href": "javascript:…" }` — reached
|
|
9
|
+
* `<a href>` through `DocsSidebar` with nothing checking it, while the markdown
|
|
10
|
+
* beside it was filtered by a comment calling the check load-bearing. Both paths
|
|
11
|
+
* end at the same anchor, so both need the same rule, and a rule with two copies
|
|
12
|
+
* is a rule with one that is out of date.
|
|
13
|
+
*
|
|
14
|
+
* Node-safe and browser-safe: two regular expressions and two functions, no
|
|
15
|
+
* imports at all.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* Strip the characters a browser ignores inside a URL.
|
|
19
|
+
*
|
|
20
|
+
* `java\nscript:` and `java	script:` are `javascript:` to a parser and not
|
|
21
|
+
* to a naive regular expression, so the test has to run on the string the
|
|
22
|
+
* browser will see rather than on the one that was written.
|
|
23
|
+
*/
|
|
24
|
+
declare function normaliseUrl(href: string): string;
|
|
25
|
+
/**
|
|
26
|
+
* Would this href navigate somewhere we are willing to send a reader?
|
|
27
|
+
*
|
|
28
|
+
* Nothing upstream filters it on the markdown side: `remarkDocLinks` skips every
|
|
29
|
+
* href with a scheme, so `assertLinks` never sees one either, and `remarkRehype`
|
|
30
|
+
* runs with `allowDangerousHtml` off but passes a link's own url through
|
|
31
|
+
* untouched. Verified against React 19: it neutralises `javascript:` in every
|
|
32
|
+
* obfuscated form, silently — but it lets `vbscript:` and
|
|
33
|
+
* `data:text/html;base64,…` reach the DOM verbatim. So the allowlist is ours.
|
|
34
|
+
*/
|
|
35
|
+
declare function isSafeHref(href: string): boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Does following this href leave the site in a new tab?
|
|
38
|
+
*
|
|
39
|
+
* ⚠️ NOT "HAS A SCHEME". `meta.json` used that test, so a `mailto:` sidebar
|
|
40
|
+
* entry was given `target="_blank"` and announced as "(opens in a new tab)" — a
|
|
41
|
+
* tab that never opens, described to precisely the reader who cannot see that it
|
|
42
|
+
* did not. Only http(s) and protocol-relative navigate; `mailto:` and `tel:`
|
|
43
|
+
* hand off to the OS and leave the page where it is.
|
|
44
|
+
*/
|
|
45
|
+
declare function opensInNewTab(href: string): boolean;
|
|
46
|
+
//#endregion
|
|
47
|
+
export { isSafeHref, normaliseUrl, opensInNewTab };
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
//#region src/safe-href.ts
|
|
2
|
+
/**
|
|
3
|
+
* The one allowlist of schemes this package will put in an `href`.
|
|
4
|
+
*
|
|
5
|
+
* Private — deliberately not an entry point.
|
|
6
|
+
*
|
|
7
|
+
* ⚠️ IT WAS THE MARKDOWN PATH'S ALONE, AND `meta.json` WENT ROUND IT. A hand
|
|
8
|
+
* written nav entry — `{ "title": "Status", "href": "javascript:…" }` — reached
|
|
9
|
+
* `<a href>` through `DocsSidebar` with nothing checking it, while the markdown
|
|
10
|
+
* beside it was filtered by a comment calling the check load-bearing. Both paths
|
|
11
|
+
* end at the same anchor, so both need the same rule, and a rule with two copies
|
|
12
|
+
* is a rule with one that is out of date.
|
|
13
|
+
*
|
|
14
|
+
* Node-safe and browser-safe: two regular expressions and two functions, no
|
|
15
|
+
* imports at all.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* Any URL with a scheme, or protocol-relative.
|
|
19
|
+
*/
|
|
20
|
+
const ABSOLUTE_URL = /^([a-z][a-z0-9+.-]*:|\/\/)/i;
|
|
21
|
+
/**
|
|
22
|
+
* The schemes a link may carry.
|
|
23
|
+
*
|
|
24
|
+
* GitHub's own allowlist, which is the bar to match: documentation links to
|
|
25
|
+
* `sms:`, `ftp:` and `irc:` are ordinary, and an allowlist of three silently
|
|
26
|
+
* deleted them. The point of the check is to stop `javascript:`, `data:` and
|
|
27
|
+
* `vbscript:` reaching an `href`, not to have an opinion about protocols.
|
|
28
|
+
*
|
|
29
|
+
* A scheme not listed here — `vscode:`, `obsidian:`, `slack:` — is refused
|
|
30
|
+
* rather than rendered. That is deliberate: an allowlist that grows on request
|
|
31
|
+
* is safe, one that guesses is not.
|
|
32
|
+
*/
|
|
33
|
+
const SAFE_SCHEME = /^(https?|mailto|tel|sms|ftp|ftps|irc|ircs|xmpp|news|nntp|feed|git|matrix):/i;
|
|
34
|
+
/**
|
|
35
|
+
* Strip the characters a browser ignores inside a URL.
|
|
36
|
+
*
|
|
37
|
+
* `java\nscript:` and `java	script:` are `javascript:` to a parser and not
|
|
38
|
+
* to a naive regular expression, so the test has to run on the string the
|
|
39
|
+
* browser will see rather than on the one that was written.
|
|
40
|
+
*/
|
|
41
|
+
function normaliseUrl(href) {
|
|
42
|
+
return [...href].filter((char) => (char.codePointAt(0) ?? 0) > 32).join("");
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Would this href navigate somewhere we are willing to send a reader?
|
|
46
|
+
*
|
|
47
|
+
* Nothing upstream filters it on the markdown side: `remarkDocLinks` skips every
|
|
48
|
+
* href with a scheme, so `assertLinks` never sees one either, and `remarkRehype`
|
|
49
|
+
* runs with `allowDangerousHtml` off but passes a link's own url through
|
|
50
|
+
* untouched. Verified against React 19: it neutralises `javascript:` in every
|
|
51
|
+
* obfuscated form, silently — but it lets `vbscript:` and
|
|
52
|
+
* `data:text/html;base64,…` reach the DOM verbatim. So the allowlist is ours.
|
|
53
|
+
*/
|
|
54
|
+
function isSafeHref(href) {
|
|
55
|
+
const normalised = normaliseUrl(href);
|
|
56
|
+
if (!ABSOLUTE_URL.test(normalised)) return true;
|
|
57
|
+
return normalised.startsWith("//") || SAFE_SCHEME.test(normalised);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Does following this href leave the site in a new tab?
|
|
61
|
+
*
|
|
62
|
+
* ⚠️ NOT "HAS A SCHEME". `meta.json` used that test, so a `mailto:` sidebar
|
|
63
|
+
* entry was given `target="_blank"` and announced as "(opens in a new tab)" — a
|
|
64
|
+
* tab that never opens, described to precisely the reader who cannot see that it
|
|
65
|
+
* did not. Only http(s) and protocol-relative navigate; `mailto:` and `tel:`
|
|
66
|
+
* hand off to the OS and leave the page where it is.
|
|
67
|
+
*/
|
|
68
|
+
function opensInNewTab(href) {
|
|
69
|
+
const normalised = normaliseUrl(href);
|
|
70
|
+
return /^https?:\/\//i.test(normalised) || normalised.startsWith("//");
|
|
71
|
+
}
|
|
72
|
+
//#endregion
|
|
73
|
+
export { isSafeHref, normaliseUrl, opensInNewTab };
|
package/dist/search-index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { isFootnotes, isTransparentContainer } from "./section-boundary.js";
|
|
2
1
|
import { mergeSearchOptions } from "./search-options.js";
|
|
2
|
+
import { isFootnotes, isTransparentContainer } from "./section-boundary.js";
|
|
3
3
|
import MiniSearch from "minisearch";
|
|
4
4
|
//#region src/search-index.ts
|
|
5
5
|
/** `<h1>`…`<h6>` to their numeric depth. */
|
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 };
|