@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.
Files changed (41) hide show
  1. package/CHANGELOG.md +84 -0
  2. package/README.md +111 -22
  3. package/dist/docs-error.d.ts +74 -0
  4. package/dist/docs-error.js +40 -0
  5. package/dist/frontmatter.d.ts +39 -7
  6. package/dist/frontmatter.js +51 -24
  7. package/dist/highlighter.d.ts +2 -2
  8. package/dist/highlighter.js +3 -2
  9. package/dist/map-pooled.d.ts +26 -0
  10. package/dist/map-pooled.js +45 -0
  11. package/dist/meta.d.ts +7 -3
  12. package/dist/meta.js +61 -15
  13. package/dist/next.d.ts +41 -19
  14. package/dist/next.js +117 -21
  15. package/dist/plugins/rehype-capture-toc.js +26 -15
  16. package/dist/plugins/rehype-code-language.d.ts +24 -0
  17. package/dist/plugins/rehype-code-language.js +48 -0
  18. package/dist/plugins/rehype-fallback-heading-ids.d.ts +6 -0
  19. package/dist/plugins/rehype-fallback-heading-ids.js +51 -0
  20. package/dist/plugins/rehype-flatten-roots.d.ts +7 -0
  21. package/dist/plugins/rehype-flatten-roots.js +39 -0
  22. package/dist/plugins/remark-doc-links.d.ts +12 -1
  23. package/dist/plugins/remark-doc-links.js +147 -20
  24. package/dist/react/markdown-components.js +71 -6
  25. package/dist/react/search-dialog.d.ts +23 -7
  26. package/dist/react/search-dialog.js +46 -29
  27. package/dist/react/toc.js +28 -5
  28. package/dist/react/youtube.js +6 -4
  29. package/dist/render.d.ts +43 -9
  30. package/dist/render.js +112 -50
  31. package/dist/search-index.d.ts +32 -14
  32. package/dist/search-index.js +45 -51
  33. package/dist/search-options.d.ts +32 -1
  34. package/dist/search-options.js +66 -3
  35. package/dist/section-boundary.d.ts +17 -0
  36. package/dist/section-boundary.js +43 -0
  37. package/dist/source.d.ts +13 -1
  38. package/dist/source.js +152 -56
  39. package/dist/styles.css +236 -90
  40. package/dist/types.d.ts +41 -27
  41. package/package.json +13 -12
@@ -1,5 +1,6 @@
1
- import { SEARCH_INDEX_OPTIONS } from "./search-options.js";
2
- import { mkdir, writeFile } from "node:fs/promises";
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, options = {}) {
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: truncateAtWordBoundary(collapseWhitespace(section.parts.join(" ")), excerptLength)
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" && TRANSPARENT_TAGS.has(node.tagName)) yield* iterateBlocks(node.children);
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(SEARCH_INDEX_OPTIONS);
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
- await writeFile(absolute, json, "utf8");
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
@@ -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 };
@@ -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
  /**