@jxsuite/search 0.4.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -57,13 +57,13 @@ Localized collections are indexed per locale. Each entry is indexed at the URL i
57
57
  }
58
58
  ```
59
59
 
60
- `query` takes a literal string or a `$ref` into page state (`#/state/ui/q` compiles to `state.ui.q`; a `$ref` outside `#/state/` degrades to `""`). Other parameters: `limit` (8), `group` (true), `index` (index-URL override), `locale`. The index URL resolves def `index` → `search.output` → `/search-index.json`.
60
+ `query` takes a literal string or a `$ref` into page state (`#/state/ui/q` compiles to `state.ui.q`; a `$ref` outside `#/state/` degrades to `""`). Other parameters: `limit` (8), `group` (true), `index` (index-URL override), `locale`, `scope`. `scope` takes a literal or a `$ref` the same way; a literal is baked into the query options, a `$ref` is read from state on every run. The index URL resolves def `index` → `search.output` → `/search-index.json`.
61
61
 
62
62
  `lower` emits `{ $bundle: ["npm:@jxsuite/search/client"], $prototype: "Function", timing: "client", body }`. The body lazy-imports the client once per page onto `globalThis.__jxSearch`, preloads the index, flips `state.__jxSearchReady`, and returns results only once `isReady()`. Reading that flag is what makes the computed re-run when the client finishes loading. The bundle URL is the deterministic, hash-free `/assets/jxsuite-search-client.js`, derived from `sidecarAssetPath("npm:@jxsuite/search/client")` in `@jxsuite/schema/asset-paths`, the same mapping the compiler's sidecar bundler uses, so neither side depends on the other.
63
63
 
64
64
  ## The client (`@jxsuite/search/client`)
65
65
 
66
- MiniSearch inside (`fuzzy: 0.2`, `prefix: true`), no UI. Core API: `preload(indexUrl?)`, `isReady()`, and a synchronous `query(q, { limit, group, pageCap, locale })` with defaults `limit` 8, `group` true, `pageCap` 3 (flat mode). That `limit` default governs a direct `query()` call only: the `$src` helper `runSearch` below always passes a fixed `UI_LIMIT` of 20. Grouped mode returns `SearchResultGroup[]`: a page with its matching sections, sorted by best score. Flat mode returns presentation-ready `SearchResult[]` carrying `crumbs` (title-cased slug ancestors, plus the page title on a section row), `titleTokens` and `excerptTokens`. Those two are arrays of `{ t, m }` runs where `m: true` marks a match, so renderers highlight without injecting HTML.
66
+ MiniSearch inside (`fuzzy: 0.2`, `prefix: true`), no UI. Core API: `preload(indexUrl?)`, `isReady()`, and a synchronous `query(q, { limit, group, pageCap, locale, scope })` with defaults `limit` 8, `group` true, `pageCap` 3 (flat mode). That `limit` default governs a direct `query()` call only: the `$src` helper `runSearch` below always passes a fixed `UI_LIMIT` of 20. Grouped mode returns `SearchResultGroup[]`: a page with its matching sections, sorted by best score. Flat mode returns presentation-ready `SearchResult[]` carrying `crumbs` (title-cased slug ancestors, plus the page title on a section row), `titleTokens` and `excerptTokens`. Those two are arrays of `{ t, m }` runs where `m: true` marks a match, so renderers highlight without injecting HTML.
67
67
 
68
68
  Compiled components do not go through lowering, so inside a component you use the `$src` state conventions instead of the `Search` prototype (`sites/jxsuite.com/components/site-search.json` is the working example):
69
69
 
@@ -84,7 +84,7 @@ Compiled components do not go through lowering, so inside a component you use th
84
84
  }
85
85
  ```
86
86
 
87
- `searchInit(state)` returns `true` immediately, preloads (honouring `state.searchIndexUrl`), then sets `state.searchReady` and re-runs a pending `state.searchQuery`. `runSearch(state, event?)` reads the query from `event.target.value` (else `state.searchQuery`) and publishes `state.searchResults`, `state.searchCount` and `state.searchActive`. `$src` parameters bind **by name, not position** (spec.md §5.3): a parameter literally named `state` gets reactive state, any other name gets the event.
87
+ `searchInit(state)` returns `true` immediately, preloads (honouring `state.searchIndexUrl`), then sets `state.searchReady` and re-runs a pending `state.searchQuery`. `runSearch(state, event?)` reads the query from `event.target.value` (else `state.searchQuery`), searches within `state.searchScope` when it is a non-empty string, and publishes `state.searchResults`, `state.searchCount` and `state.searchActive`. `$src` parameters bind **by name, not position** (spec.md §5.3): a parameter literally named `state` gets reactive state, any other name gets the event.
88
88
 
89
89
  ## Surprises
90
90
 
@@ -93,8 +93,9 @@ Compiled components do not go through lowering, so inside a component you use th
93
93
  - **`emit` only runs when the project declares a non-empty `search` section**, because the compiler skips section-owner emitters whose value is `null` or `{}` (specs/extensions.md §8.4, "Gating").
94
94
  - **The `public/` copy runs after `emit`**, so a file in `public/` at the same path shadows the emitted index (the same semantics as `sitemap.xml`).
95
95
  - **Omitting `locale` is not the same as `locale: null`.** Omitted means the page's own language, read from `<html lang>`; `null` searches every language. Both `lower` and `resolve` drop the key rather than serialize `undefined`. A document with no `locale` answers every search, and region is ignored when tags differ only there (`fr` matches `fr-CA`).
96
+ - **`scope` is a URL prefix, tested the way the Web App Manifest tests "within scope"**: same origin, and the result's path starts with the scope's path, with query and fragment ignored. It is a string prefix, so `/docs` also holds `/docs-old/`; end a folder scope with `/`. A relative scope resolves against `document.baseURI`, a scope that is not a URL holds nothing, and the filter runs before `limit`, so a scoped search still fills its rows.
96
97
  - **`Search.resolve()` returns `[]` outside the browser**, with a warning. Search is a client-side interaction, so compiler-timing bakes and dev-server resolve proxying degrade rather than fail.
97
- - **The index is one file per site and the client is a module-level singleton**, so the first `preload` wins. Multiple collections share the one index; results carry their `collection` name. A failed load clears the memo so a later call can retry.
98
+ - **The index is one file per site and the client is a module-level singleton**, so the first `preload` wins. Multiple collections share the one index; results carry their `collection` name. To search one part of a site, pass a `scope` rather than splitting the index. A failed load clears the memo so a later call can retry.
98
99
  - **First-party (`@jxsuite/*`) schema fragments are read from the host, not the project's `node_modules`**: schema composition routes every `@jxsuite/*` `.json` ref through host resolution and throws when the host cannot resolve it, so installing this package into a project alone does not make its `search` section validate. A third-party extension's fragment still resolves project-first.
99
100
  - **Class names are global across extensions.** `Search` and `SearchIndex` occupy the `$prototype`-visible namespace, not a package-scoped one.
100
101
  - **`bun run build` bundles only `search-index.ts` and `search-state.ts` into `dist/`.** The client is deliberately not built here, because the site build bundles it from source through the sidecar bundler.
@@ -84,14 +84,17 @@ var CLIENT_SPECIFIER = "npm:@jxsuite/search/client";
84
84
  function inBrowser() {
85
85
  return typeof document !== "undefined" && isFunction(globalThis.fetch);
86
86
  }
87
- function queryExpression(query) {
88
- if (isNonNullObject(query) && isString(query.$ref)) {
89
- const path = query.$ref.replace(/^#\//, "").split("/");
87
+ function stateExpression(value) {
88
+ if (isNonNullObject(value) && isString(value.$ref)) {
89
+ const path = value.$ref.replace(/^#\//, "").split("/");
90
90
  if (path[0] === "state" && path.length > 1) {
91
91
  return ["state", ...path.slice(1)].join(".");
92
92
  }
93
93
  }
94
- return JSON.stringify(isString(query) ? query : "");
94
+ return null;
95
+ }
96
+ function queryExpression(query) {
97
+ return stateExpression(query) ?? JSON.stringify(isString(query) ? query : "");
95
98
  }
96
99
 
97
100
  class Search {
@@ -115,6 +118,9 @@ class Search {
115
118
  if (this.config.locale !== undefined) {
116
119
  opts.locale = this.config.locale;
117
120
  }
121
+ if (isString(this.config.scope)) {
122
+ opts.scope = this.config.scope;
123
+ }
118
124
  return client.query(query, opts);
119
125
  }
120
126
  indexUrl() {
@@ -127,7 +133,11 @@ class Search {
127
133
  if (def.locale !== undefined) {
128
134
  queryOptions.locale = def.locale;
129
135
  }
130
- const opts = JSON.stringify(queryOptions);
136
+ if (isString(def.scope)) {
137
+ queryOptions.scope = def.scope;
138
+ }
139
+ const scopeRef = stateExpression(def.scope);
140
+ const opts = scopeRef ? `Object.assign(${JSON.stringify(queryOptions)}, { scope: ${scopeRef} })` : JSON.stringify(queryOptions);
131
141
  const body = [
132
142
  "void state.__jxSearchReady;",
133
143
  "if (!globalThis.__jxSearch) {",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/search",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
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": {
@@ -36,7 +36,7 @@
36
36
  "upgrade": "bun update --latest && bun install"
37
37
  },
38
38
  "dependencies": {
39
- "@jxsuite/schema": "^4.1.0",
39
+ "@jxsuite/schema": "^5.0.0",
40
40
  "minisearch": "^7.2.0"
41
41
  },
42
42
  "devDependencies": {
@@ -37,6 +37,11 @@
37
37
  "identifier": "locale",
38
38
  "type": { "type": ["string", "null"] },
39
39
  "description": "Language to search in \u2014 omitted, the page's own (<html lang>); null searches every language"
40
+ },
41
+ "scope": {
42
+ "identifier": "scope",
43
+ "type": { "type": "string" },
44
+ "description": "URL prefix a result must fall within (e.g. /example/docs/): a literal or a $ref into page state; omitted, the whole index"
40
45
  }
41
46
  },
42
47
  "constructor": {
@@ -46,7 +51,8 @@
46
51
  { "$ref": "#/$defs/parameters/limit" },
47
52
  { "$ref": "#/$defs/parameters/group" },
48
53
  { "$ref": "#/$defs/parameters/index" },
49
- { "$ref": "#/$defs/parameters/locale" }
54
+ { "$ref": "#/$defs/parameters/locale" },
55
+ { "$ref": "#/$defs/parameters/scope" }
50
56
  ],
51
57
  "body": ["this.config = config;"]
52
58
  },
package/src/client.ts CHANGED
@@ -92,6 +92,14 @@ export interface QueryOptions {
92
92
  * `null` searches every language.
93
93
  */
94
94
  locale?: string | null;
95
+ /**
96
+ * The part of the site to search: a URL that a result's URL must be within, by the Web App
97
+ * Manifest's "within scope" test (same origin, and the result's path starts with the scope's).
98
+ * Relative values resolve against the document's base URL, so `/example/docs/` keeps a project's
99
+ * pages to that project's documentation while one index serves the whole site. Omitted or `""`
100
+ * searches everything; a value that is not a URL holds nothing.
101
+ */
102
+ scope?: string;
95
103
  }
96
104
 
97
105
  interface ClientState {
@@ -155,6 +163,48 @@ function pageKey(hit: RawHit): string {
155
163
  return `${hit.collection}:${hit.locale ?? ""}:${hit.slug}`;
156
164
  }
157
165
 
166
+ /**
167
+ * The base relative URLs resolve against off a document — any absolute URL serves, it is never
168
+ * shown.
169
+ */
170
+ const NO_DOCUMENT_BASE = "https://jx.invalid/";
171
+
172
+ /**
173
+ * `url` resolved against `base`, or null when it is not a URL. `URL.parse` says the same thing, but
174
+ * a visitor's browser older than 2024 does not have it, and this module runs in that browser.
175
+ */
176
+ function parseUrl(url: string, base: string): URL | null {
177
+ try {
178
+ return new URL(url, base);
179
+ } catch {
180
+ return null;
181
+ }
182
+ }
183
+
184
+ /**
185
+ * A test for "is this result's URL within `scope`", the Web App Manifest's algorithm: same origin,
186
+ * and the result's path starts with the scope's path. Query and fragment are ignored on both sides,
187
+ * so a section hit (`/docs/page/#heading`) is within the scope its page is.
188
+ *
189
+ * The comparison is a string prefix, as the manifest's is: `/docs` also holds `/docs-old/`, and a
190
+ * scope meant to stop at a folder ends in `/`.
191
+ */
192
+ function scopeTest(scope: string): (url: string) => boolean {
193
+ const base = typeof document === "undefined" ? NO_DOCUMENT_BASE : document.baseURI;
194
+ const within = parseUrl(scope, base);
195
+ if (!within) {
196
+ return () => false;
197
+ }
198
+ return (url) => {
199
+ const target = parseUrl(url, base);
200
+ return (
201
+ target !== null &&
202
+ target.origin === within.origin &&
203
+ target.pathname.startsWith(within.pathname)
204
+ );
205
+ };
206
+ }
207
+
158
208
  function localeAnswers(docLocale: string | undefined, want: string): boolean {
159
209
  if (docLocale === undefined || want === "") {
160
210
  return true;
@@ -389,6 +439,12 @@ export function query(q: string, opts: QueryOptions = {}): SearchResultGroup[] |
389
439
  * copy of the page they are already on — ranked first, because it matched the same words.
390
440
  */
391
441
  const want = opts.locale === undefined ? pageLocale() : (opts.locale ?? "");
442
+ /*
443
+ * Scoped to a URL only when the caller names one. Filtering here, before `limit` is applied, is
444
+ * what makes one index serve many parts of a site: a scope cut after the limit would hand back
445
+ * whatever few rows of the top results happened to fall inside it.
446
+ */
447
+ const inScope = isString(opts.scope) && opts.scope ? scopeTest(opts.scope) : () => true;
392
448
  /*
393
449
  * SAFETY: MiniSearch copies every name in STORE_FIELDS from the indexed document onto its hit,
394
450
  * and every document came from the emitted envelope, whose SearchDocuments carry collection,
@@ -396,8 +452,8 @@ export function query(q: string, opts: QueryOptions = {}): SearchResultGroup[] |
396
452
  * are optional in RawHit). MiniSearch types the hit's `id` as `any`; it is the document's own
397
453
  * `id`, which SearchDocument declares a string.
398
454
  */
399
- const raw = (state.mini.search(text) as RawHit[]).filter((hit) =>
400
- localeAnswers(hit.locale, want),
455
+ const raw = (state.mini.search(text) as RawHit[]).filter(
456
+ (hit) => localeAnswers(hit.locale, want) && inScope(hit.url),
401
457
  );
402
458
  if (!group) {
403
459
  const pageCap = opts.pageCap ?? PAGE_CAP;
@@ -449,6 +505,8 @@ export function query(q: string, opts: QueryOptions = {}): SearchResultGroup[] |
449
505
  export interface SearchComponentState {
450
506
  /** Index URL override for `preload`. */
451
507
  searchIndexUrl?: JsonValue | undefined;
508
+ /** The part of the site to search (`QueryOptions.scope`); `""` or absent searches everything. */
509
+ searchScope?: JsonValue | undefined;
452
510
  /** The query text; `runSearch` writes back the query it ran. */
453
511
  searchQuery?: JsonValue | undefined;
454
512
  /** Set true once the index is loaded. */
@@ -482,8 +540,9 @@ const UI_LIMIT = 20;
482
540
 
483
541
  /**
484
542
  * State-convention query handler for `$src` component use: reads the query from the input event (or
485
- * `state.searchQuery`), stores flat highlighted rows on `state.searchResults`, publishes the row
486
- * count as `state.searchCount`, and resets the active-row index.
543
+ * `state.searchQuery`), searches within `state.searchScope` when it is a non-empty string, stores
544
+ * flat highlighted rows on `state.searchResults`, publishes the row count as `state.searchCount`,
545
+ * and resets the active-row index.
487
546
  */
488
547
  export function runSearch(
489
548
  componentState: SearchComponentState,
@@ -492,7 +551,11 @@ export function runSearch(
492
551
  const fromEvent = event?.target && isString(event.target.value) ? event.target.value : null;
493
552
  const q = fromEvent ?? (isString(componentState.searchQuery) ? componentState.searchQuery : "");
494
553
  componentState.searchQuery = q;
495
- const results = query(q, { group: false, limit: UI_LIMIT });
554
+ const opts: QueryOptions & { group: false } = { group: false, limit: UI_LIMIT };
555
+ if (isString(componentState.searchScope)) {
556
+ opts.scope = componentState.searchScope;
557
+ }
558
+ const results = query(q, opts);
496
559
  componentState.searchResults = results;
497
560
  componentState.searchCount = results.length;
498
561
  componentState.searchActive = 0;
@@ -42,6 +42,12 @@ export interface SearchDef {
42
42
  * reader the English copy of the page they are on. `null` searches every language.
43
43
  */
44
44
  locale?: string | null;
45
+ /**
46
+ * The part of the site to search: a URL prefix a result's URL must fall within (the client's
47
+ * `QueryOptions.scope`). A literal string or a `$ref` into page state, so a layout can scope each
48
+ * page to its own section of the site. Omitted, the whole index is searched.
49
+ */
50
+ scope?: string | { $ref: string };
45
51
  default?: JsonValue;
46
52
  /** Any other key an author wrote on the def: document JSON this class does not read. */
47
53
  [key: string]: JsonValue | undefined;
@@ -74,15 +80,20 @@ function inBrowser(): boolean {
74
80
  return typeof document !== "undefined" && isFunction(globalThis.fetch);
75
81
  }
76
82
 
77
- /** Compile a def's `query` into a client JS expression (`$ref` → state path, literal → string). */
78
- function queryExpression(query: SearchDef["query"]): string {
79
- if (isNonNullObject(query) && isString(query.$ref)) {
80
- const path = query.$ref.replace(/^#\//, "").split("/");
83
+ /** A `$ref` into page state as a client JS expression (`#/state/a/b` → `state.a.b`), else null. */
84
+ function stateExpression(value: SearchDef["query"] | SearchDef["scope"]): string | null {
85
+ if (isNonNullObject(value) && isString(value.$ref)) {
86
+ const path = value.$ref.replace(/^#\//, "").split("/");
81
87
  if (path[0] === "state" && path.length > 1) {
82
88
  return ["state", ...path.slice(1)].join(".");
83
89
  }
84
90
  }
85
- return JSON.stringify(isString(query) ? query : "");
91
+ return null;
92
+ }
93
+
94
+ /** Compile a def's `query` into a client JS expression (`$ref` → state path, literal → string). */
95
+ function queryExpression(query: SearchDef["query"]): string {
96
+ return stateExpression(query) ?? JSON.stringify(isString(query) ? query : "");
86
97
  }
87
98
 
88
99
  export class Search {
@@ -114,6 +125,9 @@ export class Search {
114
125
  if (this.config.locale !== undefined) {
115
126
  opts.locale = this.config.locale;
116
127
  }
128
+ if (isString(this.config.scope)) {
129
+ opts.scope = this.config.scope;
130
+ }
117
131
  return client.query(query, opts);
118
132
  }
119
133
 
@@ -139,7 +153,17 @@ export class Search {
139
153
  if (def.locale !== undefined) {
140
154
  queryOptions.locale = def.locale;
141
155
  }
142
- const opts = JSON.stringify(queryOptions);
156
+ if (isString(def.scope)) {
157
+ queryOptions.scope = def.scope;
158
+ }
159
+ /*
160
+ * A `$ref` scope is read when the query runs, not baked in: the options literal is extended with
161
+ * the state path, so the computed re-runs when the scope changes, as it does for the query.
162
+ */
163
+ const scopeRef = stateExpression(def.scope);
164
+ const opts = scopeRef
165
+ ? `Object.assign(${JSON.stringify(queryOptions)}, { scope: ${scopeRef} })`
166
+ : JSON.stringify(queryOptions);
143
167
  const body = [
144
168
  // Reading the ready flag makes the computed re-run when the client finishes loading.
145
169
  "void state.__jxSearchReady;",