@jxsuite/search 0.2.4 → 0.2.6
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/README.md +32 -33
- package/dist/search-index.js +27 -22
- package/package.json +3 -3
- package/src/client.ts +16 -1
- package/src/search-index.ts +28 -16
- package/src/shared.ts +39 -13
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# `@jxsuite/search`
|
|
2
2
|
|
|
3
|
-
> Full-text site search built at compile time from content collections
|
|
3
|
+
> Full-text site search built at compile time from content collections. No server, no third-party
|
|
4
4
|
> service.
|
|
5
5
|
|
|
6
6
|
## Overview
|
|
@@ -9,10 +9,10 @@
|
|
|
9
9
|
index file next to the pages. In the browser, a headless MiniSearch client fetches that file once
|
|
10
10
|
and answers queries synchronously. Two classes, declared in `jx-extension.json`:
|
|
11
11
|
|
|
12
|
-
- **`SearchIndex
|
|
12
|
+
- **`SearchIndex`**: owner of the project.json `search` section. `projectData` normalizes the
|
|
13
13
|
section into `_project.search`; `emit` (specs/extensions.md §8.4) builds the index from the
|
|
14
14
|
already-loaded content collections and hands it to the host to write.
|
|
15
|
-
- **`Search`**
|
|
15
|
+
- **`Search`** is a state class that **lowers** to core defs (specs/extensions.md §8.3): its static
|
|
16
16
|
`lower` rewrites a `timing: "client"` def into a core `$prototype: "Function"` computed, so the
|
|
17
17
|
only extension code reaching the browser is the client bundle.
|
|
18
18
|
|
|
@@ -45,7 +45,7 @@ collection, and rejects unknown keys. `engine` is an enum whose only member toda
|
|
|
45
45
|
`@jxsuite/parser` is what loads the content collections this indexes. Defaults per collection:
|
|
46
46
|
`fields` `["title", "heading", "text"]`, `sections` `true`, `sectionDepth` `3`; `output` defaults
|
|
47
47
|
to `/search-index.json`. `basePath` is normalized to leading **and** trailing slashes, so `"blog"`
|
|
48
|
-
becomes `"/blog/"`. This is the whole configuration surface
|
|
48
|
+
becomes `"/blog/"`. This is the whole configuration surface. There is no CLI of its own.
|
|
49
49
|
|
|
50
50
|
One default does not come from the fragment: `boost` falls back to `{}` in `normalizeSearchConfig`
|
|
51
51
|
(`src/shared.ts`), and the fragment declares no default for it. The schema is the source of truth
|
|
@@ -53,8 +53,8 @@ for the section's shape and for every other default.
|
|
|
53
53
|
|
|
54
54
|
## Build-time emission (`SearchIndex`)
|
|
55
55
|
|
|
56
|
-
`emit` reads `ctx.sections.content`
|
|
57
|
-
|
|
56
|
+
`emit` reads `ctx.sections.content` (the collections the parser already loaded, keyed by name) and
|
|
57
|
+
never re-reads source files. It returns exactly one record, `{ path: <output>, content }`,
|
|
58
58
|
which the compiler writes under `outDir` (step 6e of the site build). The content is one envelope:
|
|
59
59
|
|
|
60
60
|
```json
|
|
@@ -62,7 +62,7 @@ which the compiler writes under `outDir` (step 6e of the site build). The conten
|
|
|
62
62
|
```
|
|
63
63
|
|
|
64
64
|
`fields` is the union of every configured collection's `fields` and `boost` their merged boost
|
|
65
|
-
maps
|
|
65
|
+
maps, baked in so the client needs no configuration of its own. Both cover only the collections
|
|
66
66
|
that actually loaded: a name matching no content collection is skipped before its fields and boosts
|
|
67
67
|
are merged, so a typo silently narrows the index rather than failing the build.
|
|
68
68
|
|
|
@@ -72,11 +72,11 @@ Each content entry yields a **page document** (`heading: ""`, the entry's full e
|
|
|
72
72
|
entry id when frontmatter has no string `title`; `description` falls back to `""`. Entry URLs
|
|
73
73
|
honour `build.trailingSlash` (default `"always"`).
|
|
74
74
|
|
|
75
|
-
Localized collections are indexed per locale
|
|
76
|
-
to
|
|
77
|
-
the others carry a `/<locale>` prefix
|
|
78
|
-
id becomes `<collection>:<locale>:<entry-id>`, and the document carries a canonical
|
|
79
|
-
project with no `i18n` emits documents with no `locale` field at all.
|
|
75
|
+
Localized collections are indexed per locale. Each entry is indexed at the URL its own locale routes
|
|
76
|
+
to: under the default `prefix-except-default` routing the default locale stays unprefixed and only
|
|
77
|
+
the others carry a `/<locale>` prefix, while under `prefix-always` every locale carries one. The
|
|
78
|
+
document id becomes `<collection>:<locale>:<entry-id>`, and the document carries a canonical
|
|
79
|
+
`locale` tag. A project with no `i18n` emits documents with no `locale` field at all.
|
|
80
80
|
|
|
81
81
|
## The `Search` state class
|
|
82
82
|
|
|
@@ -96,8 +96,8 @@ project with no `i18n` emits documents with no `locale` field at all.
|
|
|
96
96
|
|
|
97
97
|
`lower` emits `{ $bundle: ["npm:@jxsuite/search/client"], $prototype: "Function", timing: "client",
|
|
98
98
|
body }`. The body lazy-imports the client once per page onto `globalThis.__jxSearch`, preloads the
|
|
99
|
-
index, flips `state.__jxSearchReady
|
|
100
|
-
|
|
99
|
+
index, flips `state.__jxSearchReady`, and returns results only once `isReady()`. Reading that flag
|
|
100
|
+
is what makes the computed re-run when the client finishes loading. The bundle URL is the
|
|
101
101
|
deterministic, hash-free `/assets/jxsuite-search-client.js`, derived from
|
|
102
102
|
`sidecarAssetPath("npm:@jxsuite/search/client")` in `@jxsuite/schema/asset-paths`, the same mapping
|
|
103
103
|
the compiler's sidecar bundler uses, so neither side depends on the other.
|
|
@@ -105,14 +105,13 @@ the compiler's sidecar bundler uses, so neither side depends on the other.
|
|
|
105
105
|
## The client (`@jxsuite/search/client`)
|
|
106
106
|
|
|
107
107
|
MiniSearch inside (`fuzzy: 0.2`, `prefix: true`), no UI. Core API: `preload(indexUrl?)`,
|
|
108
|
-
`isReady()`, and a synchronous `query(q, { limit, group, pageCap, locale })`
|
|
109
|
-
true, `pageCap` 3 (flat mode). That `limit` default governs a direct `query()` call
|
|
110
|
-
`$src` helper `runSearch` below always passes a fixed `UI_LIMIT` of 20. Grouped mode
|
|
111
|
-
`SearchResultGroup[]`: a page with its matching sections, sorted by best score. Flat mode
|
|
112
|
-
presentation-ready `SearchResult[]` carrying
|
|
113
|
-
|
|
114
|
-
`
|
|
115
|
-
without injecting HTML.
|
|
108
|
+
`isReady()`, and a synchronous `query(q, { limit, group, pageCap, locale })` with defaults `limit`
|
|
109
|
+
8, `group` true, `pageCap` 3 (flat mode). That `limit` default governs a direct `query()` call
|
|
110
|
+
only: the `$src` helper `runSearch` below always passes a fixed `UI_LIMIT` of 20. Grouped mode
|
|
111
|
+
returns `SearchResultGroup[]`: a page with its matching sections, sorted by best score. Flat mode
|
|
112
|
+
returns presentation-ready `SearchResult[]` carrying `crumbs` (title-cased slug ancestors, plus the
|
|
113
|
+
page title on a section row), `titleTokens` and `excerptTokens`. Those two are arrays of
|
|
114
|
+
`{ t, m }` runs where `m: true` marks a match, so renderers highlight without injecting HTML.
|
|
116
115
|
|
|
117
116
|
Compiled components do not go through lowering, so inside a component you use the `$src` state
|
|
118
117
|
conventions instead of the `Search` prototype (`sites/jxsuite.com/components/site-search.json` is
|
|
@@ -144,40 +143,40 @@ other name gets the event.
|
|
|
144
143
|
|
|
145
144
|
## Surprises
|
|
146
145
|
|
|
147
|
-
- **A collection name that is not a loaded content collection warns and is skipped
|
|
146
|
+
- **A collection name that is not a loaded content collection warns and is skipped**. The build
|
|
148
147
|
still succeeds and the envelope still emits, possibly with zero documents. A typo is therefore a
|
|
149
148
|
silently empty search box.
|
|
150
149
|
- **A heading with no `id` never starts a section document.** Heading ids come from the parser
|
|
151
150
|
(specs/parser.md §3.2) and are what make the `#anchor` land on the rendered page; a heading
|
|
152
151
|
without one folds into the enclosing section, as does anything under a heading deeper than
|
|
153
|
-
`sectionDepth`. Text before the first heading belongs to no section
|
|
154
|
-
- **`emit` only runs when the project declares a non-empty `search` section
|
|
155
|
-
section-owner emitters whose value is `null` or `{}` (specs/extensions.md §8.4, "Gating").
|
|
152
|
+
`sectionDepth`. Text before the first heading belongs to no section; the page document covers it.
|
|
153
|
+
- **`emit` only runs when the project declares a non-empty `search` section**, because the compiler
|
|
154
|
+
skips section-owner emitters whose value is `null` or `{}` (specs/extensions.md §8.4, "Gating").
|
|
156
155
|
- **The `public/` copy runs after `emit`**, so a file in `public/` at the same path shadows the
|
|
157
|
-
emitted index
|
|
156
|
+
emitted index (the same semantics as `sitemap.xml`).
|
|
158
157
|
- **Omitting `locale` is not the same as `locale: null`.** Omitted means the page's own language,
|
|
159
158
|
read from `<html lang>`; `null` searches every language. Both `lower` and `resolve` drop the key
|
|
160
159
|
rather than serialize `undefined`. A document with no `locale` answers every search, and region
|
|
161
160
|
is ignored when tags differ only there (`fr` matches `fr-CA`).
|
|
162
|
-
- **`Search.resolve()` returns `[]` outside the browser**, with a warning
|
|
161
|
+
- **`Search.resolve()` returns `[]` outside the browser**, with a warning. Search is a client-side
|
|
163
162
|
interaction, so compiler-timing bakes and dev-server resolve proxying degrade rather than fail.
|
|
164
|
-
- **The index is one file per site and the client is a module-level singleton
|
|
163
|
+
- **The index is one file per site and the client is a module-level singleton**, so the first
|
|
165
164
|
`preload` wins. Multiple collections share the one index; results carry their `collection` name.
|
|
166
165
|
A failed load clears the memo so a later call can retry.
|
|
167
166
|
- **First-party (`@jxsuite/*`) schema fragments are read from the host, not the project's
|
|
168
|
-
`node_modules
|
|
167
|
+
`node_modules`**: schema composition routes every `@jxsuite/*` `.json` ref through host
|
|
169
168
|
resolution and throws when the host cannot resolve it, so installing this package into a project
|
|
170
169
|
alone does not make its `search` section validate. A third-party extension's fragment still
|
|
171
170
|
resolves project-first.
|
|
172
171
|
- **Class names are global across extensions.** `Search` and `SearchIndex` occupy the
|
|
173
172
|
`$prototype`-visible namespace, not a package-scoped one.
|
|
174
173
|
- **`bun run build` bundles only `search-index.ts` and `search-state.ts` into `dist/`.** The client
|
|
175
|
-
is deliberately not built here
|
|
174
|
+
is deliberately not built here, because the site build bundles it from source through the sidecar
|
|
176
175
|
bundler.
|
|
177
176
|
|
|
178
177
|
## Versioning
|
|
179
178
|
|
|
180
|
-
Published to npm as `@jxsuite/search
|
|
179
|
+
Published to npm as `@jxsuite/search`, TypeScript source like every `@jxsuite` package, following
|
|
181
180
|
the monorepo's release train. Its runtime dependencies are `@jxsuite/schema` and `minisearch`;
|
|
182
|
-
extensions may depend on core packages but never the reverse
|
|
181
|
+
extensions may depend on core packages but never the reverse: no core package lists this one as a
|
|
183
182
|
runtime dependency or imports it from `src/`, and `scripts/check-dep-rules.ts` enforces that.
|
package/dist/search-index.js
CHANGED
|
@@ -193,8 +193,9 @@ function collectText(children) {
|
|
|
193
193
|
return parts;
|
|
194
194
|
}
|
|
195
195
|
var HEADING_TAG = /^h([1-6])$/;
|
|
196
|
-
function
|
|
196
|
+
function splitEntry(children, sectionDepth) {
|
|
197
197
|
const sections = [];
|
|
198
|
+
const preambleParts = [];
|
|
198
199
|
let current = null;
|
|
199
200
|
for (const node of children ?? []) {
|
|
200
201
|
const heading = headingOf(node);
|
|
@@ -205,20 +206,24 @@ function splitSections(children, sectionDepth) {
|
|
|
205
206
|
current = { ...heading, parts: [], text: "" };
|
|
206
207
|
continue;
|
|
207
208
|
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
209
|
+
const parts = current ? current.parts : preambleParts;
|
|
210
|
+
if (typeof node === "string") {
|
|
211
|
+
parts.push(node);
|
|
212
|
+
} else if (typeof node === "object" && node !== null) {
|
|
213
|
+
parts.push(jxTreeToText([node]));
|
|
212
214
|
}
|
|
213
215
|
}
|
|
214
216
|
if (current) {
|
|
215
217
|
sections.push(finishSection(current));
|
|
216
218
|
}
|
|
217
|
-
return sections;
|
|
219
|
+
return { preamble: normalizeText(preambleParts), sections };
|
|
218
220
|
}
|
|
219
221
|
function finishSection(section) {
|
|
220
222
|
const { heading, anchor, depth } = section;
|
|
221
|
-
return { anchor, depth, heading, text: section.parts
|
|
223
|
+
return { anchor, depth, heading, text: normalizeText(section.parts) };
|
|
224
|
+
}
|
|
225
|
+
function normalizeText(parts) {
|
|
226
|
+
return parts.join(" ").replaceAll(/\s+/g, " ").trim();
|
|
222
227
|
}
|
|
223
228
|
function headingOf(node) {
|
|
224
229
|
if (typeof node !== "object" || node === null || !node.tagName) {
|
|
@@ -266,6 +271,8 @@ var SearchIndex = {
|
|
|
266
271
|
const description = typeof data.description === "string" ? data.description : "";
|
|
267
272
|
const children = entry.$children;
|
|
268
273
|
const documentId = locale === undefined ? `${name}:${entry.id}` : `${name}:${locale}:${entry.id}`;
|
|
274
|
+
const split = collection.sections ? splitEntry(children, collection.sectionDepth) : undefined;
|
|
275
|
+
const sections = split?.sections ?? [];
|
|
269
276
|
documents.push({
|
|
270
277
|
id: documentId,
|
|
271
278
|
collection: name,
|
|
@@ -274,23 +281,21 @@ var SearchIndex = {
|
|
|
274
281
|
title,
|
|
275
282
|
description,
|
|
276
283
|
heading: "",
|
|
277
|
-
text: jxTreeToText(children),
|
|
284
|
+
text: sections.length > 0 && split ? split.preamble : jxTreeToText(children),
|
|
278
285
|
...locale === undefined ? {} : { locale }
|
|
279
286
|
});
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
});
|
|
293
|
-
}
|
|
287
|
+
for (const section of sections) {
|
|
288
|
+
documents.push({
|
|
289
|
+
id: `${documentId}#${section.anchor}`,
|
|
290
|
+
collection: name,
|
|
291
|
+
slug: entry.id,
|
|
292
|
+
url: `${url}#${section.anchor}`,
|
|
293
|
+
title,
|
|
294
|
+
description,
|
|
295
|
+
heading: section.heading,
|
|
296
|
+
text: section.text,
|
|
297
|
+
...locale === undefined ? {} : { locale }
|
|
298
|
+
});
|
|
294
299
|
}
|
|
295
300
|
}
|
|
296
301
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxsuite/search",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.6",
|
|
4
4
|
"description": "Static site search for Jx: build-time index emission from content collections and a headless MiniSearch browser client",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -33,10 +33,10 @@
|
|
|
33
33
|
"build": "bun run prepare",
|
|
34
34
|
"test": "bun test --isolate",
|
|
35
35
|
"test:coverage": "bun test --isolate --coverage",
|
|
36
|
-
"upgrade": "
|
|
36
|
+
"upgrade": "bun update --latest && bun install"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@jxsuite/schema": "^1.
|
|
39
|
+
"@jxsuite/schema": "^2.1.0",
|
|
40
40
|
"minisearch": "^7.2.0"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
package/src/client.ts
CHANGED
|
@@ -99,6 +99,14 @@ interface ClientState {
|
|
|
99
99
|
|
|
100
100
|
const state: ClientState = { indexUrl: "/search-index.json", loading: null, mini: null };
|
|
101
101
|
|
|
102
|
+
/**
|
|
103
|
+
* Documents indexed per chunk before yielding the main thread.
|
|
104
|
+
*
|
|
105
|
+
* Small enough that one chunk stays well inside a frame on a low-end phone, large enough that the
|
|
106
|
+
* per-chunk scheduling overhead stays noise against the tokenising itself.
|
|
107
|
+
*/
|
|
108
|
+
const INDEX_CHUNK_SIZE = 50;
|
|
109
|
+
|
|
102
110
|
/** `text` is stored so flat results can carry a highlighted excerpt of the body. */
|
|
103
111
|
const STORE_FIELDS = [
|
|
104
112
|
"collection",
|
|
@@ -291,7 +299,14 @@ export function preload(indexUrl?: string): Promise<void> {
|
|
|
291
299
|
},
|
|
292
300
|
storeFields: STORE_FIELDS,
|
|
293
301
|
});
|
|
294
|
-
|
|
302
|
+
/*
|
|
303
|
+
* `addAll` is synchronous: it tokenises the entire corpus in one uninterruptible task, which on
|
|
304
|
+
* jxsuite.com's 1,342-document index measured 181 ms on a desktop and surfaced as a ~1.2 s
|
|
305
|
+
* "Unattributable" long task under Lighthouse's mobile CPU throttle — the single largest
|
|
306
|
+
* contributor to Total Blocking Time. `addAllAsync` does identical work and yields between
|
|
307
|
+
* chunks, so the same milliseconds no longer block paint or input.
|
|
308
|
+
*/
|
|
309
|
+
await mini.addAllAsync(envelope.documents, { chunkSize: INDEX_CHUNK_SIZE });
|
|
295
310
|
state.mini = mini;
|
|
296
311
|
})().catch((error: unknown) => {
|
|
297
312
|
state.loading = null;
|
package/src/search-index.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { canonicalizeLocale, localeUrlPrefix, resolveI18n } from "@jxsuite/schema/locale";
|
|
14
|
-
import { entryUrl, jxTreeToText, normalizeSearchConfig,
|
|
14
|
+
import { entryUrl, jxTreeToText, normalizeSearchConfig, splitEntry } from "./shared.ts";
|
|
15
15
|
import type { NormalizedSearchConfig } from "./shared.ts";
|
|
16
16
|
import type { ContentLoaderEntry, JxElement, ProjectConfig } from "@jxsuite/schema/types";
|
|
17
17
|
|
|
@@ -123,6 +123,20 @@ export const SearchIndex = {
|
|
|
123
123
|
const documentId =
|
|
124
124
|
locale === undefined ? `${name}:${entry.id}` : `${name}:${locale}:${entry.id}`;
|
|
125
125
|
|
|
126
|
+
/*
|
|
127
|
+
* The preamble and the sections partition the entry, so the page document carries only the
|
|
128
|
+
* preamble when sections were produced — emitting the full text as well indexed the whole
|
|
129
|
+
* corpus twice, doubling both the bytes a visitor downloads and the tokenising their main
|
|
130
|
+
* thread does before search can answer.
|
|
131
|
+
*
|
|
132
|
+
* An entry that yielded no sections keeps its full text: it has no headings, or none with
|
|
133
|
+
* an id, and nothing else would index it.
|
|
134
|
+
*/
|
|
135
|
+
const split = collection.sections
|
|
136
|
+
? splitEntry(children, collection.sectionDepth)
|
|
137
|
+
: undefined;
|
|
138
|
+
const sections = split?.sections ?? [];
|
|
139
|
+
|
|
126
140
|
documents.push({
|
|
127
141
|
id: documentId,
|
|
128
142
|
collection: name,
|
|
@@ -131,24 +145,22 @@ export const SearchIndex = {
|
|
|
131
145
|
title,
|
|
132
146
|
description,
|
|
133
147
|
heading: "",
|
|
134
|
-
text: jxTreeToText(children),
|
|
148
|
+
text: sections.length > 0 && split ? split.preamble : jxTreeToText(children),
|
|
135
149
|
...(locale === undefined ? {} : { locale }),
|
|
136
150
|
});
|
|
137
151
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
});
|
|
151
|
-
}
|
|
152
|
+
for (const section of sections) {
|
|
153
|
+
documents.push({
|
|
154
|
+
id: `${documentId}#${section.anchor}`,
|
|
155
|
+
collection: name,
|
|
156
|
+
slug: entry.id,
|
|
157
|
+
url: `${url}#${section.anchor}`,
|
|
158
|
+
title,
|
|
159
|
+
description,
|
|
160
|
+
heading: section.heading,
|
|
161
|
+
text: section.text,
|
|
162
|
+
...(locale === undefined ? {} : { locale }),
|
|
163
|
+
});
|
|
152
164
|
}
|
|
153
165
|
}
|
|
154
166
|
}
|
package/src/shared.ts
CHANGED
|
@@ -114,18 +114,30 @@ export interface ContentSection {
|
|
|
114
114
|
|
|
115
115
|
const HEADING_TAG = /^h([1-6])$/;
|
|
116
116
|
|
|
117
|
+
/** A content entry split into the text before its first section and the sections themselves. */
|
|
118
|
+
export interface SplitEntry {
|
|
119
|
+
/** Text before the first section-starting heading. Empty when the entry opens with one. */
|
|
120
|
+
preamble: string;
|
|
121
|
+
sections: ContentSection[];
|
|
122
|
+
}
|
|
123
|
+
|
|
117
124
|
/**
|
|
118
|
-
* Split a content entry's rendered tree into heading sections
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
125
|
+
* Split a content entry's rendered tree into a preamble and heading sections. A heading of depth ≤
|
|
126
|
+
* `sectionDepth` (with an assigned `id` — see parser.md §3.2) starts a section; its text runs until
|
|
127
|
+
* the next section-starting heading. Deeper headings' text stays inside the enclosing section.
|
|
128
|
+
*
|
|
129
|
+
* The preamble is returned rather than discarded because together the two **partition** the entry:
|
|
130
|
+
* a page document that also carried the full text stored the whole corpus a second time. On
|
|
131
|
+
* jxsuite.com that was 922,007 characters of page text against 899,502 of section text, of which
|
|
132
|
+
* only ~22,505 (2.4%) was preamble no section already covered — an index twice the size it needed
|
|
133
|
+
* to be, and twice the parse and tokenise cost on every visitor's main thread.
|
|
123
134
|
*/
|
|
124
|
-
export function
|
|
135
|
+
export function splitEntry(
|
|
125
136
|
children: (JxElement | string)[] | undefined,
|
|
126
137
|
sectionDepth: number,
|
|
127
|
-
):
|
|
138
|
+
): SplitEntry {
|
|
128
139
|
const sections: ContentSection[] = [];
|
|
140
|
+
const preambleParts: string[] = [];
|
|
129
141
|
let current: (ContentSection & { parts: string[] }) | null = null;
|
|
130
142
|
|
|
131
143
|
for (const node of children ?? []) {
|
|
@@ -137,21 +149,35 @@ export function splitSections(
|
|
|
137
149
|
current = { ...heading, parts: [], text: "" };
|
|
138
150
|
continue;
|
|
139
151
|
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
152
|
+
const parts = current ? current.parts : preambleParts;
|
|
153
|
+
if (typeof node === "string") {
|
|
154
|
+
parts.push(node);
|
|
155
|
+
} else if (typeof node === "object" && node !== null) {
|
|
156
|
+
parts.push(jxTreeToText([node]));
|
|
144
157
|
}
|
|
145
158
|
}
|
|
146
159
|
if (current) {
|
|
147
160
|
sections.push(finishSection(current));
|
|
148
161
|
}
|
|
149
|
-
return sections;
|
|
162
|
+
return { preamble: normalizeText(preambleParts), sections };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** {@link splitEntry}, sections only. */
|
|
166
|
+
export function splitSections(
|
|
167
|
+
children: (JxElement | string)[] | undefined,
|
|
168
|
+
sectionDepth: number,
|
|
169
|
+
): ContentSection[] {
|
|
170
|
+
return splitEntry(children, sectionDepth).sections;
|
|
150
171
|
}
|
|
151
172
|
|
|
152
173
|
function finishSection(section: ContentSection & { parts: string[] }): ContentSection {
|
|
153
174
|
const { heading, anchor, depth } = section;
|
|
154
|
-
return { anchor, depth, heading, text: section.parts
|
|
175
|
+
return { anchor, depth, heading, text: normalizeText(section.parts) };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Join collected text parts into one whitespace-normalized string. */
|
|
179
|
+
function normalizeText(parts: readonly string[]): string {
|
|
180
|
+
return parts.join(" ").replaceAll(/\s+/g, " ").trim();
|
|
155
181
|
}
|
|
156
182
|
|
|
157
183
|
function headingOf(
|