@jxsuite/search 0.4.0 → 0.4.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.
@@ -1,3 +1,68 @@
1
+ // ../../packages/schema/src/guards.ts
2
+ function isString(value) {
3
+ return typeof value === "string";
4
+ }
5
+ function isBoolean(value) {
6
+ return typeof value === "boolean";
7
+ }
8
+ function isFunction(value) {
9
+ return typeof value === "function";
10
+ }
11
+ function isNonNullObject(value) {
12
+ return typeof value === "object" && value !== null;
13
+ }
14
+ function isJsonObject(value) {
15
+ return typeof value === "object" && value !== null && !Array.isArray(value);
16
+ }
17
+ var SCHEMA_KEYWORDS = new Set([
18
+ "type",
19
+ "enum",
20
+ "minimum",
21
+ "maximum",
22
+ "minLength",
23
+ "maxLength",
24
+ "pattern",
25
+ "format",
26
+ "items",
27
+ "properties",
28
+ "required",
29
+ "description",
30
+ "title",
31
+ "$comment"
32
+ ]);
33
+ function isPlainTagName(tagName) {
34
+ return typeof tagName === "string";
35
+ }
36
+ function isTagExpression(tagName) {
37
+ if (typeof tagName !== "object" || tagName === null || Array.isArray(tagName) || !isJsonObject(tagName.$expression)) {
38
+ return false;
39
+ }
40
+ const expression = tagName.$expression;
41
+ if (expression.operator === "?:") {
42
+ return typeof expression.value === "string" && typeof expression.initial === "string";
43
+ }
44
+ if (expression.operator === "switch") {
45
+ return isJsonObject(expression.cases) && Object.values(expression.cases).every((tag) => isString(tag)) && typeof expression.default === "string";
46
+ }
47
+ return false;
48
+ }
49
+ function tagNameCandidates(tagName) {
50
+ if (isPlainTagName(tagName)) {
51
+ return [tagName];
52
+ }
53
+ if (!isTagExpression(tagName)) {
54
+ return [];
55
+ }
56
+ const expression = tagName.$expression;
57
+ if (expression.operator === "?:") {
58
+ return [...new Set([expression.value, expression.initial])];
59
+ }
60
+ return [...new Set([...Object.values(expression.cases), expression.default])];
61
+ }
62
+ function displayTagName(tagName) {
63
+ return isPlainTagName(tagName) ? tagName : tagNameCandidates(tagName).join("|");
64
+ }
65
+
1
66
  // ../../packages/schema/src/locale.ts
2
67
  var RTL_SCRIPTS = new Set([
3
68
  "Adlm",
@@ -41,7 +106,7 @@ var RTL_SCRIPTS = new Set([
41
106
  "Yezi"
42
107
  ]);
43
108
  function canonicalizeLocale(tag) {
44
- if (typeof tag !== "string" || tag.trim() === "") {
109
+ if (!isString(tag) || tag.trim() === "") {
45
110
  return null;
46
111
  }
47
112
  try {
@@ -54,7 +119,7 @@ var DEFAULT_ROUTING = "prefix-except-default";
54
119
  function resolveI18n(projectConfig) {
55
120
  const raw = projectConfig.i18n;
56
121
  const errors = [];
57
- if (!raw || typeof raw !== "object") {
122
+ if (!isNonNullObject(raw)) {
58
123
  return { errors, i18n: null };
59
124
  }
60
125
  const declared = Array.isArray(raw.locales) ? raw.locales : [];
@@ -97,62 +162,12 @@ function localeUrlPrefix(locale, i18n) {
97
162
  return `/${canonical.toLowerCase()}`;
98
163
  }
99
164
 
100
- // ../../packages/schema/src/guards.ts
101
- function isJsonObject(value) {
102
- return typeof value === "object" && value !== null && !Array.isArray(value);
103
- }
104
- var SCHEMA_KEYWORDS = new Set([
105
- "type",
106
- "enum",
107
- "minimum",
108
- "maximum",
109
- "minLength",
110
- "maxLength",
111
- "pattern",
112
- "format",
113
- "items",
114
- "properties",
115
- "required",
116
- "description",
117
- "title",
118
- "$comment"
119
- ]);
120
- function isTagExpression(tagName) {
121
- if (!isJsonObject(tagName) || !isJsonObject(tagName.$expression)) {
122
- return false;
123
- }
124
- const expression = tagName.$expression;
125
- if (expression.operator === "?:") {
126
- return typeof expression.value === "string" && typeof expression.initial === "string";
127
- }
128
- if (expression.operator === "switch") {
129
- return isJsonObject(expression.cases) && Object.values(expression.cases).every((tag) => typeof tag === "string") && typeof expression.default === "string";
130
- }
131
- return false;
132
- }
133
- function tagNameCandidates(tagName) {
134
- if (typeof tagName === "string") {
135
- return [tagName];
136
- }
137
- if (!isTagExpression(tagName)) {
138
- return [];
139
- }
140
- const expression = tagName.$expression;
141
- if (expression.operator === "?:") {
142
- return [...new Set([expression.value, expression.initial])];
143
- }
144
- return [...new Set([...Object.values(expression.cases), expression.default])];
145
- }
146
- function displayTagName(tagName) {
147
- return typeof tagName === "string" ? tagName : tagNameCandidates(tagName).join("|");
148
- }
149
-
150
165
  // src/shared.ts
151
166
  var DEFAULT_OUTPUT = "/search-index.json";
152
167
  var DEFAULT_FIELDS = ["title", "heading", "text"];
153
168
  var DEFAULT_SECTION_DEPTH = 3;
154
169
  function normalizeSearchConfig(sectionValue) {
155
- const section = sectionValue ?? {};
170
+ const section = isJsonObject(sectionValue) ? sectionValue : {};
156
171
  const collections = {};
157
172
  for (const [name, raw] of Object.entries(section.collections ?? {})) {
158
173
  collections[name] = {
@@ -183,29 +198,29 @@ function entryUrl(basePath, id, trailingSlash) {
183
198
  function jxTreeToText(children) {
184
199
  return collectText(children).join(" ").replaceAll(/\s+/g, " ").trim();
185
200
  }
186
- var ENTITIES = {
187
- "$": "$",
188
- "&": "&",
189
- ">": ">",
190
- "&lt;": "<",
191
- "&quot;": '"'
192
- };
201
+ var ENTITIES = new Map([
202
+ ["&#36;", "$"],
203
+ ["&amp;", "&"],
204
+ ["&gt;", ">"],
205
+ ["&lt;", "<"],
206
+ ["&quot;", '"']
207
+ ]);
193
208
  function innerHtmlText(html) {
194
- return html.replaceAll(/<[^>]*>/g, " ").replaceAll(/&#36;|&amp;|&gt;|&lt;|&quot;/g, (entity) => ENTITIES[entity]);
209
+ return html.replaceAll(/<[^>]*>/g, " ").replaceAll(/&#36;|&amp;|&gt;|&lt;|&quot;/g, (entity) => ENTITIES.get(entity) ?? entity);
195
210
  }
196
211
  function collectText(children) {
197
212
  const parts = [];
198
213
  for (const node of children ?? []) {
199
- if (typeof node === "string") {
214
+ if (isString(node)) {
200
215
  parts.push(node);
201
216
  continue;
202
217
  }
203
- if (typeof node !== "object" || node === null) {
218
+ if (!isNonNullObject(node)) {
204
219
  continue;
205
220
  }
206
- if (typeof node.textContent === "string") {
221
+ if (isString(node.textContent)) {
207
222
  parts.push(node.textContent);
208
- } else if (typeof node.innerHTML === "string") {
223
+ } else if (isString(node.innerHTML)) {
209
224
  parts.push(innerHtmlText(node.innerHTML));
210
225
  }
211
226
  if (Array.isArray(node.children)) {
@@ -229,9 +244,9 @@ function splitEntry(children, sectionDepth) {
229
244
  continue;
230
245
  }
231
246
  const parts = current ? current.parts : preambleParts;
232
- if (typeof node === "string") {
247
+ if (isString(node)) {
233
248
  parts.push(node);
234
- } else if (typeof node === "object" && node !== null) {
249
+ } else if (isNonNullObject(node)) {
235
250
  parts.push(jxTreeToText([node]));
236
251
  }
237
252
  }
@@ -248,7 +263,7 @@ function normalizeText(parts) {
248
263
  return parts.join(" ").replaceAll(/\s+/g, " ").trim();
249
264
  }
250
265
  function headingOf(node) {
251
- if (typeof node !== "object" || node === null || !node.tagName) {
266
+ if (!isNonNullObject(node) || !node.tagName) {
252
267
  return null;
253
268
  }
254
269
  const match = HEADING_TAG.exec(displayTagName(node.tagName));
@@ -256,7 +271,7 @@ function headingOf(node) {
256
271
  return null;
257
272
  }
258
273
  return {
259
- anchor: typeof node.id === "string" ? node.id : "",
274
+ anchor: isString(node.id) ? node.id : "",
260
275
  depth: Number(match[1]),
261
276
  heading: jxTreeToText([node])
262
277
  };
@@ -264,7 +279,7 @@ function headingOf(node) {
264
279
 
265
280
  // src/search-index.ts
266
281
  function isOptedOut(collection, entryId, value) {
267
- if (value === undefined || typeof value === "boolean") {
282
+ if (value === undefined || isBoolean(value)) {
268
283
  return value === false;
269
284
  }
270
285
  console.warn(`@jxsuite/search: "${collection}/${entryId}" sets search to ${JSON.stringify(value)}; ` + `only the boolean false opts a page out of the index — it stays indexed`);
@@ -277,7 +292,8 @@ var SearchIndex = {
277
292
  emit(sectionValue, ctx) {
278
293
  const config = normalizeSearchConfig(sectionValue);
279
294
  const content = ctx.sections?.content;
280
- const trailingSlash = ctx.projectConfig?.build?.trailingSlash ?? "always";
295
+ const trailingSlashSetting = ctx.projectConfig?.build?.trailingSlash;
296
+ const trailingSlash = isString(trailingSlashSetting) ? trailingSlashSetting : "always";
281
297
  const { i18n } = resolveI18n(ctx.projectConfig ?? {});
282
298
  const documents = [];
283
299
  const fields = new Set;
@@ -293,20 +309,20 @@ var SearchIndex = {
293
309
  }
294
310
  Object.assign(boost, collection.boost);
295
311
  for (const entry of entries) {
296
- const data = entry.data ?? {};
312
+ const { data } = entry;
297
313
  if (isOptedOut(name, entry.id, data.search)) {
298
314
  continue;
299
315
  }
300
316
  const locale = i18n === null ? undefined : canonicalizeLocale(entry._meta?.locale) ?? undefined;
301
317
  const stamped = entry._meta?.url;
302
- const url = typeof stamped === "string" && stamped.startsWith("/") ? stamped : entryUrl(`${localeUrlPrefix(locale, i18n)}${collection.basePath}`, entry.id, trailingSlash);
303
- const title = typeof data.title === "string" ? data.title : entry.id;
304
- const description = typeof data.description === "string" ? data.description : "";
318
+ const url = stamped !== undefined && stamped.startsWith("/") ? stamped : entryUrl(`${localeUrlPrefix(locale, i18n)}${collection.basePath}`, entry.id, trailingSlash);
319
+ const title = isString(data.title) ? data.title : entry.id;
320
+ const description = isString(data.description) ? data.description : "";
305
321
  const children = entry.$children;
306
322
  const documentId = locale === undefined ? `${name}:${entry.id}` : `${name}:${locale}:${entry.id}`;
307
323
  const split = collection.sections ? splitEntry(children, collection.sectionDepth) : undefined;
308
324
  const sections = split?.sections ?? [];
309
- documents.push({
325
+ const page = {
310
326
  id: documentId,
311
327
  collection: name,
312
328
  slug: entry.id,
@@ -314,11 +330,14 @@ var SearchIndex = {
314
330
  title,
315
331
  description,
316
332
  heading: "",
317
- text: sections.length > 0 && split ? split.preamble : jxTreeToText(children),
318
- ...locale === undefined ? {} : { locale }
319
- });
333
+ text: sections.length > 0 && split ? split.preamble : jxTreeToText(children)
334
+ };
335
+ if (locale !== undefined) {
336
+ page.locale = locale;
337
+ }
338
+ documents.push(page);
320
339
  for (const section of sections) {
321
- documents.push({
340
+ const sectionDocument = {
322
341
  id: `${documentId}#${section.anchor}`,
323
342
  collection: name,
324
343
  slug: entry.id,
@@ -326,9 +345,12 @@ var SearchIndex = {
326
345
  title,
327
346
  description,
328
347
  heading: section.heading,
329
- text: section.text,
330
- ...locale === undefined ? {} : { locale }
331
- });
348
+ text: section.text
349
+ };
350
+ if (locale !== undefined) {
351
+ sectionDocument.locale = locale;
352
+ }
353
+ documents.push(sectionDocument);
332
354
  }
333
355
  }
334
356
  }
@@ -1,3 +1,68 @@
1
+ // ../../packages/schema/src/guards.ts
2
+ function isString(value) {
3
+ return typeof value === "string";
4
+ }
5
+ function isBoolean(value) {
6
+ return typeof value === "boolean";
7
+ }
8
+ function isFunction(value) {
9
+ return typeof value === "function";
10
+ }
11
+ function isNonNullObject(value) {
12
+ return typeof value === "object" && value !== null;
13
+ }
14
+ function isJsonObject(value) {
15
+ return typeof value === "object" && value !== null && !Array.isArray(value);
16
+ }
17
+ var SCHEMA_KEYWORDS = new Set([
18
+ "type",
19
+ "enum",
20
+ "minimum",
21
+ "maximum",
22
+ "minLength",
23
+ "maxLength",
24
+ "pattern",
25
+ "format",
26
+ "items",
27
+ "properties",
28
+ "required",
29
+ "description",
30
+ "title",
31
+ "$comment"
32
+ ]);
33
+ function isPlainTagName(tagName) {
34
+ return typeof tagName === "string";
35
+ }
36
+ function isTagExpression(tagName) {
37
+ if (typeof tagName !== "object" || tagName === null || Array.isArray(tagName) || !isJsonObject(tagName.$expression)) {
38
+ return false;
39
+ }
40
+ const expression = tagName.$expression;
41
+ if (expression.operator === "?:") {
42
+ return typeof expression.value === "string" && typeof expression.initial === "string";
43
+ }
44
+ if (expression.operator === "switch") {
45
+ return isJsonObject(expression.cases) && Object.values(expression.cases).every((tag) => isString(tag)) && typeof expression.default === "string";
46
+ }
47
+ return false;
48
+ }
49
+ function tagNameCandidates(tagName) {
50
+ if (isPlainTagName(tagName)) {
51
+ return [tagName];
52
+ }
53
+ if (!isTagExpression(tagName)) {
54
+ return [];
55
+ }
56
+ const expression = tagName.$expression;
57
+ if (expression.operator === "?:") {
58
+ return [...new Set([expression.value, expression.initial])];
59
+ }
60
+ return [...new Set([...Object.values(expression.cases), expression.default])];
61
+ }
62
+ function displayTagName(tagName) {
63
+ return isPlainTagName(tagName) ? tagName : tagNameCandidates(tagName).join("|");
64
+ }
65
+
1
66
  // ../../packages/schema/src/asset-paths.ts
2
67
  var NPM_SPECIFIER_PREFIX = "npm:";
3
68
  var SIDECAR_ASSET_DIR = "/assets/";
@@ -17,16 +82,16 @@ var URL_TERMINATOR = String.raw`"'\s<>()\\`;
17
82
  // src/search-state.ts
18
83
  var CLIENT_SPECIFIER = "npm:@jxsuite/search/client";
19
84
  function inBrowser() {
20
- return typeof document !== "undefined" && typeof fetch === "function";
85
+ return typeof document !== "undefined" && isFunction(globalThis.fetch);
21
86
  }
22
87
  function queryExpression(query) {
23
- if (typeof query === "object" && query !== null && typeof query.$ref === "string") {
88
+ if (isNonNullObject(query) && isString(query.$ref)) {
24
89
  const path = query.$ref.replace(/^#\//, "").split("/");
25
90
  if (path[0] === "state" && path.length > 1) {
26
91
  return ["state", ...path.slice(1)].join(".");
27
92
  }
28
93
  }
29
- return JSON.stringify(typeof query === "string" ? query : "");
94
+ return JSON.stringify(isString(query) ? query : "");
30
95
  }
31
96
 
32
97
  class Search {
@@ -42,12 +107,15 @@ class Search {
42
107
  const clientUrl = sidecarAssetPath(CLIENT_SPECIFIER);
43
108
  const client = await import(clientUrl);
44
109
  await client.preload(this.indexUrl());
45
- const query = typeof this.config.query === "string" ? this.config.query : "";
46
- return client.query(query, {
110
+ const query = isString(this.config.query) ? this.config.query : "";
111
+ const opts = {
47
112
  group: this.config.group ?? true,
48
- limit: this.config.limit ?? 8,
49
- ...this.config.locale === undefined ? {} : { locale: this.config.locale }
50
- });
113
+ limit: this.config.limit ?? 8
114
+ };
115
+ if (this.config.locale !== undefined) {
116
+ opts.locale = this.config.locale;
117
+ }
118
+ return client.query(query, opts);
51
119
  }
52
120
  indexUrl() {
53
121
  return this.config.index ?? this.config._project?.search?.output ?? "/search-index.json";
@@ -55,11 +123,11 @@ class Search {
55
123
  static lower(def, context = {}) {
56
124
  const indexUrl = def.index ?? context.projectConfig?.search?.output ?? "/search-index.json";
57
125
  const clientUrl = sidecarAssetPath(CLIENT_SPECIFIER);
58
- const opts = JSON.stringify({
59
- group: def.group ?? true,
60
- limit: def.limit ?? 8,
61
- ...def.locale === undefined ? {} : { locale: def.locale }
62
- });
126
+ const queryOptions = { group: def.group ?? true, limit: def.limit ?? 8 };
127
+ if (def.locale !== undefined) {
128
+ queryOptions.locale = def.locale;
129
+ }
130
+ const opts = JSON.stringify(queryOptions);
63
131
  const body = [
64
132
  "void state.__jxSearchReady;",
65
133
  "if (!globalThis.__jxSearch) {",
@@ -73,13 +141,16 @@ class Search {
73
141
  `return m && m.isReady() ? m.query(${queryExpression(def.query)}, ${opts}) : [];`
74
142
  ].join(`
75
143
  `);
76
- return {
144
+ const lowered = {
77
145
  $bundle: [CLIENT_SPECIFIER],
78
146
  $prototype: "Function",
79
147
  body,
80
- timing: "client",
81
- ...def.default === undefined ? {} : { default: def.default }
148
+ timing: "client"
82
149
  };
150
+ if (def.default !== undefined) {
151
+ lowered.default = def.default;
152
+ }
153
+ return lowered;
83
154
  }
84
155
  }
85
156
  export {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/search",
3
- "version": "0.4.0",
3
+ "version": "0.4.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": "^3.0.0",
39
+ "@jxsuite/schema": "^4.0.0",
40
40
  "minisearch": "^7.2.0"
41
41
  },
42
42
  "devDependencies": {
package/src/client.ts CHANGED
@@ -15,7 +15,10 @@
15
15
  * @docs framework/site/search
16
16
  */
17
17
 
18
+ import { isString } from "@jxsuite/schema/guards";
19
+ import type { JsonValue } from "@jxsuite/schema/types";
18
20
  import MiniSearch from "minisearch";
21
+ import type { SearchResult as MiniSearchResult } from "minisearch";
19
22
  import type { SearchDocument, SearchIndexEnvelope } from "./search-index.ts";
20
23
 
21
24
  /** One section hit inside a result group. */
@@ -129,7 +132,7 @@ const STORE_FIELDS = [
129
132
  * @returns {string} The tag, or `""` off a document
130
133
  */
131
134
  function pageLocale(): string {
132
- return globalThis.document?.documentElement?.lang ?? "";
135
+ return typeof document === "undefined" ? "" : document.documentElement.lang;
133
136
  }
134
137
 
135
138
  /**
@@ -289,6 +292,10 @@ export function preload(indexUrl?: string): Promise<void> {
289
292
  if (!response.ok) {
290
293
  throw new Error(`search index fetch failed: ${response.status} ${state.indexUrl}`);
291
294
  }
295
+ /*
296
+ * SAFETY: `state.indexUrl` names the file `SearchIndex.emit` wrote at build time, whose content
297
+ * is `JSON.stringify` of a SearchIndexEnvelope; the client reads no other index.
298
+ */
292
299
  const envelope = (await response.json()) as SearchIndexEnvelope;
293
300
  const mini = new MiniSearch<SearchDocument>({
294
301
  fields: envelope.fields,
@@ -308,7 +315,7 @@ export function preload(indexUrl?: string): Promise<void> {
308
315
  */
309
316
  await mini.addAllAsync(envelope.documents, { chunkSize: INDEX_CHUNK_SIZE });
310
317
  state.mini = mini;
311
- })().catch((error: unknown) => {
318
+ })().catch((error) => {
312
319
  state.loading = null;
313
320
  throw error;
314
321
  });
@@ -316,18 +323,19 @@ export function preload(indexUrl?: string): Promise<void> {
316
323
  }
317
324
 
318
325
  /** Raw MiniSearch hit with our stored fields and the terms it matched on. */
319
- interface RawHit {
326
+ interface RawHit extends MiniSearchResult {
320
327
  id: string;
321
- score: number;
322
- terms: string[];
323
- queryTerms: string[];
324
328
  collection: string;
325
329
  slug: string;
326
330
  url: string;
327
331
  title: string;
328
332
  description: string;
329
333
  heading: string;
330
- text: string;
334
+ /**
335
+ * Optional because it is read defensively: stored fields are copied from the fetched index JSON,
336
+ * and an excerpt of a missing body is simply empty.
337
+ */
338
+ text?: string;
331
339
  locale?: string;
332
340
  }
333
341
 
@@ -361,6 +369,9 @@ function toResult(hit: RawHit): SearchResult {
361
369
  * `group: false` returns flat `SearchResult` rows — one per matching page or section, at most
362
370
  * `pageCap` from any one page.
363
371
  */
372
+ export function query(q: string, opts: QueryOptions & { group: false }): SearchResult[];
373
+ export function query(q: string, opts?: QueryOptions & { group?: true }): SearchResultGroup[];
374
+ export function query(q: string, opts?: QueryOptions): SearchResultGroup[] | SearchResult[];
364
375
  export function query(q: string, opts: QueryOptions = {}): SearchResultGroup[] | SearchResult[] {
365
376
  const limit = opts.limit ?? 8;
366
377
  const group = opts.group ?? true;
@@ -378,7 +389,14 @@ export function query(q: string, opts: QueryOptions = {}): SearchResultGroup[] |
378
389
  * copy of the page they are already on — ranked first, because it matched the same words.
379
390
  */
380
391
  const want = opts.locale === undefined ? pageLocale() : (opts.locale ?? "");
381
- const raw = (state.mini.search(text) as unknown as RawHit[]).filter((hit) =>
392
+ /*
393
+ * SAFETY: MiniSearch copies every name in STORE_FIELDS from the indexed document onto its hit,
394
+ * and every document came from the emitted envelope, whose SearchDocuments carry collection,
395
+ * slug, url, title, description and heading as strings (`locale` and, read defensively, `text`
396
+ * are optional in RawHit). MiniSearch types the hit's `id` as `any`; it is the document's own
397
+ * `id`, which SearchDocument declares a string.
398
+ */
399
+ const raw = (state.mini.search(text) as RawHit[]).filter((hit) =>
382
400
  localeAnswers(hit.locale, want),
383
401
  );
384
402
  if (!group) {
@@ -424,21 +442,38 @@ export function query(q: string, opts: QueryOptions = {}): SearchResultGroup[] |
424
442
  return [...groups.values()].toSorted((a, b) => b.score - a.score).slice(0, limit);
425
443
  }
426
444
 
445
+ /**
446
+ * The component state keys the `$src` conventions read and write. The two the author declares are
447
+ * document JSON, so each is honored only when it is a string.
448
+ */
449
+ export interface SearchComponentState {
450
+ /** Index URL override for `preload`. */
451
+ searchIndexUrl?: JsonValue | undefined;
452
+ /** The query text; `runSearch` writes back the query it ran. */
453
+ searchQuery?: JsonValue | undefined;
454
+ /** Set true once the index is loaded. */
455
+ searchReady?: boolean;
456
+ /** The flat, highlighted rows of the last query. */
457
+ searchResults?: SearchResult[];
458
+ /** `searchResults.length`. */
459
+ searchCount?: number;
460
+ /** The active row index, reset to 0 by every query. */
461
+ searchActive?: number;
462
+ }
463
+
427
464
  /**
428
465
  * State-convention init for `$src` component use: preload the index and flip `state.searchReady`;
429
466
  * re-runs the pending query once ready.
430
467
  */
431
- export function searchInit(componentState: Record<string, unknown>): boolean {
432
- void preload(
433
- typeof componentState.searchIndexUrl === "string" ? componentState.searchIndexUrl : undefined,
434
- )
468
+ export function searchInit(componentState: SearchComponentState): boolean {
469
+ void preload(isString(componentState.searchIndexUrl) ? componentState.searchIndexUrl : undefined)
435
470
  .then(() => {
436
471
  componentState.searchReady = true;
437
- if (typeof componentState.searchQuery === "string" && componentState.searchQuery) {
472
+ if (isString(componentState.searchQuery) && componentState.searchQuery) {
438
473
  runSearch(componentState);
439
474
  }
440
475
  })
441
- .catch((error: unknown) => console.warn("search client failed to load:", error));
476
+ .catch((error) => console.warn("search client failed to load:", error));
442
477
  return true;
443
478
  }
444
479
 
@@ -451,15 +486,13 @@ const UI_LIMIT = 20;
451
486
  * count as `state.searchCount`, and resets the active-row index.
452
487
  */
453
488
  export function runSearch(
454
- componentState: Record<string, unknown>,
489
+ componentState: SearchComponentState,
455
490
  event?: { target?: { value?: unknown } | null },
456
491
  ): SearchResult[] {
457
- const fromEvent =
458
- event?.target && typeof event.target.value === "string" ? event.target.value : null;
459
- const q =
460
- fromEvent ?? (typeof componentState.searchQuery === "string" ? componentState.searchQuery : "");
492
+ const fromEvent = event?.target && isString(event.target.value) ? event.target.value : null;
493
+ const q = fromEvent ?? (isString(componentState.searchQuery) ? componentState.searchQuery : "");
461
494
  componentState.searchQuery = q;
462
- const results = query(q, { group: false, limit: UI_LIMIT }) as SearchResult[];
495
+ const results = query(q, { group: false, limit: UI_LIMIT });
463
496
  componentState.searchResults = results;
464
497
  componentState.searchCount = results.length;
465
498
  componentState.searchActive = 0;
@@ -11,10 +11,12 @@
11
11
  * @docs extending/extensions/search
12
12
  */
13
13
 
14
+ import { isBoolean, isString } from "@jxsuite/schema/guards";
14
15
  import { canonicalizeLocale, localeUrlPrefix, resolveI18n } from "@jxsuite/schema/locale";
15
16
  import { entryUrl, jxTreeToText, normalizeSearchConfig, splitEntry } from "./shared.ts";
16
17
  import type { NormalizedSearchConfig } from "./shared.ts";
17
- import type { ContentLoaderEntry, JxElement, ProjectConfig } from "@jxsuite/schema/types";
18
+ import type { EmitHostContext } from "@jxsuite/schema/format-registry";
19
+ import type { ContentFieldValue, ContentLoaderEntry, JsonValue } from "@jxsuite/schema/types";
18
20
 
19
21
  /** One searchable document in the emitted index. */
20
22
  export interface SearchDocument {
@@ -50,13 +52,11 @@ export interface SearchIndexEnvelope {
50
52
  documents: SearchDocument[];
51
53
  }
52
54
 
53
- /** Host context passed to `emit` (extensions.md §8.4). */
54
- export interface EmitContext {
55
- projectConfig?: { build?: { trailingSlash?: string } } & Record<string, unknown>;
56
- root?: string;
57
- sections?: Record<string, unknown>;
58
- routes?: unknown[];
59
- }
55
+ /**
56
+ * The host's `emit` context (extensions.md §8.4) as `SearchIndex.emit` accepts it: any part of
57
+ * {@link EmitHostContext}. The published name, kept for existing importers.
58
+ */
59
+ export type EmitContext = Partial<EmitHostContext>;
60
60
 
61
61
  /**
62
62
  * Whether an entry's frontmatter keeps it out of the index (`search: false`).
@@ -73,8 +73,12 @@ export interface EmitContext {
73
73
  * INDEXED — the safe failure, because a page that is present can be found and a page that is absent
74
74
  * cannot say why — and reported once per entry, not once per document it yields.
75
75
  */
76
- function isOptedOut(collection: string, entryId: string, value: unknown): boolean {
77
- if (value === undefined || typeof value === "boolean") {
76
+ function isOptedOut(
77
+ collection: string,
78
+ entryId: string,
79
+ value: ContentFieldValue | undefined,
80
+ ): boolean {
81
+ if (value === undefined || isBoolean(value)) {
78
82
  return value === false;
79
83
  }
80
84
  console.warn(
@@ -91,22 +95,30 @@ function isOptedOut(collection: string, entryId: string, value: unknown): boolea
91
95
  */
92
96
  export const SearchIndex = {
93
97
  /** Normalize the `search` section into `_project.search` (defaults applied). */
94
- projectData(sectionValue: unknown): NormalizedSearchConfig {
98
+ projectData(sectionValue: JsonValue | undefined): NormalizedSearchConfig {
95
99
  return normalizeSearchConfig(sectionValue);
96
100
  },
97
101
 
98
102
  /** Build the search index from the loaded content collections. */
99
- emit(sectionValue: unknown, ctx: EmitContext): { path: string; content: string }[] {
103
+ emit(sectionValue: JsonValue | undefined, ctx: EmitContext): { path: string; content: string }[] {
100
104
  const config = normalizeSearchConfig(sectionValue);
105
+ /* SAFETY: the `content` section's owner (the parser's `Content.projectData`) answers a
106
+ Map of collection name to loaded entries (specs/extensions.md §8.4), and the host forwards
107
+ each section's answer unchanged. */
101
108
  const content = ctx.sections?.content as Map<string, ContentLoaderEntry[]> | undefined;
102
- const trailingSlash = ctx.projectConfig?.build?.trailingSlash ?? "always";
109
+ /*
110
+ * `entryUrl` drops the trailing slash only for the exact string "never", so a setting that is
111
+ * absent or not a string means "always", which is the default.
112
+ */
113
+ const trailingSlashSetting = ctx.projectConfig?.build?.trailingSlash;
114
+ const trailingSlash = isString(trailingSlashSetting) ? trailingSlashSetting : "always";
103
115
  /*
104
116
  * A localized entry lives at a locale-prefixed URL, and the index is the only place that knew
105
117
  * the entry without knowing the prefix: every French post was indexed at the English post's
106
118
  * URL, so a reader who searched in French was sent to the English page. The prefix is the
107
119
  * routing rule, read from the same resolver the build reads.
108
120
  */
109
- const { i18n } = resolveI18n((ctx.projectConfig ?? {}) as ProjectConfig);
121
+ const { i18n } = resolveI18n(ctx.projectConfig ?? {});
110
122
 
111
123
  const documents: SearchDocument[] = [];
112
124
  const fields = new Set<string>();
@@ -126,7 +138,7 @@ export const SearchIndex = {
126
138
  Object.assign(boost, collection.boost);
127
139
 
128
140
  for (const entry of entries) {
129
- const data = (entry.data ?? {}) as Record<string, unknown>;
141
+ const { data } = entry;
130
142
  if (isOptedOut(name, entry.id, data.search)) {
131
143
  continue;
132
144
  }
@@ -145,16 +157,16 @@ export const SearchIndex = {
145
157
  */
146
158
  const stamped = entry._meta?.url;
147
159
  const url =
148
- typeof stamped === "string" && stamped.startsWith("/")
160
+ stamped !== undefined && stamped.startsWith("/")
149
161
  ? stamped
150
162
  : entryUrl(
151
163
  `${localeUrlPrefix(locale, i18n)}${collection.basePath}`,
152
164
  entry.id,
153
165
  trailingSlash,
154
166
  );
155
- const title = typeof data.title === "string" ? data.title : entry.id;
156
- const description = typeof data.description === "string" ? data.description : "";
157
- const children = entry.$children as (JxElement | string)[] | undefined;
167
+ const title = isString(data.title) ? data.title : entry.id;
168
+ const description = isString(data.description) ? data.description : "";
169
+ const children = entry.$children;
158
170
  /*
159
171
  * Two translations of one post share an id (§13.3), so the document id has to carry the
160
172
  * locale as well — without it the French entry and the English one are one document and
@@ -177,7 +189,8 @@ export const SearchIndex = {
177
189
  : undefined;
178
190
  const sections = split?.sections ?? [];
179
191
 
180
- documents.push({
192
+ // `locale` is the last key of every document, and present only for a localized entry.
193
+ const page: SearchDocument = {
181
194
  id: documentId,
182
195
  collection: name,
183
196
  slug: entry.id,
@@ -186,11 +199,14 @@ export const SearchIndex = {
186
199
  description,
187
200
  heading: "",
188
201
  text: sections.length > 0 && split ? split.preamble : jxTreeToText(children),
189
- ...(locale === undefined ? {} : { locale }),
190
- });
202
+ };
203
+ if (locale !== undefined) {
204
+ page.locale = locale;
205
+ }
206
+ documents.push(page);
191
207
 
192
208
  for (const section of sections) {
193
- documents.push({
209
+ const sectionDocument: SearchDocument = {
194
210
  id: `${documentId}#${section.anchor}`,
195
211
  collection: name,
196
212
  slug: entry.id,
@@ -199,8 +215,11 @@ export const SearchIndex = {
199
215
  description,
200
216
  heading: section.heading,
201
217
  text: section.text,
202
- ...(locale === undefined ? {} : { locale }),
203
- });
218
+ };
219
+ if (locale !== undefined) {
220
+ sectionDocument.locale = locale;
221
+ }
222
+ documents.push(sectionDocument);
204
223
  }
205
224
  }
206
225
  }
@@ -16,6 +16,10 @@
16
16
  */
17
17
 
18
18
  import { sidecarAssetPath } from "@jxsuite/schema/asset-paths";
19
+ import { isFunction, isNonNullObject, isString } from "@jxsuite/schema/guards";
20
+ import type { JsonValue } from "@jxsuite/schema/types";
21
+ import type * as ClientModule from "./client.ts";
22
+ import type { QueryOptions, SearchResult, SearchResultGroup } from "./client.ts";
19
23
 
20
24
  /** The npm specifier of the headless browser client, bundled by the site build. */
21
25
  export const CLIENT_SPECIFIER = "npm:@jxsuite/search/client";
@@ -38,31 +42,47 @@ export interface SearchDef {
38
42
  * reader the English copy of the page they are on. `null` searches every language.
39
43
  */
40
44
  locale?: string | null;
41
- default?: unknown;
42
- [key: string]: unknown;
45
+ default?: JsonValue;
46
+ /** Any other key an author wrote on the def: document JSON this class does not read. */
47
+ [key: string]: JsonValue | undefined;
43
48
  }
44
49
 
45
50
  /** The context object hosts pass to `lower(def, context)`. */
46
51
  export interface LowerContext {
47
- projectConfig?: { search?: { output?: string } } & Record<string, unknown>;
52
+ /** The project config. `lower` reads only the raw `search` section's `output`. */
53
+ projectConfig?: { search?: { output?: string } };
48
54
  root?: string;
49
- route?: Record<string, unknown>;
55
+ /** The route being compiled (prototype-resolver.ts passes its source path and path params). */
56
+ route?: { sourcePath?: string; _pathParams?: Record<string, string> };
50
57
  }
51
58
 
59
+ /** The core `Function` def a `Search` def lowers into, plus the `$bundle` host hint. */
60
+ export interface LoweredSearchDef {
61
+ /** Client modules the site build must bundle; the host strips the key after reading it. */
62
+ $bundle: string[];
63
+ $prototype: "Function";
64
+ body: string;
65
+ timing: "client";
66
+ default?: JsonValue;
67
+ }
68
+
69
+ /** The part of the bundled client module `resolve` calls. */
70
+ type SearchClient = Pick<typeof ClientModule, "preload" | "query">;
71
+
52
72
  /** True when running in a browser; node hosts degrade to empty results. */
53
73
  function inBrowser(): boolean {
54
- return typeof document !== "undefined" && typeof fetch === "function";
74
+ return typeof document !== "undefined" && isFunction(globalThis.fetch);
55
75
  }
56
76
 
57
77
  /** Compile a def's `query` into a client JS expression (`$ref` → state path, literal → string). */
58
78
  function queryExpression(query: SearchDef["query"]): string {
59
- if (typeof query === "object" && query !== null && typeof query.$ref === "string") {
79
+ if (isNonNullObject(query) && isString(query.$ref)) {
60
80
  const path = query.$ref.replace(/^#\//, "").split("/");
61
81
  if (path[0] === "state" && path.length > 1) {
62
82
  return ["state", ...path.slice(1)].join(".");
63
83
  }
64
84
  }
65
- return JSON.stringify(typeof query === "string" ? query : "");
85
+ return JSON.stringify(isString(query) ? query : "");
66
86
  }
67
87
 
68
88
  export class Search {
@@ -73,26 +93,28 @@ export class Search {
73
93
  }
74
94
 
75
95
  /** Grouped search results for the configured query — browser only. */
76
- async resolve(): Promise<unknown> {
96
+ async resolve(): Promise<SearchResultGroup[] | SearchResult[]> {
77
97
  if (!inBrowser()) {
78
98
  console.warn("Search resolves to [] outside the browser — search is a client interaction");
79
99
  return [];
80
100
  }
81
101
  const clientUrl = sidecarAssetPath(CLIENT_SPECIFIER);
82
- const client = (await import(clientUrl)) as {
83
- preload: (url?: string) => Promise<void>;
84
- query: (
85
- q: string,
86
- opts?: { limit?: number; group?: boolean; locale?: string | null },
87
- ) => unknown;
88
- };
102
+ /*
103
+ * SAFETY: `clientUrl` is the sidecar asset path of CLIENT_SPECIFIER, which the site build
104
+ * bundles from ./client.ts (the `$bundle` hint `lower` emits), so the module it serves exports
105
+ * that file's `preload` and `query`.
106
+ */
107
+ const client = (await import(clientUrl)) as SearchClient;
89
108
  await client.preload(this.indexUrl());
90
- const query = typeof this.config.query === "string" ? this.config.query : "";
91
- return client.query(query, {
109
+ const query = isString(this.config.query) ? this.config.query : "";
110
+ const opts: QueryOptions = {
92
111
  group: this.config.group ?? true,
93
112
  limit: this.config.limit ?? 8,
94
- ...(this.config.locale === undefined ? {} : { locale: this.config.locale }),
95
- });
113
+ };
114
+ if (this.config.locale !== undefined) {
115
+ opts.locale = this.config.locale;
116
+ }
117
+ return client.query(query, opts);
96
118
  }
97
119
 
98
120
  /** The index URL: def override, then the project's `search.output`, then the default. */
@@ -105,7 +127,7 @@ export class Search {
105
127
  * bundled client + index, then queries reactively. `$bundle` registers the client module with the
106
128
  * sidecar bundler (extensions.md §8.3).
107
129
  */
108
- static lower(def: SearchDef, context: LowerContext = {}): Record<string, unknown> {
130
+ static lower(def: SearchDef, context: LowerContext = {}): LoweredSearchDef {
109
131
  const indexUrl = def.index ?? context.projectConfig?.search?.output ?? "/search-index.json";
110
132
  const clientUrl = sidecarAssetPath(CLIENT_SPECIFIER);
111
133
  /*
@@ -113,11 +135,11 @@ export class Search {
113
135
  * `undefined` through JSON — the client reads "absent" as "the page's own language", which is
114
136
  * the default a search box should have and the one nobody has to configure.
115
137
  */
116
- const opts = JSON.stringify({
117
- group: def.group ?? true,
118
- limit: def.limit ?? 8,
119
- ...(def.locale === undefined ? {} : { locale: def.locale }),
120
- });
138
+ const queryOptions: QueryOptions = { group: def.group ?? true, limit: def.limit ?? 8 };
139
+ if (def.locale !== undefined) {
140
+ queryOptions.locale = def.locale;
141
+ }
142
+ const opts = JSON.stringify(queryOptions);
121
143
  const body = [
122
144
  // Reading the ready flag makes the computed re-run when the client finishes loading.
123
145
  "void state.__jxSearchReady;",
@@ -131,12 +153,15 @@ export class Search {
131
153
  "const m = globalThis.__jxSearch.m;",
132
154
  `return m && m.isReady() ? m.query(${queryExpression(def.query)}, ${opts}) : [];`,
133
155
  ].join("\n");
134
- return {
156
+ const lowered: LoweredSearchDef = {
135
157
  $bundle: [CLIENT_SPECIFIER],
136
158
  $prototype: "Function",
137
159
  body,
138
160
  timing: "client",
139
- ...(def.default === undefined ? {} : { default: def.default }),
140
161
  };
162
+ if (def.default !== undefined) {
163
+ lowered.default = def.default;
164
+ }
165
+ return lowered;
141
166
  }
142
167
  }
package/src/shared.ts CHANGED
@@ -6,8 +6,8 @@
6
6
  * @docs extending/extensions/search
7
7
  */
8
8
 
9
- import type { JxElement } from "@jxsuite/schema/types";
10
- import { displayTagName } from "@jxsuite/schema/guards";
9
+ import type { JsonValue, JxElement } from "@jxsuite/schema/types";
10
+ import { displayTagName, isJsonObject, isNonNullObject, isString } from "@jxsuite/schema/guards";
11
11
 
12
12
  /** One collection's index configuration inside the `search` project section. */
13
13
  export interface SearchCollectionConfig {
@@ -23,11 +23,17 @@ export interface SearchCollectionConfig {
23
23
  sectionDepth?: number;
24
24
  }
25
25
 
26
- /** The raw `search` project section (schemas/project.fragment.schema.json). */
26
+ /**
27
+ * The raw `search` project section (schemas/project.fragment.schema.json).
28
+ *
29
+ * A collection's members are all optional here, `basePath` included: the schema requires it, but
30
+ * this is the value before anything validated it, and {@link normalizeSearchConfig} defaults a
31
+ * missing one to `/` rather than rejecting it.
32
+ */
27
33
  export interface SearchSection {
28
34
  engine?: "minisearch";
29
35
  output?: string;
30
- collections?: Record<string, SearchCollectionConfig>;
36
+ collections?: Record<string, Partial<SearchCollectionConfig>>;
31
37
  }
32
38
 
33
39
  /** A fully-defaulted collection config. */
@@ -45,8 +51,15 @@ export const DEFAULT_FIELDS = ["title", "heading", "text"];
45
51
  export const DEFAULT_SECTION_DEPTH = 3;
46
52
 
47
53
  /** Apply schema defaults to a raw `search` section value. */
48
- export function normalizeSearchConfig(sectionValue?: unknown): NormalizedSearchConfig {
49
- const section = (sectionValue ?? {}) as SearchSection;
54
+ export function normalizeSearchConfig(sectionValue?: JsonValue): NormalizedSearchConfig {
55
+ /*
56
+ * SAFETY: a JSON object here is the project's `search` section, whose member shapes
57
+ * schemas/project.fragment.schema.json defines and `jx validate` and the editor enforce. This
58
+ * normalizer only fills defaults for absent members; it never re-validates member types. Any
59
+ * other value (absent, null, a scalar, an array) has none of the section's members, so it reads
60
+ * as an empty section.
61
+ */
62
+ const section: SearchSection = isJsonObject(sectionValue) ? (sectionValue as SearchSection) : {};
50
63
  const collections: Record<string, NormalizedCollectionConfig> = {};
51
64
  for (const [name, raw] of Object.entries(section.collections ?? {})) {
52
65
  collections[name] = {
@@ -85,13 +98,13 @@ export function jxTreeToText(children?: (JxElement | string)[]): string {
85
98
  }
86
99
 
87
100
  /** The five entities the parser's escaping and an author's markup commonly leave in `innerHTML`. */
88
- const ENTITIES: Record<string, string> = {
89
- "&#36;": "$",
90
- "&amp;": "&",
91
- "&gt;": ">",
92
- "&lt;": "<",
93
- "&quot;": '"',
94
- };
101
+ const ENTITIES = new Map<string, string>([
102
+ ["&#36;", "$"],
103
+ ["&amp;", "&"],
104
+ ["&gt;", ">"],
105
+ ["&lt;", "<"],
106
+ ["&quot;", '"'],
107
+ ]);
95
108
 
96
109
  /**
97
110
  * The text of an `innerHTML` string: tags dropped, the common entities decoded.
@@ -103,26 +116,30 @@ const ENTITIES: Record<string, string> = {
103
116
  function innerHtmlText(html: string): string {
104
117
  return html
105
118
  .replaceAll(/<[^>]*>/g, " ")
106
- .replaceAll(/&#36;|&amp;|&gt;|&lt;|&quot;/g, (entity) => ENTITIES[entity]!);
119
+ .replaceAll(/&#36;|&amp;|&gt;|&lt;|&quot;/g, (entity) => ENTITIES.get(entity) ?? entity);
107
120
  }
108
121
 
109
122
  function collectText(children: (JxElement | string)[] | undefined): string[] {
110
123
  const parts: string[] = [];
111
124
  for (const node of children ?? []) {
112
- if (typeof node === "string") {
125
+ if (isString(node)) {
113
126
  parts.push(node);
114
127
  continue;
115
128
  }
116
- if (typeof node !== "object" || node === null) {
129
+ /*
130
+ * The tree is a format class's `load` output, which nothing validates: a `null` child in
131
+ * hand-written JSON is skipped rather than allowed to throw on the reads below.
132
+ */
133
+ if (!isNonNullObject(node)) {
117
134
  continue;
118
135
  }
119
- if (typeof node.textContent === "string") {
136
+ if (isString(node.textContent)) {
120
137
  parts.push(node.textContent);
121
- } else if (typeof node.innerHTML === "string") {
138
+ } else if (isString(node.innerHTML)) {
122
139
  parts.push(innerHtmlText(node.innerHTML));
123
140
  }
124
141
  if (Array.isArray(node.children)) {
125
- parts.push(...collectText(node.children as (JxElement | string)[]));
142
+ parts.push(...collectText(node.children));
126
143
  }
127
144
  }
128
145
  return parts;
@@ -174,9 +191,9 @@ export function splitEntry(
174
191
  continue;
175
192
  }
176
193
  const parts = current ? current.parts : preambleParts;
177
- if (typeof node === "string") {
194
+ if (isString(node)) {
178
195
  parts.push(node);
179
- } else if (typeof node === "object" && node !== null) {
196
+ } else if (isNonNullObject(node)) {
180
197
  parts.push(jxTreeToText([node]));
181
198
  }
182
199
  }
@@ -207,7 +224,7 @@ function normalizeText(parts: readonly string[]): string {
207
224
  function headingOf(
208
225
  node: JxElement | string,
209
226
  ): { heading: string; anchor: string; depth: number } | null {
210
- if (typeof node !== "object" || node === null || !node.tagName) {
227
+ if (!isNonNullObject(node) || !node.tagName) {
211
228
  return null;
212
229
  }
213
230
  const match = HEADING_TAG.exec(displayTagName(node.tagName));
@@ -215,7 +232,7 @@ function headingOf(
215
232
  return null;
216
233
  }
217
234
  return {
218
- anchor: typeof node.id === "string" ? node.id : "",
235
+ anchor: isString(node.id) ? node.id : "",
219
236
  depth: Number(match[1]),
220
237
  heading: jxTreeToText([node]),
221
238
  };