@jxsuite/search 0.4.0 → 0.4.2
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/dist/search-index.js +105 -83
- package/dist/search-state.js +87 -16
- package/package.json +2 -2
- package/src/client.ts +53 -20
- package/src/search-index.ts +44 -25
- package/src/search-state.ts +52 -27
- package/src/shared.ts +40 -23
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,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
|
-
"<"
|
|
191
|
-
"""
|
|
192
|
-
|
|
201
|
+
var ENTITIES = new Map([
|
|
202
|
+
["$", "$"],
|
|
203
|
+
["&", "&"],
|
|
204
|
+
[">", ">"],
|
|
205
|
+
["<", "<"],
|
|
206
|
+
[""", '"']
|
|
207
|
+
]);
|
|
193
208
|
function innerHtmlText(html) {
|
|
194
|
-
return html.replaceAll(/<[^>]*>/g, " ").replaceAll(/$|&|>|<|"/g, (entity) => ENTITIES
|
|
209
|
+
return html.replaceAll(/<[^>]*>/g, " ").replaceAll(/$|&|>|<|"/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 (
|
|
214
|
+
if (isString(node)) {
|
|
200
215
|
parts.push(node);
|
|
201
216
|
continue;
|
|
202
217
|
}
|
|
203
|
-
if (
|
|
218
|
+
if (!isNonNullObject(node)) {
|
|
204
219
|
continue;
|
|
205
220
|
}
|
|
206
|
-
if (
|
|
221
|
+
if (isString(node.textContent)) {
|
|
207
222
|
parts.push(node.textContent);
|
|
208
|
-
} else if (
|
|
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 (
|
|
247
|
+
if (isString(node)) {
|
|
233
248
|
parts.push(node);
|
|
234
|
-
} else if (
|
|
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 (
|
|
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:
|
|
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 ||
|
|
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
|
|
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
|
|
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 =
|
|
303
|
-
const title =
|
|
304
|
-
const 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
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.4.
|
|
3
|
+
"version": "0.4.2",
|
|
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.1.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
|
}
|
|
@@ -145,16 +157,16 @@ export const SearchIndex = {
|
|
|
145
157
|
*/
|
|
146
158
|
const stamped = entry._meta?.url;
|
|
147
159
|
const url =
|
|
148
|
-
|
|
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 =
|
|
156
|
-
const description =
|
|
157
|
-
const children = entry.$children
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
203
|
-
|
|
218
|
+
};
|
|
219
|
+
if (locale !== undefined) {
|
|
220
|
+
sectionDocument.locale = locale;
|
|
221
|
+
}
|
|
222
|
+
documents.push(sectionDocument);
|
|
204
223
|
}
|
|
205
224
|
}
|
|
206
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] = {
|
|
@@ -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
|
|
89
|
-
"$"
|
|
90
|
-
"&"
|
|
91
|
-
">"
|
|
92
|
-
"<"
|
|
93
|
-
"""
|
|
94
|
-
|
|
101
|
+
const ENTITIES = new Map<string, string>([
|
|
102
|
+
["$", "$"],
|
|
103
|
+
["&", "&"],
|
|
104
|
+
[">", ">"],
|
|
105
|
+
["<", "<"],
|
|
106
|
+
[""", '"'],
|
|
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(/$|&|>|<|"/g, (entity) => ENTITIES
|
|
119
|
+
.replaceAll(/$|&|>|<|"/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 (
|
|
125
|
+
if (isString(node)) {
|
|
113
126
|
parts.push(node);
|
|
114
127
|
continue;
|
|
115
128
|
}
|
|
116
|
-
|
|
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 (
|
|
136
|
+
if (isString(node.textContent)) {
|
|
120
137
|
parts.push(node.textContent);
|
|
121
|
-
} else if (
|
|
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
|
|
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 (
|
|
194
|
+
if (isString(node)) {
|
|
178
195
|
parts.push(node);
|
|
179
|
-
} else if (
|
|
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 (
|
|
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:
|
|
235
|
+
anchor: isString(node.id) ? node.id : "",
|
|
219
236
|
depth: Number(match[1]),
|
|
220
237
|
heading: jxTreeToText([node]),
|
|
221
238
|
};
|