@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # `@jxsuite/search`
2
2
 
3
- > Full-text site search built at compile time from content collections — no server, no third-party
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`** — owner of the project.json `search` section. `projectData` normalizes the
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`** — a state class that **lowers** to core defs (specs/extensions.md §8.3): its static
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 — there is no CLI of its own.
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` — the collections the parser already loaded, keyed by name —
57
- and never re-reads source files. It returns exactly one record, `{ path: <output>, content }`,
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 — baked in so the client needs no configuration of its own. Both cover only the collections
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: 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; under `prefix-always` every locale carries one — the document
78
- id becomes `<collection>:<locale>:<entry-id>`, and the document carries a canonical `locale` tag. A
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` — reading that flag is what makes the computed re-run when the
100
- client finishes loading — and returns results only once `isReady()`. The bundle URL is the
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 })` — `limit` 8, `group`
109
- true, `pageCap` 3 (flat mode). That `limit` default governs a direct `query()` call only: the
110
- `$src` helper `runSearch` below always passes a fixed `UI_LIMIT` of 20. Grouped mode returns
111
- `SearchResultGroup[]`: a page with its matching sections, sorted by best score. Flat mode returns
112
- presentation-ready `SearchResult[]` carrying
113
- `crumbs` (title-cased slug ancestors, plus the page title on a section row), `titleTokens` and
114
- `excerptTokens` — arrays of `{ t, m }` runs where `m: true` marks a match, so renderers highlight
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** — the build
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 — the page document covers it.
154
- - **`emit` only runs when the project declares a non-empty `search` section** — the compiler skips
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 — the same semantics as `sitemap.xml`.
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 — search is a client-side
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** — the first
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`** — schema composition routes every `@jxsuite/*` `.json` ref through host
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 — the site build bundles it from source through the sidecar
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` — TypeScript source, like every `@jxsuite` package, following
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 — no core package lists this one as a
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.
@@ -193,8 +193,9 @@ function collectText(children) {
193
193
  return parts;
194
194
  }
195
195
  var HEADING_TAG = /^h([1-6])$/;
196
- function splitSections(children, sectionDepth) {
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
- if (current && typeof node === "string") {
209
- current.parts.push(node);
210
- } else if (current && typeof node === "object" && node !== null) {
211
- current.parts.push(jxTreeToText([node]));
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.join(" ").replaceAll(/\s+/g, " ").trim() };
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
- if (collection.sections) {
281
- for (const section of splitSections(children, collection.sectionDepth)) {
282
- documents.push({
283
- id: `${documentId}#${section.anchor}`,
284
- collection: name,
285
- slug: entry.id,
286
- url: `${url}#${section.anchor}`,
287
- title,
288
- description,
289
- heading: section.heading,
290
- text: section.text,
291
- ...locale === undefined ? {} : { locale }
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.4",
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": "bunx npm-check-updates -u && bun install"
36
+ "upgrade": "bun update --latest && bun install"
37
37
  },
38
38
  "dependencies": {
39
- "@jxsuite/schema": "^1.9.0",
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
- mini.addAll(envelope.documents);
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;
@@ -11,7 +11,7 @@
11
11
  */
12
12
 
13
13
  import { canonicalizeLocale, localeUrlPrefix, resolveI18n } from "@jxsuite/schema/locale";
14
- import { entryUrl, jxTreeToText, normalizeSearchConfig, splitSections } from "./shared.ts";
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
- if (collection.sections) {
139
- for (const section of splitSections(children, collection.sectionDepth)) {
140
- documents.push({
141
- id: `${documentId}#${section.anchor}`,
142
- collection: name,
143
- slug: entry.id,
144
- url: `${url}#${section.anchor}`,
145
- title,
146
- description,
147
- heading: section.heading,
148
- text: section.text,
149
- ...(locale === undefined ? {} : { locale }),
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 for section-level search documents. A
119
- * heading of depth ≤ `sectionDepth` (with an assigned `id` — see parser.md §3.2) starts a section;
120
- * its text runs until the next section-starting heading. Content before the first heading belongs
121
- * to no section (the page-level document already indexes the full text). Deeper headings' text
122
- * stays inside the enclosing section.
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 splitSections(
135
+ export function splitEntry(
125
136
  children: (JxElement | string)[] | undefined,
126
137
  sectionDepth: number,
127
- ): ContentSection[] {
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
- if (current && typeof node === "string") {
141
- current.parts.push(node);
142
- } else if (current && typeof node === "object" && node !== null) {
143
- current.parts.push(jxTreeToText([node]));
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.join(" ").replaceAll(/\s+/g, " ").trim() };
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(