@waveso/docs 0.1.0 → 0.2.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 +84 -0
- package/README.md +111 -22
- package/dist/docs-error.d.ts +74 -0
- package/dist/docs-error.js +40 -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 +41 -19
- package/dist/next.js +117 -21
- package/dist/plugins/rehype-capture-toc.js +26 -15
- package/dist/plugins/rehype-code-language.d.ts +24 -0
- package/dist/plugins/rehype-code-language.js +48 -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/markdown-components.js +71 -6
- package/dist/react/search-dialog.d.ts +23 -7
- package/dist/react/search-dialog.js +46 -29
- package/dist/react/toc.js +28 -5
- package/dist/react/youtube.js +6 -4
- package/dist/render.d.ts +43 -9
- package/dist/render.js +112 -50
- package/dist/search-index.d.ts +32 -14
- package/dist/search-index.js +45 -51
- 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/source.d.ts +13 -1
- package/dist/source.js +152 -56
- package/dist/styles.css +236 -90
- package/dist/types.d.ts +41 -27
- package/package.json +13 -12
package/dist/search-index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { isFootnotes, isTransparentContainer } from "./section-boundary.js";
|
|
2
|
+
import { mergeSearchOptions } from "./search-options.js";
|
|
3
|
+
import { mkdir, rename, rm, writeFile } from "node:fs/promises";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
import MiniSearch from "minisearch";
|
|
5
6
|
//#region src/search-index.ts
|
|
@@ -16,8 +17,6 @@ import MiniSearch from "minisearch";
|
|
|
16
17
|
* 1.35 ms / 3.84 ms. Fuse is a fuzzy short-string matcher routinely
|
|
17
18
|
* misapplied to full text.
|
|
18
19
|
*/
|
|
19
|
-
/** Default excerpt length, in characters, of {@link SearchRecord.text}. */
|
|
20
|
-
const DEFAULT_EXCERPT_LENGTH = 300;
|
|
21
20
|
/** `<h1>`…`<h6>` to their numeric depth. */
|
|
22
21
|
const HEADING_DEPTHS = /* @__PURE__ */ new Map([
|
|
23
22
|
["h1", 1],
|
|
@@ -43,32 +42,6 @@ const SKIPPED_TAGS = /* @__PURE__ */ new Set([
|
|
|
43
42
|
"svg",
|
|
44
43
|
"template"
|
|
45
44
|
]);
|
|
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
45
|
/** Tags after which extracted text needs a separator. */
|
|
73
46
|
const BLOCK_TAGS = /* @__PURE__ */ new Set([
|
|
74
47
|
"address",
|
|
@@ -118,13 +91,20 @@ const BLOCK_TAGS = /* @__PURE__ */ new Set([
|
|
|
118
91
|
* does with the same heading — the two must not disagree about which sections
|
|
119
92
|
* exist.
|
|
120
93
|
*
|
|
94
|
+
* `text` is the section's PROSE IN FULL. It used to be cut to 300 characters
|
|
95
|
+
* before indexing, which on a normal corpus (200 pages × 6 sections, ~1,686
|
|
96
|
+
* characters of prose each) dropped 82% of the words from the index — and
|
|
97
|
+
* `combineWith: 'AND'` compounds it, since every term of a query then has to
|
|
98
|
+
* land inside the surviving prefix of the same section. The cap bought nothing
|
|
99
|
+
* back: `storeFields` does not carry `text`, so not one character of the kept
|
|
100
|
+
* prefix was ever rendered. Truncate for display, in the layer that displays.
|
|
101
|
+
*
|
|
121
102
|
* Not generic over the frontmatter type, deliberately: `frontmatter.title` is
|
|
122
103
|
* the only field read, and a `RenderedDoc` carrying a project's own fields is
|
|
123
104
|
* assignable to this signature already. A type parameter here would appear in
|
|
124
105
|
* every call site and constrain nothing.
|
|
125
106
|
*/
|
|
126
|
-
function extractSearchRecords(doc
|
|
127
|
-
const excerptLength = options.excerptLength ?? DEFAULT_EXCERPT_LENGTH;
|
|
107
|
+
function extractSearchRecords(doc) {
|
|
128
108
|
const title = doc.frontmatter.title;
|
|
129
109
|
const records = [];
|
|
130
110
|
const slug = doc.segments.join("/");
|
|
@@ -145,7 +125,7 @@ function extractSearchRecords(doc, options = {}) {
|
|
|
145
125
|
heading: section.heading,
|
|
146
126
|
ancestors: section.ancestors,
|
|
147
127
|
href,
|
|
148
|
-
text:
|
|
128
|
+
text: collapseWhitespace(section.parts.join(" "))
|
|
149
129
|
});
|
|
150
130
|
};
|
|
151
131
|
for (const node of iterateBlocks(doc.hast.children)) {
|
|
@@ -191,7 +171,7 @@ function extractSearchRecords(doc, options = {}) {
|
|
|
191
171
|
function* iterateBlocks(nodes) {
|
|
192
172
|
for (const node of nodes) {
|
|
193
173
|
if (node.type === "element" && isFootnotes(node)) continue;
|
|
194
|
-
if (node.type === "element" &&
|
|
174
|
+
if (node.type === "element" && isTransparentContainer(node)) yield* iterateBlocks(node.children);
|
|
195
175
|
else yield node;
|
|
196
176
|
}
|
|
197
177
|
}
|
|
@@ -235,24 +215,21 @@ function collapseWhitespace(text) {
|
|
|
235
215
|
return text.replace(/\s+/g, " ").trim();
|
|
236
216
|
}
|
|
237
217
|
/**
|
|
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
218
|
* Build a serialised MiniSearch index from extracted records.
|
|
250
219
|
*
|
|
251
220
|
* The return value is JSON, ready for `MiniSearch.loadJSON` on the client or
|
|
252
|
-
* for {@link writeSearchIndex} to put on disk.
|
|
221
|
+
* for {@link writeSearchIndex} to put on disk. The output is byte-stable for a
|
|
222
|
+
* given record list, so an index committed to the repository does not dirty
|
|
223
|
+
* the diff on every build.
|
|
224
|
+
*
|
|
225
|
+
* ⚠️ `options` MUST ALSO REACH THE DIALOG — pass the identical object to
|
|
226
|
+
* `SearchDialog`'s `searchOptions`. Both sides feed it through
|
|
227
|
+
* `mergeSearchOptions`, and a `tokenize` or `processTerm` applied to the
|
|
228
|
+
* documents but not to the query produces an index whose terms no query can
|
|
229
|
+
* spell: zero results, no error, nothing in the console.
|
|
253
230
|
*/
|
|
254
|
-
function buildSearchIndex(records) {
|
|
255
|
-
const index = new MiniSearch(
|
|
231
|
+
function buildSearchIndex(records, options = {}) {
|
|
232
|
+
const index = new MiniSearch(mergeSearchOptions(options));
|
|
256
233
|
index.addAll(records);
|
|
257
234
|
return JSON.stringify(index);
|
|
258
235
|
}
|
|
@@ -262,12 +239,29 @@ function buildSearchIndex(records) {
|
|
|
262
239
|
* Returns the byte size written, so a build step can log it or assert a
|
|
263
240
|
* budget — a docs index that quietly crosses a megabyte is a regression
|
|
264
241
|
* nobody notices until the dialog takes a second to open.
|
|
242
|
+
*
|
|
243
|
+
* ⚠️ WRITTEN BESIDE THE TARGET AND RENAMED OVER IT, NEVER INTO IT. The target
|
|
244
|
+
* is normally `public/search-index.json`, a live static asset: writing in
|
|
245
|
+
* place truncates it to zero and grows it back in 1 MiB chunks, and a fetch
|
|
246
|
+
* landing in that window gets a 200 with a half-written body. `response.ok`
|
|
247
|
+
* passes, the parse throws, and the dialog is stuck in its error state —
|
|
248
|
+
* *"Try reloading the page"* — for every visitor, reloading forever, until
|
|
249
|
+
* someone redeploys content that did not change. `rename` is atomic within a
|
|
250
|
+
* filesystem, so a reader sees either the whole old file or the whole new one;
|
|
251
|
+
* it also makes two concurrent builds safe.
|
|
265
252
|
*/
|
|
266
|
-
async function writeSearchIndex(records, outFile) {
|
|
267
|
-
const json = buildSearchIndex(records);
|
|
253
|
+
async function writeSearchIndex(records, outFile, options = {}) {
|
|
254
|
+
const json = buildSearchIndex(records, options);
|
|
268
255
|
const absolute = path.resolve(outFile);
|
|
256
|
+
const temporary = `${absolute}.tmp-${process.pid}`;
|
|
269
257
|
await mkdir(path.dirname(absolute), { recursive: true });
|
|
270
|
-
|
|
258
|
+
try {
|
|
259
|
+
await writeFile(temporary, json, "utf8");
|
|
260
|
+
await rename(temporary, absolute);
|
|
261
|
+
} catch (error) {
|
|
262
|
+
await rm(temporary, { force: true });
|
|
263
|
+
throw error;
|
|
264
|
+
}
|
|
271
265
|
return Buffer.byteLength(json, "utf8");
|
|
272
266
|
}
|
|
273
267
|
//#endregion
|
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 };
|
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.
|
|
@@ -52,7 +63,8 @@ declare function createDocsSource<TFrontmatter extends DocFrontmatter = DocFront
|
|
|
52
63
|
*
|
|
53
64
|
* Shared by both adapters so they agree on which routes exist: an alias is a
|
|
54
65
|
* redirect the host installs, so a link to one resolves, and a link that
|
|
55
|
-
* builds under Next must build under Vite.
|
|
66
|
+
* builds under Next must build under Vite. The source scan calls it too, so
|
|
67
|
+
* every rejection below names the markdown file at the moment it is read.
|
|
56
68
|
*/
|
|
57
69
|
declare function toAliasRoute(alias: string, basePath: string,
|
|
58
70
|
/**
|