@jxsuite/search 0.4.3 → 0.5.1
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 +5 -4
- package/dist/search-state.js +15 -5
- package/package.json +2 -2
- package/src/Search.class.json +7 -1
- package/src/client.ts +68 -5
- package/src/search-state.ts +30 -6
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.
|
package/dist/search-state.js
CHANGED
|
@@ -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
|
|
88
|
-
if (isNonNullObject(
|
|
89
|
-
const path =
|
|
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
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.5.1",
|
|
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": "^5.
|
|
39
|
+
"@jxsuite/schema": "^5.1.0",
|
|
40
40
|
"minisearch": "^7.2.0"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
package/src/Search.class.json
CHANGED
|
@@ -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(
|
|
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`),
|
|
486
|
-
*
|
|
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
|
|
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;
|
package/src/search-state.ts
CHANGED
|
@@ -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
|
-
/**
|
|
78
|
-
function
|
|
79
|
-
if (isNonNullObject(
|
|
80
|
-
const path =
|
|
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
|
|
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
|
-
|
|
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;",
|