@waveso/docs 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +194 -0
- package/README.md +592 -88
- package/dist/code-frame.d.ts +29 -0
- package/dist/code-frame.js +41 -0
- package/dist/code-meta.d.ts +48 -0
- package/dist/code-meta.js +72 -0
- package/dist/docs-content-id.d.ts +19 -0
- package/dist/docs-content-id.js +19 -0
- package/dist/docs-error.d.ts +19 -0
- package/dist/docs-error.js +28 -0
- package/dist/errors.d.ts +94 -0
- package/dist/errors.js +45 -0
- package/dist/frontmatter.d.ts +39 -7
- package/dist/frontmatter.js +51 -24
- package/dist/highlighter.d.ts +2 -2
- package/dist/highlighter.js +3 -2
- package/dist/map-pooled.d.ts +26 -0
- package/dist/map-pooled.js +45 -0
- package/dist/meta.d.ts +7 -3
- package/dist/meta.js +61 -15
- package/dist/next.d.ts +182 -35
- package/dist/next.js +177 -49
- package/dist/plugins/rehype-capture-toc.js +52 -20
- package/dist/plugins/rehype-code-frame.d.ts +10 -0
- package/dist/plugins/rehype-code-frame.js +88 -0
- package/dist/plugins/rehype-code-language.d.ts +24 -0
- package/dist/plugins/rehype-code-language.js +54 -0
- package/dist/plugins/rehype-fallback-heading-ids.d.ts +6 -0
- package/dist/plugins/rehype-fallback-heading-ids.js +51 -0
- package/dist/plugins/rehype-flatten-roots.d.ts +7 -0
- package/dist/plugins/rehype-flatten-roots.js +39 -0
- package/dist/plugins/remark-doc-links.d.ts +12 -1
- package/dist/plugins/remark-doc-links.js +147 -20
- package/dist/react/code-runtime.d.ts +14 -0
- package/dist/react/code-runtime.js +161 -0
- package/dist/react/doc-content.d.ts +39 -2
- package/dist/react/doc-content.js +42 -10
- package/dist/react/layout.d.ts +44 -0
- package/dist/react/layout.js +65 -0
- package/dist/react/markdown-components.js +71 -6
- package/dist/react/nav.d.ts +28 -0
- package/dist/react/nav.js +70 -0
- package/dist/react/nearest-scroll-top.d.ts +45 -0
- package/dist/react/nearest-scroll-top.js +44 -0
- package/dist/react/next-link.d.ts +34 -0
- package/dist/react/next-link.js +30 -0
- package/dist/react/next-nav.d.ts +11 -0
- package/dist/react/next-nav.js +32 -0
- package/dist/react/next-search.d.ts +22 -0
- package/dist/react/next-search.js +52 -0
- package/dist/react/search-dialog.d.ts +35 -7
- package/dist/react/search-dialog.js +55 -33
- package/dist/react/shell-labels.d.ts +43 -0
- package/dist/react/shell-labels.js +27 -0
- package/dist/react/sidebar.d.ts +38 -3
- package/dist/react/sidebar.js +104 -12
- package/dist/react/skip-link.d.ts +1 -9
- package/dist/react/skip-link.js +6 -5
- package/dist/react/toc.d.ts +12 -4
- package/dist/react/toc.js +46 -12
- package/dist/react/youtube.d.ts +31 -5
- package/dist/react/youtube.js +76 -52
- package/dist/render.d.ts +78 -10
- package/dist/render.js +137 -54
- package/dist/route-path.d.ts +46 -0
- package/dist/route-path.js +51 -0
- package/dist/search-index.d.ts +22 -21
- package/dist/search-index.js +27 -78
- package/dist/search-options.d.ts +32 -1
- package/dist/search-options.js +66 -3
- package/dist/section-boundary.d.ts +17 -0
- package/dist/section-boundary.js +43 -0
- package/dist/sitemap-limit.d.ts +34 -0
- package/dist/sitemap-limit.js +37 -0
- package/dist/source.d.ts +12 -22
- package/dist/source.js +165 -72
- package/dist/styles.css +1117 -125
- package/dist/types.d.ts +52 -29
- package/package.json +70 -34
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
//#region src/route-path.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Turning route segments into a URL path.
|
|
4
|
+
*
|
|
5
|
+
* Private — deliberately not an entry point in `package.json`. `toAliasRoute`
|
|
6
|
+
* was exported from `./source` and therefore public, which froze both its
|
|
7
|
+
* signature and the wording of three error messages under semver, for a
|
|
8
|
+
* function no README mentions and only this package calls. It lives here with
|
|
9
|
+
* `encodeSegments` because the two have to agree: an alias and a link that
|
|
10
|
+
* spell the same page differently produce a redirect no request can match.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* Percent-encode the segments, and only here.
|
|
14
|
+
*
|
|
15
|
+
* `segments` and `slug` stay raw on purpose: Next decodes route params before
|
|
16
|
+
* they reach `find()`, so an encoded slug would match nothing. Unencoded, a
|
|
17
|
+
* `#`, `?` or `%` in a filename stops being part of the path — the sitemap
|
|
18
|
+
* emitted `https://example.com/docs/c#%20guide`, and `alternates.canonical`
|
|
19
|
+
* and `og:url` are built by the same call — while a space produced a URL that
|
|
20
|
+
* only works until something re-encodes it.
|
|
21
|
+
*/
|
|
22
|
+
declare function encodeSegments(segments: readonly string[]): string;
|
|
23
|
+
/**
|
|
24
|
+
* A former URL from `aliases` frontmatter, as a route.
|
|
25
|
+
*
|
|
26
|
+
* `'quickstart'` on a site mounted at `/docs` becomes `/docs/quickstart`.
|
|
27
|
+
* Leading and trailing slashes are tolerated because authors write them, but
|
|
28
|
+
* the value is always relative to the base path — an alias of `'/docs/old'` on
|
|
29
|
+
* a `/docs` site would produce `/docs/docs/old`.
|
|
30
|
+
*
|
|
31
|
+
* Shared by both adapters so they agree on which routes exist: an alias is a
|
|
32
|
+
* redirect the host installs, so a link to one resolves, and a link that
|
|
33
|
+
* builds under Next must build under Vite. The source scan calls it too, so
|
|
34
|
+
* every rejection below names the markdown file at the moment it is read.
|
|
35
|
+
*/
|
|
36
|
+
declare function toAliasRoute(alias: string, basePath: string,
|
|
37
|
+
/**
|
|
38
|
+
* The source path, for the error. A STRING rather than the whole `DocFile`
|
|
39
|
+
* it used to take: this function dereferenced exactly one property of it, and
|
|
40
|
+
* demanding the object meant a cache reader or a manifest-driven redirect
|
|
41
|
+
* table had to fabricate a `DocFile` to agree with the package about which
|
|
42
|
+
* routes exist. That is the reason it is exported at all.
|
|
43
|
+
*/
|
|
44
|
+
sourceLabel: string): string;
|
|
45
|
+
//#endregion
|
|
46
|
+
export { encodeSegments, toAliasRoute };
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { docsError } from "./docs-error.js";
|
|
2
|
+
import { foldSegments } from "./plugins/remark-doc-links.js";
|
|
3
|
+
//#region src/route-path.ts
|
|
4
|
+
/**
|
|
5
|
+
* Turning route segments into a URL path.
|
|
6
|
+
*
|
|
7
|
+
* Private — deliberately not an entry point in `package.json`. `toAliasRoute`
|
|
8
|
+
* was exported from `./source` and therefore public, which froze both its
|
|
9
|
+
* signature and the wording of three error messages under semver, for a
|
|
10
|
+
* function no README mentions and only this package calls. It lives here with
|
|
11
|
+
* `encodeSegments` because the two have to agree: an alias and a link that
|
|
12
|
+
* spell the same page differently produce a redirect no request can match.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Percent-encode the segments, and only here.
|
|
16
|
+
*
|
|
17
|
+
* `segments` and `slug` stay raw on purpose: Next decodes route params before
|
|
18
|
+
* they reach `find()`, so an encoded slug would match nothing. Unencoded, a
|
|
19
|
+
* `#`, `?` or `%` in a filename stops being part of the path — the sitemap
|
|
20
|
+
* emitted `https://example.com/docs/c#%20guide`, and `alternates.canonical`
|
|
21
|
+
* and `og:url` are built by the same call — while a space produced a URL that
|
|
22
|
+
* only works until something re-encodes it.
|
|
23
|
+
*/
|
|
24
|
+
function encodeSegments(segments) {
|
|
25
|
+
return segments.map(encodeURIComponent).join("/");
|
|
26
|
+
}
|
|
27
|
+
const ALIAS_PATTERN_CHARS = /[:()+*?{}]/;
|
|
28
|
+
/**
|
|
29
|
+
* A former URL from `aliases` frontmatter, as a route.
|
|
30
|
+
*
|
|
31
|
+
* `'quickstart'` on a site mounted at `/docs` becomes `/docs/quickstart`.
|
|
32
|
+
* Leading and trailing slashes are tolerated because authors write them, but
|
|
33
|
+
* the value is always relative to the base path — an alias of `'/docs/old'` on
|
|
34
|
+
* a `/docs` site would produce `/docs/docs/old`.
|
|
35
|
+
*
|
|
36
|
+
* Shared by both adapters so they agree on which routes exist: an alias is a
|
|
37
|
+
* redirect the host installs, so a link to one resolves, and a link that
|
|
38
|
+
* builds under Next must build under Vite. The source scan calls it too, so
|
|
39
|
+
* every rejection below names the markdown file at the moment it is read.
|
|
40
|
+
*/
|
|
41
|
+
function toAliasRoute(alias, basePath, sourceLabel) {
|
|
42
|
+
const trimmed = alias.trim();
|
|
43
|
+
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
|
+
const pattern = ALIAS_PATTERN_CHARS.exec(trimmed);
|
|
45
|
+
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.`);
|
|
46
|
+
const segments = foldSegments([], trimmed);
|
|
47
|
+
if (segments === void 0 || segments.length === 0) throw docsError("invalid-alias", `@waveso/docs: ${sourceLabel} has an empty entry in its \`aliases\` frontmatter. Each alias is a former URL for this page, relative to the docs base path — e.g. \`aliases: [quickstart]\`.`);
|
|
48
|
+
return `${basePath}/${encodeSegments(segments)}`;
|
|
49
|
+
}
|
|
50
|
+
//#endregion
|
|
51
|
+
export { encodeSegments, toAliasRoute };
|
package/dist/search-index.d.ts
CHANGED
|
@@ -1,14 +1,6 @@
|
|
|
1
1
|
import { RenderedDoc, SearchRecord } from "./types.js";
|
|
2
|
+
import { Options } from "minisearch";
|
|
2
3
|
//#region src/search-index.d.ts
|
|
3
|
-
/** Options for {@link extractSearchRecords}. */
|
|
4
|
-
interface ExtractSearchRecordsOptions {
|
|
5
|
-
/**
|
|
6
|
-
* Maximum length of {@link SearchRecord.text}, in characters. Defaults to
|
|
7
|
-
* 300 — long enough to carry a section's vocabulary into the index, short
|
|
8
|
-
* enough that a 300-page corpus stays under a megabyte.
|
|
9
|
-
*/
|
|
10
|
-
excerptLength?: number;
|
|
11
|
-
}
|
|
12
4
|
/**
|
|
13
5
|
* Split a rendered document into section-scoped {@link SearchRecord}s.
|
|
14
6
|
*
|
|
@@ -26,26 +18,35 @@ interface ExtractSearchRecordsOptions {
|
|
|
26
18
|
* does with the same heading — the two must not disagree about which sections
|
|
27
19
|
* exist.
|
|
28
20
|
*
|
|
21
|
+
* `text` is the section's PROSE IN FULL. It used to be cut to 300 characters
|
|
22
|
+
* before indexing, which on a normal corpus (200 pages × 6 sections, ~1,686
|
|
23
|
+
* characters of prose each) dropped 82% of the words from the index — and
|
|
24
|
+
* `combineWith: 'AND'` compounds it, since every term of a query then has to
|
|
25
|
+
* land inside the surviving prefix of the same section. The cap bought nothing
|
|
26
|
+
* back: `storeFields` does not carry `text`, so not one character of the kept
|
|
27
|
+
* prefix was ever rendered. Truncate for display, in the layer that displays.
|
|
28
|
+
*
|
|
29
29
|
* Not generic over the frontmatter type, deliberately: `frontmatter.title` is
|
|
30
30
|
* the only field read, and a `RenderedDoc` carrying a project's own fields is
|
|
31
31
|
* assignable to this signature already. A type parameter here would appear in
|
|
32
32
|
* every call site and constrain nothing.
|
|
33
33
|
*/
|
|
34
|
-
declare function extractSearchRecords(doc: RenderedDoc
|
|
34
|
+
declare function extractSearchRecords(doc: RenderedDoc): SearchRecord[];
|
|
35
35
|
/**
|
|
36
36
|
* Build a serialised MiniSearch index from extracted records.
|
|
37
37
|
*
|
|
38
|
-
* The return value is JSON, ready for `MiniSearch.loadJSON` on the client
|
|
39
|
-
*
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
* Write the serialised index to `outFile`, creating parent directories.
|
|
38
|
+
* The return value is JSON, ready for `MiniSearch.loadJSON` on the client.
|
|
39
|
+
* `docs.searchIndex` serves exactly this; reach for `buildSearchIndex`
|
|
40
|
+
* directly only when you need an artifact that route cannot produce. The
|
|
41
|
+
* output is byte-stable for a given record list, which is what lets the
|
|
42
|
+
* route ship a strong `ETag`.
|
|
44
43
|
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
44
|
+
* ⚠️ `options` MUST ALSO REACH THE DIALOG — pass the identical object to
|
|
45
|
+
* `SearchDialog`'s `searchOptions`. Both sides feed it through
|
|
46
|
+
* `mergeSearchOptions`, and a `tokenize` or `processTerm` applied to the
|
|
47
|
+
* documents but not to the query produces an index whose terms no query can
|
|
48
|
+
* spell: zero results, no error, nothing in the console.
|
|
48
49
|
*/
|
|
49
|
-
declare function
|
|
50
|
+
declare function buildSearchIndex(records: SearchRecord[], options?: Partial<Options<SearchRecord>>): string;
|
|
50
51
|
//#endregion
|
|
51
|
-
export {
|
|
52
|
+
export { buildSearchIndex, extractSearchRecords };
|
package/dist/search-index.js
CHANGED
|
@@ -1,23 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import path from "node:path";
|
|
1
|
+
import { isFootnotes, isTransparentContainer } from "./section-boundary.js";
|
|
2
|
+
import { mergeSearchOptions } from "./search-options.js";
|
|
4
3
|
import MiniSearch from "minisearch";
|
|
5
4
|
//#region src/search-index.ts
|
|
6
|
-
/**
|
|
7
|
-
* Build-time search index construction.
|
|
8
|
-
*
|
|
9
|
-
* Node-only, and deliberately so: the markdown parser, the hast walk and
|
|
10
|
-
* MiniSearch's index builder all run once per build, and the browser receives
|
|
11
|
-
* nothing but the serialised result. `src/react/search-dialog.tsx` is the
|
|
12
|
-
* matching client half.
|
|
13
|
-
*
|
|
14
|
-
* MiniSearch over Fuse.js is a measured choice, not a taste one: on a 282-page
|
|
15
|
-
* corpus Fuse ran 96.6 ms median / 298 ms max per query against MiniSearch's
|
|
16
|
-
* 1.35 ms / 3.84 ms. Fuse is a fuzzy short-string matcher routinely
|
|
17
|
-
* misapplied to full text.
|
|
18
|
-
*/
|
|
19
|
-
/** Default excerpt length, in characters, of {@link SearchRecord.text}. */
|
|
20
|
-
const DEFAULT_EXCERPT_LENGTH = 300;
|
|
21
5
|
/** `<h1>`…`<h6>` to their numeric depth. */
|
|
22
6
|
const HEADING_DEPTHS = /* @__PURE__ */ new Map([
|
|
23
7
|
["h1", 1],
|
|
@@ -43,32 +27,6 @@ const SKIPPED_TAGS = /* @__PURE__ */ new Set([
|
|
|
43
27
|
"svg",
|
|
44
28
|
"template"
|
|
45
29
|
]);
|
|
46
|
-
/**
|
|
47
|
-
* Containers that merely wrap content rather than nesting it semantically.
|
|
48
|
-
*
|
|
49
|
-
* Walking through them keeps heading detection working when a rehype plugin
|
|
50
|
-
* (or a consumer's own) wraps the document body, without descending into
|
|
51
|
-
* blockquotes or list items where a heading is not a section boundary.
|
|
52
|
-
*/
|
|
53
|
-
const TRANSPARENT_TAGS = /* @__PURE__ */ new Set([
|
|
54
|
-
"div",
|
|
55
|
-
"section",
|
|
56
|
-
"article",
|
|
57
|
-
"main"
|
|
58
|
-
]);
|
|
59
|
-
/**
|
|
60
|
-
* The GFM footnote block `mdast-util-to-hast` appends, and everything in it.
|
|
61
|
-
*
|
|
62
|
-
* It is a `section` — so {@link TRANSPARENT_TAGS} would otherwise walk straight
|
|
63
|
-
* into it — carrying a generated `<h2 id="footnote-label">Footnotes</h2>`. That
|
|
64
|
-
* heading is machinery, not a section of the page: indexing it puts a
|
|
65
|
-
* `Footnotes` hit in the dialog for every footnoted page, and the footnote text
|
|
66
|
-
* itself is already indexed where it was written. `rehypeCaptureToc` skips the
|
|
67
|
-
* same subtree, so the TOC and the index agree.
|
|
68
|
-
*/
|
|
69
|
-
function isFootnotes(node) {
|
|
70
|
-
return node.properties.dataFootnotes !== void 0;
|
|
71
|
-
}
|
|
72
30
|
/** Tags after which extracted text needs a separator. */
|
|
73
31
|
const BLOCK_TAGS = /* @__PURE__ */ new Set([
|
|
74
32
|
"address",
|
|
@@ -118,13 +76,20 @@ const BLOCK_TAGS = /* @__PURE__ */ new Set([
|
|
|
118
76
|
* does with the same heading — the two must not disagree about which sections
|
|
119
77
|
* exist.
|
|
120
78
|
*
|
|
79
|
+
* `text` is the section's PROSE IN FULL. It used to be cut to 300 characters
|
|
80
|
+
* before indexing, which on a normal corpus (200 pages × 6 sections, ~1,686
|
|
81
|
+
* characters of prose each) dropped 82% of the words from the index — and
|
|
82
|
+
* `combineWith: 'AND'` compounds it, since every term of a query then has to
|
|
83
|
+
* land inside the surviving prefix of the same section. The cap bought nothing
|
|
84
|
+
* back: `storeFields` does not carry `text`, so not one character of the kept
|
|
85
|
+
* prefix was ever rendered. Truncate for display, in the layer that displays.
|
|
86
|
+
*
|
|
121
87
|
* Not generic over the frontmatter type, deliberately: `frontmatter.title` is
|
|
122
88
|
* the only field read, and a `RenderedDoc` carrying a project's own fields is
|
|
123
89
|
* assignable to this signature already. A type parameter here would appear in
|
|
124
90
|
* every call site and constrain nothing.
|
|
125
91
|
*/
|
|
126
|
-
function extractSearchRecords(doc
|
|
127
|
-
const excerptLength = options.excerptLength ?? DEFAULT_EXCERPT_LENGTH;
|
|
92
|
+
function extractSearchRecords(doc) {
|
|
128
93
|
const title = doc.frontmatter.title;
|
|
129
94
|
const records = [];
|
|
130
95
|
const slug = doc.segments.join("/");
|
|
@@ -145,7 +110,7 @@ function extractSearchRecords(doc, options = {}) {
|
|
|
145
110
|
heading: section.heading,
|
|
146
111
|
ancestors: section.ancestors,
|
|
147
112
|
href,
|
|
148
|
-
text:
|
|
113
|
+
text: collapseWhitespace(section.parts.join(" "))
|
|
149
114
|
});
|
|
150
115
|
};
|
|
151
116
|
for (const node of iterateBlocks(doc.hast.children)) {
|
|
@@ -191,7 +156,7 @@ function extractSearchRecords(doc, options = {}) {
|
|
|
191
156
|
function* iterateBlocks(nodes) {
|
|
192
157
|
for (const node of nodes) {
|
|
193
158
|
if (node.type === "element" && isFootnotes(node)) continue;
|
|
194
|
-
if (node.type === "element" &&
|
|
159
|
+
if (node.type === "element" && isTransparentContainer(node)) yield* iterateBlocks(node.children);
|
|
195
160
|
else yield node;
|
|
196
161
|
}
|
|
197
162
|
}
|
|
@@ -235,40 +200,24 @@ function collapseWhitespace(text) {
|
|
|
235
200
|
return text.replace(/\s+/g, " ").trim();
|
|
236
201
|
}
|
|
237
202
|
/**
|
|
238
|
-
* Cut `text` to at most `limit` characters, on a word boundary where one is
|
|
239
|
-
* near enough to the cut to be worth honouring.
|
|
240
|
-
*/
|
|
241
|
-
function truncateAtWordBoundary(text, limit) {
|
|
242
|
-
if (limit <= 0) return "";
|
|
243
|
-
if (text.length <= limit) return text;
|
|
244
|
-
const slice = text.slice(0, limit);
|
|
245
|
-
const lastSpace = slice.lastIndexOf(" ");
|
|
246
|
-
return `${(lastSpace > limit / 2 ? slice.slice(0, lastSpace) : slice).trimEnd()}…`;
|
|
247
|
-
}
|
|
248
|
-
/**
|
|
249
203
|
* Build a serialised MiniSearch index from extracted records.
|
|
250
204
|
*
|
|
251
|
-
* The return value is JSON, ready for `MiniSearch.loadJSON` on the client
|
|
252
|
-
*
|
|
205
|
+
* The return value is JSON, ready for `MiniSearch.loadJSON` on the client.
|
|
206
|
+
* `docs.searchIndex` serves exactly this; reach for `buildSearchIndex`
|
|
207
|
+
* directly only when you need an artifact that route cannot produce. The
|
|
208
|
+
* output is byte-stable for a given record list, which is what lets the
|
|
209
|
+
* route ship a strong `ETag`.
|
|
210
|
+
*
|
|
211
|
+
* ⚠️ `options` MUST ALSO REACH THE DIALOG — pass the identical object to
|
|
212
|
+
* `SearchDialog`'s `searchOptions`. Both sides feed it through
|
|
213
|
+
* `mergeSearchOptions`, and a `tokenize` or `processTerm` applied to the
|
|
214
|
+
* documents but not to the query produces an index whose terms no query can
|
|
215
|
+
* spell: zero results, no error, nothing in the console.
|
|
253
216
|
*/
|
|
254
|
-
function buildSearchIndex(records) {
|
|
255
|
-
const index = new MiniSearch(
|
|
217
|
+
function buildSearchIndex(records, options = {}) {
|
|
218
|
+
const index = new MiniSearch(mergeSearchOptions(options));
|
|
256
219
|
index.addAll(records);
|
|
257
220
|
return JSON.stringify(index);
|
|
258
221
|
}
|
|
259
|
-
/**
|
|
260
|
-
* Write the serialised index to `outFile`, creating parent directories.
|
|
261
|
-
*
|
|
262
|
-
* Returns the byte size written, so a build step can log it or assert a
|
|
263
|
-
* budget — a docs index that quietly crosses a megabyte is a regression
|
|
264
|
-
* nobody notices until the dialog takes a second to open.
|
|
265
|
-
*/
|
|
266
|
-
async function writeSearchIndex(records, outFile) {
|
|
267
|
-
const json = buildSearchIndex(records);
|
|
268
|
-
const absolute = path.resolve(outFile);
|
|
269
|
-
await mkdir(path.dirname(absolute), { recursive: true });
|
|
270
|
-
await writeFile(absolute, json, "utf8");
|
|
271
|
-
return Buffer.byteLength(json, "utf8");
|
|
272
|
-
}
|
|
273
222
|
//#endregion
|
|
274
|
-
export { buildSearchIndex, extractSearchRecords
|
|
223
|
+
export { buildSearchIndex, extractSearchRecords };
|
package/dist/search-options.d.ts
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
import { SearchRecord } from "./types.js";
|
|
2
2
|
import { Options } from "minisearch";
|
|
3
3
|
//#region src/search-options.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Split text into index terms.
|
|
6
|
+
*
|
|
7
|
+
* ⚠️ THE SAME FUNCTION MUST TOKENISE THE DOCUMENTS AND THE QUERY. It is part of
|
|
8
|
+
* {@link SEARCH_INDEX_OPTIONS} rather than a separate argument for exactly that
|
|
9
|
+
* reason — MiniSearch reads `tokenize` both when indexing and when searching,
|
|
10
|
+
* and a build-time tokeniser the client does not share fails silently, with an
|
|
11
|
+
* index full of terms no query can ever spell.
|
|
12
|
+
*/
|
|
13
|
+
declare function tokenizeSearchText(text: string): string[];
|
|
4
14
|
/**
|
|
5
15
|
* Fields, stored fields and query defaults for the docs index.
|
|
6
16
|
*
|
|
@@ -9,10 +19,31 @@ import { Options } from "minisearch";
|
|
|
9
19
|
* recover fields the serialised index never stored, and the failure is silent
|
|
10
20
|
* — results arrive with an id and a score and nothing to render.
|
|
11
21
|
*
|
|
22
|
+
* `title` is stored but NOT indexed, and the asymmetry is the whole point. A
|
|
23
|
+
* record exists per section, and every one of them carries its page's title, so
|
|
24
|
+
* indexing it scored all N sections of one page on a single title match: a
|
|
25
|
+
* query for `configuration` filled seven of the dialog's eight rows with the
|
|
26
|
+
* same page and pushed every other page's matches off the list entirely. The
|
|
27
|
+
* page title is still searchable — the lead record's `heading` IS the title, so
|
|
28
|
+
* it matches exactly once per page, which is what the reader wanted from it.
|
|
29
|
+
*
|
|
12
30
|
* `combineWith: 'AND'` is not MiniSearch's default and is not optional here.
|
|
13
31
|
* The default OR returned 68–131 hits on real queries where AND returned a
|
|
14
32
|
* usable handful.
|
|
15
33
|
*/
|
|
16
34
|
declare const SEARCH_INDEX_OPTIONS: Options<SearchRecord>;
|
|
35
|
+
/**
|
|
36
|
+
* {@link SEARCH_INDEX_OPTIONS} with `overrides` applied over it.
|
|
37
|
+
*
|
|
38
|
+
* The one supported way to customise search, and it exists so both halves can
|
|
39
|
+
* customise it identically: `buildSearchIndex` takes the same overrides the
|
|
40
|
+
* dialog does, and an index built with one `tokenize` and queried with another
|
|
41
|
+
* returns nothing at all.
|
|
42
|
+
*
|
|
43
|
+
* `searchOptions` merges one level deep, so `{ searchOptions: { fuzzy: 0 } }`
|
|
44
|
+
* keeps `combineWith: 'AND'` instead of silently reverting it to MiniSearch's
|
|
45
|
+
* OR. Nothing below that merges: a `boost` override replaces the whole map.
|
|
46
|
+
*/
|
|
47
|
+
declare function mergeSearchOptions(overrides?: Partial<Options<SearchRecord>>): Options<SearchRecord>;
|
|
17
48
|
//#endregion
|
|
18
|
-
export { SEARCH_INDEX_OPTIONS };
|
|
49
|
+
export { SEARCH_INDEX_OPTIONS, mergeSearchOptions, tokenizeSearchText };
|
package/dist/search-options.js
CHANGED
|
@@ -1,5 +1,39 @@
|
|
|
1
1
|
//#region src/search-options.ts
|
|
2
2
|
/**
|
|
3
|
+
* Everything that is not a letter, a digit or a combining mark.
|
|
4
|
+
*
|
|
5
|
+
* Applied to each segment the word breaker returns, so `wave.config.json`
|
|
6
|
+
* indexes as three terms rather than one. UAX #29 treats a full stop between
|
|
7
|
+
* letters as part of the word, which is right for `e.g.` in prose and wrong
|
|
8
|
+
* for every dotted identifier in a technical document.
|
|
9
|
+
*/
|
|
10
|
+
const NON_WORD = /[^\p{L}\p{N}\p{M}]+/u;
|
|
11
|
+
/**
|
|
12
|
+
* Absent on browsers older than Firefox 125; Node has had it since 16.
|
|
13
|
+
* Without it CJK falls back to whitespace splitting, which is how this package
|
|
14
|
+
* shipped before: Chinese, Japanese and Thai prose has no spaces, so a whole
|
|
15
|
+
* clause became one term and every query returned zero results.
|
|
16
|
+
*/
|
|
17
|
+
const WORD_SEGMENTER = typeof Intl.Segmenter === "function" ? new Intl.Segmenter("en", { granularity: "word" }) : void 0;
|
|
18
|
+
/**
|
|
19
|
+
* Split text into index terms.
|
|
20
|
+
*
|
|
21
|
+
* ⚠️ THE SAME FUNCTION MUST TOKENISE THE DOCUMENTS AND THE QUERY. It is part of
|
|
22
|
+
* {@link SEARCH_INDEX_OPTIONS} rather than a separate argument for exactly that
|
|
23
|
+
* reason — MiniSearch reads `tokenize` both when indexing and when searching,
|
|
24
|
+
* and a build-time tokeniser the client does not share fails silently, with an
|
|
25
|
+
* index full of terms no query can ever spell.
|
|
26
|
+
*/
|
|
27
|
+
function tokenizeSearchText(text) {
|
|
28
|
+
if (WORD_SEGMENTER === void 0) return text.split(NON_WORD).filter((token) => token !== "");
|
|
29
|
+
const tokens = [];
|
|
30
|
+
for (const { segment, isWordLike } of WORD_SEGMENTER.segment(text)) {
|
|
31
|
+
if (isWordLike !== true) continue;
|
|
32
|
+
for (const token of segment.split(NON_WORD)) if (token !== "") tokens.push(token);
|
|
33
|
+
}
|
|
34
|
+
return tokens;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
3
37
|
* Fields, stored fields and query defaults for the docs index.
|
|
4
38
|
*
|
|
5
39
|
* `storeFields` MUST be applied at build time. It decides what
|
|
@@ -7,13 +41,20 @@
|
|
|
7
41
|
* recover fields the serialised index never stored, and the failure is silent
|
|
8
42
|
* — results arrive with an id and a score and nothing to render.
|
|
9
43
|
*
|
|
44
|
+
* `title` is stored but NOT indexed, and the asymmetry is the whole point. A
|
|
45
|
+
* record exists per section, and every one of them carries its page's title, so
|
|
46
|
+
* indexing it scored all N sections of one page on a single title match: a
|
|
47
|
+
* query for `configuration` filled seven of the dialog's eight rows with the
|
|
48
|
+
* same page and pushed every other page's matches off the list entirely. The
|
|
49
|
+
* page title is still searchable — the lead record's `heading` IS the title, so
|
|
50
|
+
* it matches exactly once per page, which is what the reader wanted from it.
|
|
51
|
+
*
|
|
10
52
|
* `combineWith: 'AND'` is not MiniSearch's default and is not optional here.
|
|
11
53
|
* The default OR returned 68–131 hits on real queries where AND returned a
|
|
12
54
|
* usable handful.
|
|
13
55
|
*/
|
|
14
56
|
const SEARCH_INDEX_OPTIONS = {
|
|
15
57
|
fields: [
|
|
16
|
-
"title",
|
|
17
58
|
"heading",
|
|
18
59
|
"text",
|
|
19
60
|
"ancestors"
|
|
@@ -24,17 +65,39 @@ const SEARCH_INDEX_OPTIONS = {
|
|
|
24
65
|
"ancestors",
|
|
25
66
|
"href"
|
|
26
67
|
],
|
|
68
|
+
tokenize: tokenizeSearchText,
|
|
27
69
|
searchOptions: {
|
|
28
70
|
prefix: true,
|
|
29
71
|
fuzzy: .2,
|
|
30
72
|
combineWith: "AND",
|
|
31
73
|
boost: {
|
|
32
|
-
title: 4,
|
|
33
74
|
heading: 3,
|
|
34
75
|
text: 2,
|
|
35
76
|
ancestors: 1
|
|
36
77
|
}
|
|
37
78
|
}
|
|
38
79
|
};
|
|
80
|
+
/**
|
|
81
|
+
* {@link SEARCH_INDEX_OPTIONS} with `overrides` applied over it.
|
|
82
|
+
*
|
|
83
|
+
* The one supported way to customise search, and it exists so both halves can
|
|
84
|
+
* customise it identically: `buildSearchIndex` takes the same overrides the
|
|
85
|
+
* dialog does, and an index built with one `tokenize` and queried with another
|
|
86
|
+
* returns nothing at all.
|
|
87
|
+
*
|
|
88
|
+
* `searchOptions` merges one level deep, so `{ searchOptions: { fuzzy: 0 } }`
|
|
89
|
+
* keeps `combineWith: 'AND'` instead of silently reverting it to MiniSearch's
|
|
90
|
+
* OR. Nothing below that merges: a `boost` override replaces the whole map.
|
|
91
|
+
*/
|
|
92
|
+
function mergeSearchOptions(overrides = {}) {
|
|
93
|
+
return {
|
|
94
|
+
...SEARCH_INDEX_OPTIONS,
|
|
95
|
+
...overrides,
|
|
96
|
+
searchOptions: {
|
|
97
|
+
...SEARCH_INDEX_OPTIONS.searchOptions,
|
|
98
|
+
...overrides.searchOptions
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
}
|
|
39
102
|
//#endregion
|
|
40
|
-
export { SEARCH_INDEX_OPTIONS };
|
|
103
|
+
export { SEARCH_INDEX_OPTIONS, mergeSearchOptions, tokenizeSearchText };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Element } from "hast";
|
|
2
|
+
//#region src/section-boundary.d.ts
|
|
3
|
+
/** Whether a heading nested in `node` still opens a section of its own. */
|
|
4
|
+
declare function isTransparentContainer(node: Element): boolean;
|
|
5
|
+
/**
|
|
6
|
+
* The GFM footnote block `mdast-util-to-hast` appends, and everything in it.
|
|
7
|
+
*
|
|
8
|
+
* It is a `section` — so {@link TRANSPARENT_TAGS} would otherwise walk straight
|
|
9
|
+
* into it — carrying a generated `<h2 id="footnote-label">Footnotes</h2>`. That
|
|
10
|
+
* heading is machinery, not a section of the page: indexing it puts a
|
|
11
|
+
* `Footnotes` hit in the dialog for every footnoted page, and the footnote text
|
|
12
|
+
* itself is already indexed where it was written. Both readers skip the same
|
|
13
|
+
* subtree, so the TOC and the index agree.
|
|
14
|
+
*/
|
|
15
|
+
declare function isFootnotes(node: Element): boolean;
|
|
16
|
+
//#endregion
|
|
17
|
+
export { isFootnotes, isTransparentContainer };
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
//#region src/section-boundary.ts
|
|
2
|
+
/**
|
|
3
|
+
* Containers a heading can sit inside and still open a section.
|
|
4
|
+
*
|
|
5
|
+
* ⚠️ THIS SET IS THE SECTION-BOUNDARY RULE, AND BOTH READERS HAVE TO AGREE ON
|
|
6
|
+
* IT. A heading the TOC captures but the index does not gets a table-of-contents
|
|
7
|
+
* entry with no searchable section behind it, its prose folded into the section
|
|
8
|
+
* above, and a hit that deep-links to the wrong anchor under the wrong
|
|
9
|
+
* breadcrumb. `callout` — what `> [!NOTE]` renders to in this very package — was
|
|
10
|
+
* exactly that case. Use {@link isTransparentContainer} rather than reaching for
|
|
11
|
+
* the set, and add to it in one place.
|
|
12
|
+
*
|
|
13
|
+
* `li` is deliberately absent, and not as an oversight: a list item's children
|
|
14
|
+
* can be bare inline nodes, so descending into one would yield `re`, `<em>al`,
|
|
15
|
+
* `ly` as three separate blocks and index `re al ly` as three terms.
|
|
16
|
+
*/
|
|
17
|
+
const TRANSPARENT_TAGS = /* @__PURE__ */ new Set([
|
|
18
|
+
"article",
|
|
19
|
+
"blockquote",
|
|
20
|
+
"callout",
|
|
21
|
+
"div",
|
|
22
|
+
"main",
|
|
23
|
+
"section"
|
|
24
|
+
]);
|
|
25
|
+
/** Whether a heading nested in `node` still opens a section of its own. */
|
|
26
|
+
function isTransparentContainer(node) {
|
|
27
|
+
return TRANSPARENT_TAGS.has(node.tagName);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The GFM footnote block `mdast-util-to-hast` appends, and everything in it.
|
|
31
|
+
*
|
|
32
|
+
* It is a `section` — so {@link TRANSPARENT_TAGS} would otherwise walk straight
|
|
33
|
+
* into it — carrying a generated `<h2 id="footnote-label">Footnotes</h2>`. That
|
|
34
|
+
* heading is machinery, not a section of the page: indexing it puts a
|
|
35
|
+
* `Footnotes` hit in the dialog for every footnoted page, and the footnote text
|
|
36
|
+
* itself is already indexed where it was written. Both readers skip the same
|
|
37
|
+
* subtree, so the TOC and the index agree.
|
|
38
|
+
*/
|
|
39
|
+
function isFootnotes(node) {
|
|
40
|
+
return node.properties.dataFootnotes !== void 0;
|
|
41
|
+
}
|
|
42
|
+
//#endregion
|
|
43
|
+
export { isFootnotes, isTransparentContainer };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
//#region src/sitemap-limit.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Google's per-sitemap URL cap, and the warning for crossing it.
|
|
4
|
+
*
|
|
5
|
+
* Private — deliberately not an entry point in `package.json`. It lives in its
|
|
6
|
+
* own module for one reason: the branch is otherwise untestable. `next.ts` held
|
|
7
|
+
* the limit and the `console.warn` inline, and reaching them from a test meant
|
|
8
|
+
* writing 50,001 markdown files to a temporary directory, so the test that
|
|
9
|
+
* claimed to cover it ("warns rather than silently emitting an oversized
|
|
10
|
+
* sitemap") built a one-page site and asserted the warning did *not* fire. It
|
|
11
|
+
* could only ever fail if the comparison were inverted.
|
|
12
|
+
*
|
|
13
|
+
* A count is the whole input. Split out, the arithmetic and the wording are
|
|
14
|
+
* checkable in microseconds, and `createDocsSitemap`'s own test keeps covering
|
|
15
|
+
* the case that matters at the integration level: an ordinary sitemap stays
|
|
16
|
+
* quiet.
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* The cap.
|
|
20
|
+
*
|
|
21
|
+
* 50,000 URLs or 50 MB uncompressed, whichever comes first; a crawler rejects
|
|
22
|
+
* the file whole rather than truncating it.
|
|
23
|
+
*/
|
|
24
|
+
declare const SITEMAP_URL_LIMIT = 50000;
|
|
25
|
+
/**
|
|
26
|
+
* The warning for a sitemap of `count` URLs, or `undefined` when it fits.
|
|
27
|
+
*
|
|
28
|
+
* Splitting belongs to the caller — Next's `generateSitemaps` plus a slice of
|
|
29
|
+
* the returned array is three lines — but silently emitting a file no crawler
|
|
30
|
+
* will read is not something to discover from Search Console six weeks later.
|
|
31
|
+
*/
|
|
32
|
+
declare function sitemapLimitWarning(count: number): string | undefined;
|
|
33
|
+
//#endregion
|
|
34
|
+
export { SITEMAP_URL_LIMIT, sitemapLimitWarning };
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
//#region src/sitemap-limit.ts
|
|
2
|
+
/**
|
|
3
|
+
* Google's per-sitemap URL cap, and the warning for crossing it.
|
|
4
|
+
*
|
|
5
|
+
* Private — deliberately not an entry point in `package.json`. It lives in its
|
|
6
|
+
* own module for one reason: the branch is otherwise untestable. `next.ts` held
|
|
7
|
+
* the limit and the `console.warn` inline, and reaching them from a test meant
|
|
8
|
+
* writing 50,001 markdown files to a temporary directory, so the test that
|
|
9
|
+
* claimed to cover it ("warns rather than silently emitting an oversized
|
|
10
|
+
* sitemap") built a one-page site and asserted the warning did *not* fire. It
|
|
11
|
+
* could only ever fail if the comparison were inverted.
|
|
12
|
+
*
|
|
13
|
+
* A count is the whole input. Split out, the arithmetic and the wording are
|
|
14
|
+
* checkable in microseconds, and `createDocsSitemap`'s own test keeps covering
|
|
15
|
+
* the case that matters at the integration level: an ordinary sitemap stays
|
|
16
|
+
* quiet.
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* The cap.
|
|
20
|
+
*
|
|
21
|
+
* 50,000 URLs or 50 MB uncompressed, whichever comes first; a crawler rejects
|
|
22
|
+
* the file whole rather than truncating it.
|
|
23
|
+
*/
|
|
24
|
+
const SITEMAP_URL_LIMIT = 5e4;
|
|
25
|
+
/**
|
|
26
|
+
* The warning for a sitemap of `count` URLs, or `undefined` when it fits.
|
|
27
|
+
*
|
|
28
|
+
* Splitting belongs to the caller — Next's `generateSitemaps` plus a slice of
|
|
29
|
+
* the returned array is three lines — but silently emitting a file no crawler
|
|
30
|
+
* will read is not something to discover from Search Console six weeks later.
|
|
31
|
+
*/
|
|
32
|
+
function sitemapLimitWarning(count) {
|
|
33
|
+
if (count <= 5e4) return void 0;
|
|
34
|
+
return `@waveso/docs: this sitemap has ${count} URLs, above Google's limit of ${SITEMAP_URL_LIMIT}. Split it with Next's \`generateSitemaps\` and slice the array this returns.`;
|
|
35
|
+
}
|
|
36
|
+
//#endregion
|
|
37
|
+
export { SITEMAP_URL_LIMIT, sitemapLimitWarning };
|
package/dist/source.d.ts
CHANGED
|
@@ -9,6 +9,17 @@ import { DocFile, DocFrontmatter, DocNavNode, DocsConfig, ResolvedDocsConfig } f
|
|
|
9
9
|
interface DocsSource<TFrontmatter extends DocFrontmatter = DocFrontmatter> {
|
|
10
10
|
/** Every page, drafts excluded unless `includeDrafts`. */
|
|
11
11
|
all(): Promise<Array<DocFile<TFrontmatter>>>;
|
|
12
|
+
/**
|
|
13
|
+
* Every `draft: true` page, whatever `includeDrafts` says.
|
|
14
|
+
*
|
|
15
|
+
* A link to a draft resolves to a file plainly sitting on disk, so the
|
|
16
|
+
* renderer's generic "no such page exists" sends the author hunting for a
|
|
17
|
+
* typo in a link that is spelled correctly. `createDocsRoute` feeds this to
|
|
18
|
+
* `DocsRendererOptions.draftRoutes` so the build still fails, but names the
|
|
19
|
+
* reason. Unfiltered on purpose: under `includeDrafts` these routes are
|
|
20
|
+
* published too, and the renderer checks `knownRoutes` first.
|
|
21
|
+
*/
|
|
22
|
+
drafts(): Promise<Array<DocFile<TFrontmatter>>>;
|
|
12
23
|
/**
|
|
13
24
|
* One page by route segments. Returns drafts regardless of config, so a
|
|
14
25
|
* preview route can opt into them without a second source.
|
|
@@ -42,26 +53,5 @@ declare function resolveDocsConfig<TFrontmatter extends DocFrontmatter = DocFron
|
|
|
42
53
|
* Create (or reuse) the source for a content directory.
|
|
43
54
|
*/
|
|
44
55
|
declare function createDocsSource<TFrontmatter extends DocFrontmatter = DocFrontmatter>(config: DocsConfig<TFrontmatter>): DocsSource<TFrontmatter>;
|
|
45
|
-
/**
|
|
46
|
-
* A former URL from `aliases` frontmatter, as a route.
|
|
47
|
-
*
|
|
48
|
-
* `'quickstart'` on a site mounted at `/docs` becomes `/docs/quickstart`.
|
|
49
|
-
* Leading and trailing slashes are tolerated because authors write them, but
|
|
50
|
-
* the value is always relative to the base path — an alias of `'/docs/old'` on
|
|
51
|
-
* a `/docs` site would produce `/docs/docs/old`.
|
|
52
|
-
*
|
|
53
|
-
* Shared by both adapters so they agree on which routes exist: an alias is a
|
|
54
|
-
* redirect the host installs, so a link to one resolves, and a link that
|
|
55
|
-
* builds under Next must build under Vite.
|
|
56
|
-
*/
|
|
57
|
-
declare function toAliasRoute(alias: string, basePath: string,
|
|
58
|
-
/**
|
|
59
|
-
* The source path, for the error. A STRING rather than the whole `DocFile`
|
|
60
|
-
* it used to take: this function dereferenced exactly one property of it, and
|
|
61
|
-
* demanding the object meant a cache reader or a manifest-driven redirect
|
|
62
|
-
* table had to fabricate a `DocFile` to agree with the package about which
|
|
63
|
-
* routes exist. That is the reason it is exported at all.
|
|
64
|
-
*/
|
|
65
|
-
sourceLabel: string): string;
|
|
66
56
|
//#endregion
|
|
67
|
-
export { DocsSource, createDocsSource, resolveDocsConfig
|
|
57
|
+
export { DocsSource, createDocsSource, resolveDocsConfig };
|