@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.
Files changed (79) hide show
  1. package/CHANGELOG.md +194 -0
  2. package/README.md +592 -88
  3. package/dist/code-frame.d.ts +29 -0
  4. package/dist/code-frame.js +41 -0
  5. package/dist/code-meta.d.ts +48 -0
  6. package/dist/code-meta.js +72 -0
  7. package/dist/docs-content-id.d.ts +19 -0
  8. package/dist/docs-content-id.js +19 -0
  9. package/dist/docs-error.d.ts +19 -0
  10. package/dist/docs-error.js +28 -0
  11. package/dist/errors.d.ts +94 -0
  12. package/dist/errors.js +45 -0
  13. package/dist/frontmatter.d.ts +39 -7
  14. package/dist/frontmatter.js +51 -24
  15. package/dist/highlighter.d.ts +2 -2
  16. package/dist/highlighter.js +3 -2
  17. package/dist/map-pooled.d.ts +26 -0
  18. package/dist/map-pooled.js +45 -0
  19. package/dist/meta.d.ts +7 -3
  20. package/dist/meta.js +61 -15
  21. package/dist/next.d.ts +182 -35
  22. package/dist/next.js +177 -49
  23. package/dist/plugins/rehype-capture-toc.js +52 -20
  24. package/dist/plugins/rehype-code-frame.d.ts +10 -0
  25. package/dist/plugins/rehype-code-frame.js +88 -0
  26. package/dist/plugins/rehype-code-language.d.ts +24 -0
  27. package/dist/plugins/rehype-code-language.js +54 -0
  28. package/dist/plugins/rehype-fallback-heading-ids.d.ts +6 -0
  29. package/dist/plugins/rehype-fallback-heading-ids.js +51 -0
  30. package/dist/plugins/rehype-flatten-roots.d.ts +7 -0
  31. package/dist/plugins/rehype-flatten-roots.js +39 -0
  32. package/dist/plugins/remark-doc-links.d.ts +12 -1
  33. package/dist/plugins/remark-doc-links.js +147 -20
  34. package/dist/react/code-runtime.d.ts +14 -0
  35. package/dist/react/code-runtime.js +161 -0
  36. package/dist/react/doc-content.d.ts +39 -2
  37. package/dist/react/doc-content.js +42 -10
  38. package/dist/react/layout.d.ts +44 -0
  39. package/dist/react/layout.js +65 -0
  40. package/dist/react/markdown-components.js +71 -6
  41. package/dist/react/nav.d.ts +28 -0
  42. package/dist/react/nav.js +70 -0
  43. package/dist/react/nearest-scroll-top.d.ts +45 -0
  44. package/dist/react/nearest-scroll-top.js +44 -0
  45. package/dist/react/next-link.d.ts +34 -0
  46. package/dist/react/next-link.js +30 -0
  47. package/dist/react/next-nav.d.ts +11 -0
  48. package/dist/react/next-nav.js +32 -0
  49. package/dist/react/next-search.d.ts +22 -0
  50. package/dist/react/next-search.js +52 -0
  51. package/dist/react/search-dialog.d.ts +35 -7
  52. package/dist/react/search-dialog.js +55 -33
  53. package/dist/react/shell-labels.d.ts +43 -0
  54. package/dist/react/shell-labels.js +27 -0
  55. package/dist/react/sidebar.d.ts +38 -3
  56. package/dist/react/sidebar.js +104 -12
  57. package/dist/react/skip-link.d.ts +1 -9
  58. package/dist/react/skip-link.js +6 -5
  59. package/dist/react/toc.d.ts +12 -4
  60. package/dist/react/toc.js +46 -12
  61. package/dist/react/youtube.d.ts +31 -5
  62. package/dist/react/youtube.js +76 -52
  63. package/dist/render.d.ts +78 -10
  64. package/dist/render.js +137 -54
  65. package/dist/route-path.d.ts +46 -0
  66. package/dist/route-path.js +51 -0
  67. package/dist/search-index.d.ts +22 -21
  68. package/dist/search-index.js +27 -78
  69. package/dist/search-options.d.ts +32 -1
  70. package/dist/search-options.js +66 -3
  71. package/dist/section-boundary.d.ts +17 -0
  72. package/dist/section-boundary.js +43 -0
  73. package/dist/sitemap-limit.d.ts +34 -0
  74. package/dist/sitemap-limit.js +37 -0
  75. package/dist/source.d.ts +12 -22
  76. package/dist/source.js +165 -72
  77. package/dist/styles.css +1117 -125
  78. package/dist/types.d.ts +52 -29
  79. 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 };
@@ -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, options?: ExtractSearchRecordsOptions): SearchRecord[];
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 or
39
- * for {@link writeSearchIndex} to put on disk.
40
- */
41
- declare function buildSearchIndex(records: SearchRecord[]): string;
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
- * Returns the byte size written, so a build step can log it or assert a
46
- * budget a docs index that quietly crosses a megabyte is a regression
47
- * nobody notices until the dialog takes a second to open.
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 writeSearchIndex(records: SearchRecord[], outFile: string): Promise<number>;
50
+ declare function buildSearchIndex(records: SearchRecord[], options?: Partial<Options<SearchRecord>>): string;
50
51
  //#endregion
51
- export { ExtractSearchRecordsOptions, buildSearchIndex, extractSearchRecords, writeSearchIndex };
52
+ export { buildSearchIndex, extractSearchRecords };
@@ -1,23 +1,7 @@
1
- import { SEARCH_INDEX_OPTIONS } from "./search-options.js";
2
- import { mkdir, writeFile } from "node:fs/promises";
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, options = {}) {
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: truncateAtWordBoundary(collapseWhitespace(section.parts.join(" ")), excerptLength)
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" && TRANSPARENT_TAGS.has(node.tagName)) yield* iterateBlocks(node.children);
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 or
252
- * for {@link writeSearchIndex} to put on disk.
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(SEARCH_INDEX_OPTIONS);
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, writeSearchIndex };
223
+ export { buildSearchIndex, extractSearchRecords };
@@ -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 };
@@ -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, toAliasRoute };
57
+ export { DocsSource, createDocsSource, resolveDocsConfig };