@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 +1 -1
- package/dist/search-index.js +109 -74
- package/dist/search-state.js +87 -16
- package/package.json +2 -2
- package/src/client.ts +53 -20
- package/src/search-index.ts +58 -29
- package/src/search-state.ts +52 -27
- package/src/shared.ts +55 -14
package/LICENSE
CHANGED
package/dist/search-index.js
CHANGED
|
@@ -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 (
|
|
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
|
|
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
|
+
["<", "<"],
|
|
206
|
+
[""", '"']
|
|
207
|
+
]);
|
|
208
|
+
function innerHtmlText(html) {
|
|
209
|
+
return html.replaceAll(/<[^>]*>/g, " ").replaceAll(/$|&|>|<|"/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 (
|
|
214
|
+
if (isString(node)) {
|
|
190
215
|
parts.push(node);
|
|
191
216
|
continue;
|
|
192
217
|
}
|
|
193
|
-
if (
|
|
218
|
+
if (!isNonNullObject(node)) {
|
|
194
219
|
continue;
|
|
195
220
|
}
|
|
196
|
-
if (
|
|
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 (
|
|
247
|
+
if (isString(node)) {
|
|
221
248
|
parts.push(node);
|
|
222
|
-
} else if (
|
|
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 (
|
|
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:
|
|
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 ||
|
|
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
|
|
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
|
|
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
|
|
290
|
-
const
|
|
291
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/search-state.js
CHANGED
|
@@ -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" &&
|
|
85
|
+
return typeof document !== "undefined" && isFunction(globalThis.fetch);
|
|
21
86
|
}
|
|
22
87
|
function queryExpression(query) {
|
|
23
|
-
if (
|
|
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(
|
|
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 =
|
|
46
|
-
|
|
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
|
-
|
|
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
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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
|
+
"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": "^
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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 (
|
|
472
|
+
if (isString(componentState.searchQuery) && componentState.searchQuery) {
|
|
438
473
|
runSearch(componentState);
|
|
439
474
|
}
|
|
440
475
|
})
|
|
441
|
-
.catch((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:
|
|
489
|
+
componentState: SearchComponentState,
|
|
455
490
|
event?: { target?: { value?: unknown } | null },
|
|
456
491
|
): SearchResult[] {
|
|
457
|
-
const fromEvent =
|
|
458
|
-
|
|
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 })
|
|
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;
|
package/src/search-index.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
/**
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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(
|
|
77
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
const
|
|
147
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
193
|
-
|
|
218
|
+
};
|
|
219
|
+
if (locale !== undefined) {
|
|
220
|
+
sectionDocument.locale = locale;
|
|
221
|
+
}
|
|
222
|
+
documents.push(sectionDocument);
|
|
194
223
|
}
|
|
195
224
|
}
|
|
196
225
|
}
|
package/src/search-state.ts
CHANGED
|
@@ -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?:
|
|
42
|
-
|
|
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
|
-
|
|
52
|
+
/** The project config. `lower` reads only the raw `search` section's `output`. */
|
|
53
|
+
projectConfig?: { search?: { output?: string } };
|
|
48
54
|
root?: string;
|
|
49
|
-
route
|
|
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" &&
|
|
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 (
|
|
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(
|
|
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<
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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 =
|
|
91
|
-
|
|
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
|
-
|
|
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 = {}):
|
|
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
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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?:
|
|
49
|
-
|
|
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
|
+
["$", "$"],
|
|
103
|
+
["&", "&"],
|
|
104
|
+
[">", ">"],
|
|
105
|
+
["<", "<"],
|
|
106
|
+
[""", '"'],
|
|
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(/$|&|>|<|"/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 (
|
|
125
|
+
if (isString(node)) {
|
|
91
126
|
parts.push(node);
|
|
92
127
|
continue;
|
|
93
128
|
}
|
|
94
|
-
|
|
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 (
|
|
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
|
|
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 (
|
|
194
|
+
if (isString(node)) {
|
|
154
195
|
parts.push(node);
|
|
155
|
-
} else if (
|
|
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 (
|
|
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:
|
|
235
|
+
anchor: isString(node.id) ? node.id : "",
|
|
195
236
|
depth: Number(match[1]),
|
|
196
237
|
heading: jxTreeToText([node]),
|
|
197
238
|
};
|