@jxsuite/search 0.3.1 → 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.
package/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 Kevin Shenk
3
+ Copyright (c) 2026 Avunu LLC and Jx Suite contributors
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
@@ -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,18 +198,30 @@ function entryUrl(basePath, id, trailingSlash) {
183
198
  function jxTreeToText(children) {
184
199
  return collectText(children).join(" ").replaceAll(/\s+/g, " ").trim();
185
200
  }
201
+ var ENTITIES = new Map([
202
+ ["$", "$"],
203
+ ["&", "&"],
204
+ [">", ">"],
205
+ ["&lt;", "<"],
206
+ ["&quot;", '"']
207
+ ]);
208
+ function innerHtmlText(html) {
209
+ return html.replaceAll(/<[^>]*>/g, " ").replaceAll(/&#36;|&amp;|&gt;|&lt;|&quot;/g, (entity) => ENTITIES.get(entity) ?? entity);
210
+ }
186
211
  function collectText(children) {
187
212
  const parts = [];
188
213
  for (const node of children ?? []) {
189
- if (typeof node === "string") {
214
+ if (isString(node)) {
190
215
  parts.push(node);
191
216
  continue;
192
217
  }
193
- if (typeof node !== "object" || node === null) {
218
+ if (!isNonNullObject(node)) {
194
219
  continue;
195
220
  }
196
- if (typeof node.textContent === "string") {
221
+ if (isString(node.textContent)) {
197
222
  parts.push(node.textContent);
223
+ } else if (isString(node.innerHTML)) {
224
+ parts.push(innerHtmlText(node.innerHTML));
198
225
  }
199
226
  if (Array.isArray(node.children)) {
200
227
  parts.push(...collectText(node.children));
@@ -217,9 +244,9 @@ function splitEntry(children, sectionDepth) {
217
244
  continue;
218
245
  }
219
246
  const parts = current ? current.parts : preambleParts;
220
- if (typeof node === "string") {
247
+ if (isString(node)) {
221
248
  parts.push(node);
222
- } else if (typeof node === "object" && node !== null) {
249
+ } else if (isNonNullObject(node)) {
223
250
  parts.push(jxTreeToText([node]));
224
251
  }
225
252
  }
@@ -236,7 +263,7 @@ function normalizeText(parts) {
236
263
  return parts.join(" ").replaceAll(/\s+/g, " ").trim();
237
264
  }
238
265
  function headingOf(node) {
239
- if (typeof node !== "object" || node === null || !node.tagName) {
266
+ if (!isNonNullObject(node) || !node.tagName) {
240
267
  return null;
241
268
  }
242
269
  const match = HEADING_TAG.exec(displayTagName(node.tagName));
@@ -244,7 +271,7 @@ function headingOf(node) {
244
271
  return null;
245
272
  }
246
273
  return {
247
- anchor: typeof node.id === "string" ? node.id : "",
274
+ anchor: isString(node.id) ? node.id : "",
248
275
  depth: Number(match[1]),
249
276
  heading: jxTreeToText([node])
250
277
  };
@@ -252,7 +279,7 @@ function headingOf(node) {
252
279
 
253
280
  // src/search-index.ts
254
281
  function isOptedOut(collection, entryId, value) {
255
- if (value === undefined || typeof value === "boolean") {
282
+ if (value === undefined || isBoolean(value)) {
256
283
  return value === false;
257
284
  }
258
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`);
@@ -265,7 +292,8 @@ var SearchIndex = {
265
292
  emit(sectionValue, ctx) {
266
293
  const config = normalizeSearchConfig(sectionValue);
267
294
  const content = ctx.sections?.content;
268
- const trailingSlash = ctx.projectConfig?.build?.trailingSlash ?? "always";
295
+ const trailingSlashSetting = ctx.projectConfig?.build?.trailingSlash;
296
+ const trailingSlash = isString(trailingSlashSetting) ? trailingSlashSetting : "always";
269
297
  const { i18n } = resolveI18n(ctx.projectConfig ?? {});
270
298
  const documents = [];
271
299
  const fields = new Set;
@@ -281,19 +309,20 @@ var SearchIndex = {
281
309
  }
282
310
  Object.assign(boost, collection.boost);
283
311
  for (const entry of entries) {
284
- const data = entry.data ?? {};
312
+ const { data } = entry;
285
313
  if (isOptedOut(name, entry.id, data.search)) {
286
314
  continue;
287
315
  }
288
316
  const locale = i18n === null ? undefined : canonicalizeLocale(entry._meta?.locale) ?? undefined;
289
- const url = entryUrl(`${localeUrlPrefix(locale, i18n)}${collection.basePath}`, entry.id, trailingSlash);
290
- const title = typeof data.title === "string" ? data.title : entry.id;
291
- const description = typeof data.description === "string" ? data.description : "";
317
+ const stamped = entry._meta?.url;
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 : "";
292
321
  const children = entry.$children;
293
322
  const documentId = locale === undefined ? `${name}:${entry.id}` : `${name}:${locale}:${entry.id}`;
294
323
  const split = collection.sections ? splitEntry(children, collection.sectionDepth) : undefined;
295
324
  const sections = split?.sections ?? [];
296
- documents.push({
325
+ const page = {
297
326
  id: documentId,
298
327
  collection: name,
299
328
  slug: entry.id,
@@ -301,11 +330,14 @@ var SearchIndex = {
301
330
  title,
302
331
  description,
303
332
  heading: "",
304
- text: sections.length > 0 && split ? split.preamble : jxTreeToText(children),
305
- ...locale === undefined ? {} : { locale }
306
- });
333
+ text: sections.length > 0 && split ? split.preamble : jxTreeToText(children)
334
+ };
335
+ if (locale !== undefined) {
336
+ page.locale = locale;
337
+ }
338
+ documents.push(page);
307
339
  for (const section of sections) {
308
- documents.push({
340
+ const sectionDocument = {
309
341
  id: `${documentId}#${section.anchor}`,
310
342
  collection: name,
311
343
  slug: entry.id,
@@ -313,9 +345,12 @@ var SearchIndex = {
313
345
  title,
314
346
  description,
315
347
  heading: section.heading,
316
- text: section.text,
317
- ...locale === undefined ? {} : { locale }
318
- });
348
+ text: section.text
349
+ };
350
+ if (locale !== undefined) {
351
+ sectionDocument.locale = locale;
352
+ }
353
+ documents.push(sectionDocument);
319
354
  }
320
355
  }
321
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.3.1",
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": "^2.3.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
  }
@@ -137,14 +149,24 @@ export const SearchIndex = {
137
149
  */
138
150
  const locale =
139
151
  i18n === null ? undefined : (canonicalizeLocale(entry._meta?.locale) ?? undefined);
140
- const url = entryUrl(
141
- `${localeUrlPrefix(locale, i18n)}${collection.basePath}`,
142
- entry.id,
143
- trailingSlash,
144
- );
145
- const title = typeof data.title === "string" ? data.title : entry.id;
146
- const description = typeof data.description === "string" ? data.description : "";
147
- const children = entry.$children as (JxElement | string)[] | undefined;
152
+ /*
153
+ * An entry of a content type with a `route` carries its URL (`_meta.url`), stamped by the
154
+ * loader from the template its page and its links were generated from, so the index cannot
155
+ * point at a URL the site does not have. Only an entry with none is placed by the
156
+ * collection's `basePath` + id, the older, flat rule.
157
+ */
158
+ const stamped = entry._meta?.url;
159
+ const url =
160
+ stamped !== undefined && stamped.startsWith("/")
161
+ ? stamped
162
+ : entryUrl(
163
+ `${localeUrlPrefix(locale, i18n)}${collection.basePath}`,
164
+ entry.id,
165
+ trailingSlash,
166
+ );
167
+ const title = isString(data.title) ? data.title : entry.id;
168
+ const description = isString(data.description) ? data.description : "";
169
+ const children = entry.$children;
148
170
  /*
149
171
  * Two translations of one post share an id (§13.3), so the document id has to carry the
150
172
  * locale as well — without it the French entry and the English one are one document and
@@ -167,7 +189,8 @@ export const SearchIndex = {
167
189
  : undefined;
168
190
  const sections = split?.sections ?? [];
169
191
 
170
- documents.push({
192
+ // `locale` is the last key of every document, and present only for a localized entry.
193
+ const page: SearchDocument = {
171
194
  id: documentId,
172
195
  collection: name,
173
196
  slug: entry.id,
@@ -176,11 +199,14 @@ export const SearchIndex = {
176
199
  description,
177
200
  heading: "",
178
201
  text: sections.length > 0 && split ? split.preamble : jxTreeToText(children),
179
- ...(locale === undefined ? {} : { locale }),
180
- });
202
+ };
203
+ if (locale !== undefined) {
204
+ page.locale = locale;
205
+ }
206
+ documents.push(page);
181
207
 
182
208
  for (const section of sections) {
183
- documents.push({
209
+ const sectionDocument: SearchDocument = {
184
210
  id: `${documentId}#${section.anchor}`,
185
211
  collection: name,
186
212
  slug: entry.id,
@@ -189,8 +215,11 @@ export const SearchIndex = {
189
215
  description,
190
216
  heading: section.heading,
191
217
  text: section.text,
192
- ...(locale === undefined ? {} : { locale }),
193
- });
218
+ };
219
+ if (locale !== undefined) {
220
+ sectionDocument.locale = locale;
221
+ }
222
+ documents.push(sectionDocument);
194
223
  }
195
224
  }
196
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] = {
@@ -84,21 +97,49 @@ export function jxTreeToText(children?: (JxElement | string)[]): string {
84
97
  return collectText(children).join(" ").replaceAll(/\s+/g, " ").trim();
85
98
  }
86
99
 
100
+ /** The five entities the parser's escaping and an author's markup commonly leave in `innerHTML`. */
101
+ const ENTITIES = new Map<string, string>([
102
+ ["&#36;", "$"],
103
+ ["&amp;", "&"],
104
+ ["&gt;", ">"],
105
+ ["&lt;", "<"],
106
+ ["&quot;", '"'],
107
+ ]);
108
+
109
+ /**
110
+ * The text of an `innerHTML` string: tags dropped, the common entities decoded.
111
+ *
112
+ * The content loader writes text that contains `${` as escaped `innerHTML` so the build does not
113
+ * read it as a template (parser.md §3.5), which means the words of a code sample live there and not
114
+ * in `textContent`. An index that skipped `innerHTML` would silently stop finding them.
115
+ */
116
+ function innerHtmlText(html: string): string {
117
+ return html
118
+ .replaceAll(/<[^>]*>/g, " ")
119
+ .replaceAll(/&#36;|&amp;|&gt;|&lt;|&quot;/g, (entity) => ENTITIES.get(entity) ?? entity);
120
+ }
121
+
87
122
  function collectText(children: (JxElement | string)[] | undefined): string[] {
88
123
  const parts: string[] = [];
89
124
  for (const node of children ?? []) {
90
- if (typeof node === "string") {
125
+ if (isString(node)) {
91
126
  parts.push(node);
92
127
  continue;
93
128
  }
94
- 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)) {
95
134
  continue;
96
135
  }
97
- if (typeof node.textContent === "string") {
136
+ if (isString(node.textContent)) {
98
137
  parts.push(node.textContent);
138
+ } else if (isString(node.innerHTML)) {
139
+ parts.push(innerHtmlText(node.innerHTML));
99
140
  }
100
141
  if (Array.isArray(node.children)) {
101
- parts.push(...collectText(node.children as (JxElement | string)[]));
142
+ parts.push(...collectText(node.children));
102
143
  }
103
144
  }
104
145
  return parts;
@@ -150,9 +191,9 @@ export function splitEntry(
150
191
  continue;
151
192
  }
152
193
  const parts = current ? current.parts : preambleParts;
153
- if (typeof node === "string") {
194
+ if (isString(node)) {
154
195
  parts.push(node);
155
- } else if (typeof node === "object" && node !== null) {
196
+ } else if (isNonNullObject(node)) {
156
197
  parts.push(jxTreeToText([node]));
157
198
  }
158
199
  }
@@ -183,7 +224,7 @@ function normalizeText(parts: readonly string[]): string {
183
224
  function headingOf(
184
225
  node: JxElement | string,
185
226
  ): { heading: string; anchor: string; depth: number } | null {
186
- if (typeof node !== "object" || node === null || !node.tagName) {
227
+ if (!isNonNullObject(node) || !node.tagName) {
187
228
  return null;
188
229
  }
189
230
  const match = HEADING_TAG.exec(displayTagName(node.tagName));
@@ -191,7 +232,7 @@ function headingOf(
191
232
  return null;
192
233
  }
193
234
  return {
194
- anchor: typeof node.id === "string" ? node.id : "",
235
+ anchor: isString(node.id) ? node.id : "",
195
236
  depth: Number(match[1]),
196
237
  heading: jxTreeToText([node]),
197
238
  };