@jxsuite/search 0.1.0 → 0.2.0
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 +21 -0
- package/dist/search-index.js +150 -6
- package/dist/search-state.js +8 -2
- package/package.json +3 -3
- package/src/Search.class.json +7 -1
- package/src/client.ts +273 -16
- package/src/search-index.ts +38 -4
- package/src/search-state.ts +21 -2
- package/src/shared.ts +2 -1
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kevin Shenk
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/dist/search-index.js
CHANGED
|
@@ -1,3 +1,142 @@
|
|
|
1
|
+
// ../../packages/schema/src/locale.ts
|
|
2
|
+
var RTL_SCRIPTS = new Set([
|
|
3
|
+
"Adlm",
|
|
4
|
+
"Arab",
|
|
5
|
+
"Aran",
|
|
6
|
+
"Armi",
|
|
7
|
+
"Avst",
|
|
8
|
+
"Cprt",
|
|
9
|
+
"Egyd",
|
|
10
|
+
"Egyh",
|
|
11
|
+
"Elym",
|
|
12
|
+
"Gara",
|
|
13
|
+
"Hatr",
|
|
14
|
+
"Hebr",
|
|
15
|
+
"Hluw",
|
|
16
|
+
"Hung",
|
|
17
|
+
"Khar",
|
|
18
|
+
"Lydi",
|
|
19
|
+
"Mand",
|
|
20
|
+
"Mani",
|
|
21
|
+
"Mend",
|
|
22
|
+
"Merc",
|
|
23
|
+
"Mero",
|
|
24
|
+
"Narb",
|
|
25
|
+
"Nbat",
|
|
26
|
+
"Nkoo",
|
|
27
|
+
"Orkh",
|
|
28
|
+
"Palm",
|
|
29
|
+
"Phli",
|
|
30
|
+
"Phlp",
|
|
31
|
+
"Phnx",
|
|
32
|
+
"Prti",
|
|
33
|
+
"Rohg",
|
|
34
|
+
"Samr",
|
|
35
|
+
"Sarb",
|
|
36
|
+
"Sogd",
|
|
37
|
+
"Sogo",
|
|
38
|
+
"Syrc",
|
|
39
|
+
"Thaa",
|
|
40
|
+
"Todr",
|
|
41
|
+
"Yezi"
|
|
42
|
+
]);
|
|
43
|
+
function canonicalizeLocale(tag) {
|
|
44
|
+
if (typeof tag !== "string" || tag.trim() === "") {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
return new Intl.Locale(tag.trim()).toString();
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
var DEFAULT_ROUTING = "prefix-except-default";
|
|
54
|
+
function resolveI18n(projectConfig) {
|
|
55
|
+
const raw = projectConfig.i18n;
|
|
56
|
+
const errors = [];
|
|
57
|
+
if (!raw || typeof raw !== "object") {
|
|
58
|
+
return { errors, i18n: null };
|
|
59
|
+
}
|
|
60
|
+
const declared = Array.isArray(raw.locales) ? raw.locales : [];
|
|
61
|
+
const locales = [];
|
|
62
|
+
for (const tag of declared) {
|
|
63
|
+
const canonical = canonicalizeLocale(tag);
|
|
64
|
+
if (canonical === null) {
|
|
65
|
+
errors.push(`i18n.locales: "${String(tag)}" is not a well-formed BCP 47 language tag.`);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (!locales.includes(canonical)) {
|
|
69
|
+
locales.push(canonical);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const defaultLocale = canonicalizeLocale(raw.defaultLocale) ?? locales[0] ?? null;
|
|
73
|
+
if (raw.defaultLocale !== undefined && canonicalizeLocale(raw.defaultLocale) === null) {
|
|
74
|
+
errors.push(`i18n.defaultLocale: "${String(raw.defaultLocale)}" is not a well-formed BCP 47 language tag.`);
|
|
75
|
+
}
|
|
76
|
+
if (defaultLocale === null) {
|
|
77
|
+
errors.push("i18n is declared with no usable locale — set `defaultLocale` or `locales`.");
|
|
78
|
+
return { errors, i18n: null };
|
|
79
|
+
}
|
|
80
|
+
if (!locales.includes(defaultLocale)) {
|
|
81
|
+
locales.unshift(defaultLocale);
|
|
82
|
+
}
|
|
83
|
+
const routing = raw.routing === "prefix-always" || raw.routing === "prefix-except-default" ? raw.routing : DEFAULT_ROUTING;
|
|
84
|
+
return { errors, i18n: { defaultLocale, locales, routing } };
|
|
85
|
+
}
|
|
86
|
+
function localeUrlPrefix(locale, i18n) {
|
|
87
|
+
if (i18n === null) {
|
|
88
|
+
return "";
|
|
89
|
+
}
|
|
90
|
+
const canonical = canonicalizeLocale(locale);
|
|
91
|
+
if (canonical === null || !i18n.locales.includes(canonical)) {
|
|
92
|
+
return "";
|
|
93
|
+
}
|
|
94
|
+
if (canonical === i18n.defaultLocale && i18n.routing === "prefix-except-default") {
|
|
95
|
+
return "";
|
|
96
|
+
}
|
|
97
|
+
return `/${canonical.toLowerCase()}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
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
|
+
return isJsonObject(tagName) && isJsonObject(tagName.$expression);
|
|
122
|
+
}
|
|
123
|
+
function tagNameCandidates(tagName) {
|
|
124
|
+
if (typeof tagName === "string") {
|
|
125
|
+
return [tagName];
|
|
126
|
+
}
|
|
127
|
+
if (!isTagExpression(tagName)) {
|
|
128
|
+
return [];
|
|
129
|
+
}
|
|
130
|
+
const expression = tagName.$expression;
|
|
131
|
+
if (expression.operator === "?:") {
|
|
132
|
+
return [...new Set([expression.value, expression.initial])];
|
|
133
|
+
}
|
|
134
|
+
return [...new Set([...Object.values(expression.cases), expression.default])];
|
|
135
|
+
}
|
|
136
|
+
function displayTagName(tagName) {
|
|
137
|
+
return typeof tagName === "string" ? tagName : tagNameCandidates(tagName).join("|");
|
|
138
|
+
}
|
|
139
|
+
|
|
1
140
|
// src/shared.ts
|
|
2
141
|
var DEFAULT_OUTPUT = "/search-index.json";
|
|
3
142
|
var DEFAULT_FIELDS = ["title", "heading", "text"];
|
|
@@ -85,7 +224,7 @@ function headingOf(node) {
|
|
|
85
224
|
if (typeof node !== "object" || node === null || !node.tagName) {
|
|
86
225
|
return null;
|
|
87
226
|
}
|
|
88
|
-
const match = HEADING_TAG.exec(node.tagName);
|
|
227
|
+
const match = HEADING_TAG.exec(displayTagName(node.tagName));
|
|
89
228
|
if (!match) {
|
|
90
229
|
return null;
|
|
91
230
|
}
|
|
@@ -105,6 +244,7 @@ var SearchIndex = {
|
|
|
105
244
|
const config = normalizeSearchConfig(sectionValue);
|
|
106
245
|
const content = ctx.sections?.content;
|
|
107
246
|
const trailingSlash = ctx.projectConfig?.build?.trailingSlash ?? "always";
|
|
247
|
+
const { i18n } = resolveI18n(ctx.projectConfig ?? {});
|
|
108
248
|
const documents = [];
|
|
109
249
|
const fields = new Set;
|
|
110
250
|
const boost = {};
|
|
@@ -120,31 +260,35 @@ var SearchIndex = {
|
|
|
120
260
|
Object.assign(boost, collection.boost);
|
|
121
261
|
for (const entry of entries) {
|
|
122
262
|
const data = entry.data ?? {};
|
|
123
|
-
const
|
|
263
|
+
const locale = i18n === null ? undefined : canonicalizeLocale(entry._meta?.locale) ?? undefined;
|
|
264
|
+
const url = entryUrl(`${localeUrlPrefix(locale, i18n)}${collection.basePath}`, entry.id, trailingSlash);
|
|
124
265
|
const title = typeof data.title === "string" ? data.title : entry.id;
|
|
125
266
|
const description = typeof data.description === "string" ? data.description : "";
|
|
126
267
|
const children = entry.$children;
|
|
268
|
+
const documentId = locale === undefined ? `${name}:${entry.id}` : `${name}:${locale}:${entry.id}`;
|
|
127
269
|
documents.push({
|
|
128
|
-
id:
|
|
270
|
+
id: documentId,
|
|
129
271
|
collection: name,
|
|
130
272
|
slug: entry.id,
|
|
131
273
|
url,
|
|
132
274
|
title,
|
|
133
275
|
description,
|
|
134
276
|
heading: "",
|
|
135
|
-
text: jxTreeToText(children)
|
|
277
|
+
text: jxTreeToText(children),
|
|
278
|
+
...locale === undefined ? {} : { locale }
|
|
136
279
|
});
|
|
137
280
|
if (collection.sections) {
|
|
138
281
|
for (const section of splitSections(children, collection.sectionDepth)) {
|
|
139
282
|
documents.push({
|
|
140
|
-
id: `${
|
|
283
|
+
id: `${documentId}#${section.anchor}`,
|
|
141
284
|
collection: name,
|
|
142
285
|
slug: entry.id,
|
|
143
286
|
url: `${url}#${section.anchor}`,
|
|
144
287
|
title,
|
|
145
288
|
description,
|
|
146
289
|
heading: section.heading,
|
|
147
|
-
text: section.text
|
|
290
|
+
text: section.text,
|
|
291
|
+
...locale === undefined ? {} : { locale }
|
|
148
292
|
});
|
|
149
293
|
}
|
|
150
294
|
}
|
package/dist/search-state.js
CHANGED
|
@@ -12,6 +12,7 @@ function sidecarAssetPath(specifier) {
|
|
|
12
12
|
base = base.replace(/^\.\//, "").replace(/\.(js|ts|mjs|mts)$/, "").replaceAll("@", "").replaceAll(/[/\\]+/g, "-").replaceAll(/[^\w.-]/g, "-").replaceAll(/-+/g, "-").replaceAll(/^-|-$/g, "");
|
|
13
13
|
return `${SIDECAR_ASSET_DIR}${base}.js`;
|
|
14
14
|
}
|
|
15
|
+
var URL_TERMINATOR = String.raw`"'\s<>()\\`;
|
|
15
16
|
|
|
16
17
|
// src/search-state.ts
|
|
17
18
|
var CLIENT_SPECIFIER = "npm:@jxsuite/search/client";
|
|
@@ -44,7 +45,8 @@ class Search {
|
|
|
44
45
|
const query = typeof this.config.query === "string" ? this.config.query : "";
|
|
45
46
|
return client.query(query, {
|
|
46
47
|
group: this.config.group ?? true,
|
|
47
|
-
limit: this.config.limit ?? 8
|
|
48
|
+
limit: this.config.limit ?? 8,
|
|
49
|
+
...this.config.locale === undefined ? {} : { locale: this.config.locale }
|
|
48
50
|
});
|
|
49
51
|
}
|
|
50
52
|
indexUrl() {
|
|
@@ -53,7 +55,11 @@ class Search {
|
|
|
53
55
|
static lower(def, context = {}) {
|
|
54
56
|
const indexUrl = def.index ?? context.projectConfig?.search?.output ?? "/search-index.json";
|
|
55
57
|
const clientUrl = sidecarAssetPath(CLIENT_SPECIFIER);
|
|
56
|
-
const opts = JSON.stringify({
|
|
58
|
+
const opts = JSON.stringify({
|
|
59
|
+
group: def.group ?? true,
|
|
60
|
+
limit: def.limit ?? 8,
|
|
61
|
+
...def.locale === undefined ? {} : { locale: def.locale }
|
|
62
|
+
});
|
|
57
63
|
const body = [
|
|
58
64
|
"void state.__jxSearchReady;",
|
|
59
65
|
"if (!globalThis.__jxSearch) {",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxsuite/search",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
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": {
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"./Search.class.json": "./src/Search.class.json"
|
|
27
27
|
},
|
|
28
28
|
"publishConfig": {
|
|
29
|
-
"provenance":
|
|
29
|
+
"provenance": true
|
|
30
30
|
},
|
|
31
31
|
"scripts": {
|
|
32
32
|
"prepare": "bun build src/search-index.ts src/search-state.ts --outdir=dist --format=esm --target=node",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"upgrade": "bunx npm-check-updates -u && bun install"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@jxsuite/schema": "
|
|
39
|
+
"@jxsuite/schema": "^1.6.0",
|
|
40
40
|
"minisearch": "^7.2.0"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
package/src/Search.class.json
CHANGED
|
@@ -32,6 +32,11 @@
|
|
|
32
32
|
"identifier": "index",
|
|
33
33
|
"type": { "type": "string" },
|
|
34
34
|
"description": "Index URL override (defaults to the project's search.output)"
|
|
35
|
+
},
|
|
36
|
+
"locale": {
|
|
37
|
+
"identifier": "locale",
|
|
38
|
+
"type": { "type": ["string", "null"] },
|
|
39
|
+
"description": "Language to search in \u2014 omitted, the page's own (<html lang>); null searches every language"
|
|
35
40
|
}
|
|
36
41
|
},
|
|
37
42
|
"constructor": {
|
|
@@ -40,7 +45,8 @@
|
|
|
40
45
|
{ "$ref": "#/$defs/parameters/query" },
|
|
41
46
|
{ "$ref": "#/$defs/parameters/limit" },
|
|
42
47
|
{ "$ref": "#/$defs/parameters/group" },
|
|
43
|
-
{ "$ref": "#/$defs/parameters/index" }
|
|
48
|
+
{ "$ref": "#/$defs/parameters/index" },
|
|
49
|
+
{ "$ref": "#/$defs/parameters/locale" }
|
|
44
50
|
],
|
|
45
51
|
"body": ["this.config = config;"]
|
|
46
52
|
},
|
package/src/client.ts
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Bundled into `/assets/` by the site build (spec.md §5.3); no UI. Fetches the emitted index
|
|
5
5
|
* envelope once, builds a MiniSearch index in-memory (tens of milliseconds at docs scale), and
|
|
6
|
-
* answers synchronous queries with
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* answers synchronous queries with section-anchored results — either grouped under their page, or
|
|
7
|
+
* flat and presentation-ready (breadcrumbs plus highlight tokens for the title and a body excerpt).
|
|
8
|
+
* Field boosts and indexed fields come from the envelope — the emitter bakes the project's `search`
|
|
9
|
+
* section in, so the client needs no configuration.
|
|
9
10
|
*
|
|
10
11
|
* Two integration surfaces: - `preload` / `isReady` / `query` — the core API (used by lowered
|
|
11
12
|
* `Search` defs). - `searchInit` / `runSearch` — state-convention helpers for `$src` use inside
|
|
@@ -24,6 +25,37 @@ export interface SearchHit {
|
|
|
24
25
|
score: number;
|
|
25
26
|
}
|
|
26
27
|
|
|
28
|
+
/**
|
|
29
|
+
* One run of display text. `m` is true when the run matched a query term — renderers wrap those in
|
|
30
|
+
* `<mark>` (or equivalent). Tokens rather than an HTML string so declarative renderers can
|
|
31
|
+
* highlight without injecting markup.
|
|
32
|
+
*/
|
|
33
|
+
export interface SearchToken {
|
|
34
|
+
t: string;
|
|
35
|
+
m: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** One flat, presentation-ready result row: a page or one of its heading sections. */
|
|
39
|
+
export interface SearchResult {
|
|
40
|
+
id: string;
|
|
41
|
+
collection: string;
|
|
42
|
+
slug: string;
|
|
43
|
+
/** Page URL, or page URL + `#anchor` for a section row. */
|
|
44
|
+
url: string;
|
|
45
|
+
/** Page title. */
|
|
46
|
+
title: string;
|
|
47
|
+
/** Heading text for a section row; `""` for a page row. */
|
|
48
|
+
heading: string;
|
|
49
|
+
description: string;
|
|
50
|
+
score: number;
|
|
51
|
+
/** Breadcrumb trail: slug ancestors, plus the page title on a section row. */
|
|
52
|
+
crumbs: string[];
|
|
53
|
+
/** The row's display title (heading, else page title), highlighted. */
|
|
54
|
+
titleTokens: SearchToken[];
|
|
55
|
+
/** A window of body text around the first match, highlighted. */
|
|
56
|
+
excerptTokens: SearchToken[];
|
|
57
|
+
}
|
|
58
|
+
|
|
27
59
|
/** One result group: a page and its matching sections, best-score first. */
|
|
28
60
|
export interface SearchResultGroup {
|
|
29
61
|
collection: string;
|
|
@@ -39,10 +71,24 @@ export interface SearchResultGroup {
|
|
|
39
71
|
|
|
40
72
|
/** Query options. */
|
|
41
73
|
export interface QueryOptions {
|
|
42
|
-
/** Maximum
|
|
74
|
+
/** Maximum rows returned (default 8). */
|
|
43
75
|
limit?: number;
|
|
44
|
-
/**
|
|
76
|
+
/**
|
|
77
|
+
* Group section hits under their page (default true), yielding `SearchResultGroup[]`; false
|
|
78
|
+
* returns flat, presentation-ready `SearchResult[]` — one row per matching page or section.
|
|
79
|
+
*/
|
|
45
80
|
group?: boolean;
|
|
81
|
+
/**
|
|
82
|
+
* In flat mode, the most rows one page may contribute (default 3). Keeps long pages from flooding
|
|
83
|
+
* the list.
|
|
84
|
+
*/
|
|
85
|
+
pageCap?: number;
|
|
86
|
+
/**
|
|
87
|
+
* The language to search in. Omitted, it is the page's own — `<html lang>`, which the build wrote
|
|
88
|
+
* from the route's locale, so a reader searching a French page is not handed English results.
|
|
89
|
+
* `null` searches every language.
|
|
90
|
+
*/
|
|
91
|
+
locale?: string | null;
|
|
46
92
|
}
|
|
47
93
|
|
|
48
94
|
interface ClientState {
|
|
@@ -53,7 +99,162 @@ interface ClientState {
|
|
|
53
99
|
|
|
54
100
|
const state: ClientState = { indexUrl: "/search-index.json", loading: null, mini: null };
|
|
55
101
|
|
|
56
|
-
|
|
102
|
+
/** `text` is stored so flat results can carry a highlighted excerpt of the body. */
|
|
103
|
+
const STORE_FIELDS = [
|
|
104
|
+
"collection",
|
|
105
|
+
"slug",
|
|
106
|
+
"url",
|
|
107
|
+
"title",
|
|
108
|
+
"description",
|
|
109
|
+
"heading",
|
|
110
|
+
"text",
|
|
111
|
+
"locale",
|
|
112
|
+
];
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The language this page is in, from `<html lang>`.
|
|
116
|
+
*
|
|
117
|
+
* The document element is the right source and the only one available: the build wrote that
|
|
118
|
+
* attribute from the route's locale (site-architecture.md §13.4), so it is the same answer the
|
|
119
|
+
* index was built against, and it is correct inside a component, an island and a fragment alike.
|
|
120
|
+
*
|
|
121
|
+
* @returns {string} The tag, or `""` off a document
|
|
122
|
+
*/
|
|
123
|
+
function pageLocale(): string {
|
|
124
|
+
return globalThis.document?.documentElement?.lang ?? "";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Whether a document in `docLocale` answers a search made in `want`.
|
|
129
|
+
*
|
|
130
|
+
* A document with no locale always answers: an unlocalized collection is not in one language, it is
|
|
131
|
+
* outside the question, and hiding it would empty the search box on a site whose docs are shared.
|
|
132
|
+
*
|
|
133
|
+
* Region is ignored when the tags disagree on it alone — a reader on `/fr-ca/` wants the French
|
|
134
|
+
* posts — which is RFC 4647 Lookup's truncation, at the one length that matters here.
|
|
135
|
+
*/
|
|
136
|
+
/**
|
|
137
|
+
* What makes two hits the same page.
|
|
138
|
+
*
|
|
139
|
+
* The locale is part of it because two translations of one entry **share a slug** (§13.3): without
|
|
140
|
+
* it, an unscoped search collapses the French copy and the English one into a single group whose
|
|
141
|
+
* title is whichever was indexed first.
|
|
142
|
+
*/
|
|
143
|
+
function pageKey(hit: RawHit): string {
|
|
144
|
+
return `${hit.collection}:${hit.locale ?? ""}:${hit.slug}`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function localeAnswers(docLocale: string | undefined, want: string): boolean {
|
|
148
|
+
if (docLocale === undefined || want === "") {
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
const a = docLocale.toLowerCase();
|
|
152
|
+
const b = want.toLowerCase();
|
|
153
|
+
return a === b || a.split("-")[0] === b.split("-")[0];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** A fresh word scanner — regexes with `g` carry `lastIndex`, so never share one. */
|
|
157
|
+
function wordScanner(): RegExp {
|
|
158
|
+
return /[\p{L}\p{N}_]+/gu;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Excerpt window, in characters. */
|
|
162
|
+
const EXCERPT_WIDTH = 160;
|
|
163
|
+
|
|
164
|
+
/** How much of the excerpt window sits before the first match. */
|
|
165
|
+
const EXCERPT_LEAD = 60;
|
|
166
|
+
|
|
167
|
+
/** Default cap on rows one page may contribute to a flat result list. */
|
|
168
|
+
const PAGE_CAP = 3;
|
|
169
|
+
|
|
170
|
+
/** Lowercased needles a word must start with to count as a match. */
|
|
171
|
+
function needlesOf(terms: readonly string[]): string[] {
|
|
172
|
+
const seen = new Set<string>();
|
|
173
|
+
for (const term of terms) {
|
|
174
|
+
if (term) {
|
|
175
|
+
seen.add(term.toLowerCase());
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return [...seen];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** `[start, end)` spans of the whole words in `text` that begin with one of `needles`. */
|
|
182
|
+
function matchSpans(text: string, needles: readonly string[]): [number, number][] {
|
|
183
|
+
const spans: [number, number][] = [];
|
|
184
|
+
if (!text || needles.length === 0) {
|
|
185
|
+
return spans;
|
|
186
|
+
}
|
|
187
|
+
const lower = text.toLowerCase();
|
|
188
|
+
const scanner = wordScanner();
|
|
189
|
+
let match = scanner.exec(lower);
|
|
190
|
+
while (match !== null) {
|
|
191
|
+
const [word] = match;
|
|
192
|
+
if (needles.some((needle) => word.startsWith(needle))) {
|
|
193
|
+
spans.push([match.index, match.index + word.length]);
|
|
194
|
+
}
|
|
195
|
+
match = scanner.exec(lower);
|
|
196
|
+
}
|
|
197
|
+
return spans;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Split `text` into alternating plain and matched runs. A word is matched when it starts with one
|
|
202
|
+
* of the query's terms, so a prefix search for "intro" highlights the whole word "introduction".
|
|
203
|
+
*/
|
|
204
|
+
export function buildTokens(text: string, terms: readonly string[]): SearchToken[] {
|
|
205
|
+
if (!text) {
|
|
206
|
+
return [];
|
|
207
|
+
}
|
|
208
|
+
const tokens: SearchToken[] = [];
|
|
209
|
+
let cursor = 0;
|
|
210
|
+
for (const [start, end] of matchSpans(text, needlesOf(terms))) {
|
|
211
|
+
if (start > cursor) {
|
|
212
|
+
tokens.push({ m: false, t: text.slice(cursor, start) });
|
|
213
|
+
}
|
|
214
|
+
tokens.push({ m: true, t: text.slice(start, end) });
|
|
215
|
+
cursor = end;
|
|
216
|
+
}
|
|
217
|
+
if (cursor < text.length) {
|
|
218
|
+
tokens.push({ m: false, t: text.slice(cursor) });
|
|
219
|
+
}
|
|
220
|
+
return tokens;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* A readable window of `text` around its first match, snapped to word boundaries and elided with
|
|
225
|
+
* `…` on either side when truncated. With no match in the body, the opening of the text is used.
|
|
226
|
+
*/
|
|
227
|
+
export function buildExcerpt(text: string, terms: readonly string[]): string {
|
|
228
|
+
const body = text.trim();
|
|
229
|
+
if (!body) {
|
|
230
|
+
return "";
|
|
231
|
+
}
|
|
232
|
+
const at = matchSpans(body, needlesOf(terms))[0]?.[0] ?? -1;
|
|
233
|
+
let start = at < 0 ? 0 : Math.max(0, at - EXCERPT_LEAD);
|
|
234
|
+
if (start > 0) {
|
|
235
|
+
const space = body.indexOf(" ", start);
|
|
236
|
+
start = space === -1 || space >= at ? start : space + 1;
|
|
237
|
+
}
|
|
238
|
+
let end = Math.min(body.length, start + EXCERPT_WIDTH);
|
|
239
|
+
if (end < body.length) {
|
|
240
|
+
const space = body.lastIndexOf(" ", end);
|
|
241
|
+
if (space > start) {
|
|
242
|
+
end = space;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return `${start > 0 ? "…" : ""}${body.slice(start, end).trim()}${end < body.length ? "…" : ""}`;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Breadcrumb labels for a slug's ancestor segments: `framework/site/search` → `Framework › Site`. */
|
|
249
|
+
export function crumbsFromSlug(slug: string): string[] {
|
|
250
|
+
return slug
|
|
251
|
+
.split("/")
|
|
252
|
+
.filter(Boolean)
|
|
253
|
+
.slice(0, -1)
|
|
254
|
+
.map((segment) =>
|
|
255
|
+
segment.replaceAll(/[-_]+/g, " ").replaceAll(/\b\p{Ll}/gu, (char) => char.toUpperCase()),
|
|
256
|
+
);
|
|
257
|
+
}
|
|
57
258
|
|
|
58
259
|
/** Override the default index URL for subsequent `preload()` calls. */
|
|
59
260
|
export function configure(opts: { indexUrl?: string }): void {
|
|
@@ -99,24 +300,53 @@ export function preload(indexUrl?: string): Promise<void> {
|
|
|
99
300
|
return state.loading;
|
|
100
301
|
}
|
|
101
302
|
|
|
102
|
-
/** Raw MiniSearch hit with our stored fields. */
|
|
303
|
+
/** Raw MiniSearch hit with our stored fields and the terms it matched on. */
|
|
103
304
|
interface RawHit {
|
|
104
305
|
id: string;
|
|
105
306
|
score: number;
|
|
307
|
+
terms: string[];
|
|
308
|
+
queryTerms: string[];
|
|
106
309
|
collection: string;
|
|
107
310
|
slug: string;
|
|
108
311
|
url: string;
|
|
109
312
|
title: string;
|
|
110
313
|
description: string;
|
|
111
314
|
heading: string;
|
|
315
|
+
text: string;
|
|
316
|
+
locale?: string;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Turn a raw hit into a presentation-ready row: breadcrumbs plus highlighted title and excerpt. */
|
|
320
|
+
function toResult(hit: RawHit): SearchResult {
|
|
321
|
+
// `terms` holds the matched document words, so a prefix or fuzzy search highlights the whole
|
|
322
|
+
// `word` it landed on; `queryTerms` holds what the user actually typed.
|
|
323
|
+
const terms = [...hit.terms, ...hit.queryTerms];
|
|
324
|
+
const crumbs = crumbsFromSlug(hit.slug);
|
|
325
|
+
if (hit.heading) {
|
|
326
|
+
crumbs.push(hit.title);
|
|
327
|
+
}
|
|
328
|
+
return {
|
|
329
|
+
collection: hit.collection,
|
|
330
|
+
crumbs,
|
|
331
|
+
description: hit.description,
|
|
332
|
+
excerptTokens: buildTokens(buildExcerpt(hit.text ?? "", terms), terms),
|
|
333
|
+
heading: hit.heading,
|
|
334
|
+
id: hit.id,
|
|
335
|
+
score: hit.score,
|
|
336
|
+
slug: hit.slug,
|
|
337
|
+
title: hit.title,
|
|
338
|
+
titleTokens: buildTokens(hit.heading || hit.title, terms),
|
|
339
|
+
url: hit.url,
|
|
340
|
+
};
|
|
112
341
|
}
|
|
113
342
|
|
|
114
343
|
/**
|
|
115
344
|
* Search synchronously. Returns `[]` until `preload` completes (and kicks it off if nobody has).
|
|
116
345
|
* With `group: true` (default), section hits nest under their page, groups sorted by best score;
|
|
117
|
-
* `group: false` returns
|
|
346
|
+
* `group: false` returns flat `SearchResult` rows — one per matching page or section, at most
|
|
347
|
+
* `pageCap` from any one page.
|
|
118
348
|
*/
|
|
119
|
-
export function query(q: string, opts: QueryOptions = {}): SearchResultGroup[] |
|
|
349
|
+
export function query(q: string, opts: QueryOptions = {}): SearchResultGroup[] | SearchResult[] {
|
|
120
350
|
const limit = opts.limit ?? 8;
|
|
121
351
|
const group = opts.group ?? true;
|
|
122
352
|
if (!state.mini) {
|
|
@@ -127,14 +357,37 @@ export function query(q: string, opts: QueryOptions = {}): SearchResultGroup[] |
|
|
|
127
357
|
if (!text) {
|
|
128
358
|
return [];
|
|
129
359
|
}
|
|
130
|
-
|
|
360
|
+
/*
|
|
361
|
+
* Scoped to the page's language unless the caller says otherwise. A multilingual site's index
|
|
362
|
+
* holds every translation of every post, so an unscoped search hands a French reader the English
|
|
363
|
+
* copy of the page they are already on — ranked first, because it matched the same words.
|
|
364
|
+
*/
|
|
365
|
+
const want = opts.locale === undefined ? pageLocale() : (opts.locale ?? "");
|
|
366
|
+
const raw = (state.mini.search(text) as unknown as RawHit[]).filter((hit) =>
|
|
367
|
+
localeAnswers(hit.locale, want),
|
|
368
|
+
);
|
|
131
369
|
if (!group) {
|
|
132
|
-
|
|
370
|
+
const pageCap = opts.pageCap ?? PAGE_CAP;
|
|
371
|
+
const perPage = new Map<string, number>();
|
|
372
|
+
const rows: SearchResult[] = [];
|
|
373
|
+
for (const hit of raw) {
|
|
374
|
+
if (rows.length >= limit) {
|
|
375
|
+
break;
|
|
376
|
+
}
|
|
377
|
+
const key = pageKey(hit);
|
|
378
|
+
const taken = perPage.get(key) ?? 0;
|
|
379
|
+
if (taken >= pageCap) {
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
perPage.set(key, taken + 1);
|
|
383
|
+
rows.push(toResult(hit));
|
|
384
|
+
}
|
|
385
|
+
return rows;
|
|
133
386
|
}
|
|
134
387
|
|
|
135
388
|
const groups = new Map<string, SearchResultGroup>();
|
|
136
389
|
for (const hit of raw) {
|
|
137
|
-
const key =
|
|
390
|
+
const key = pageKey(hit);
|
|
138
391
|
let entry = groups.get(key);
|
|
139
392
|
if (!entry) {
|
|
140
393
|
entry = {
|
|
@@ -174,22 +427,26 @@ export function searchInit(componentState: Record<string, unknown>): boolean {
|
|
|
174
427
|
return true;
|
|
175
428
|
}
|
|
176
429
|
|
|
430
|
+
/** Rows a `$src` component search shows at once. */
|
|
431
|
+
const UI_LIMIT = 20;
|
|
432
|
+
|
|
177
433
|
/**
|
|
178
434
|
* State-convention query handler for `$src` component use: reads the query from the input event (or
|
|
179
|
-
* `state.searchQuery`), stores
|
|
180
|
-
* index.
|
|
435
|
+
* `state.searchQuery`), stores flat highlighted rows on `state.searchResults`, publishes the row
|
|
436
|
+
* count as `state.searchCount`, and resets the active-row index.
|
|
181
437
|
*/
|
|
182
438
|
export function runSearch(
|
|
183
439
|
componentState: Record<string, unknown>,
|
|
184
440
|
event?: { target?: { value?: unknown } | null },
|
|
185
|
-
):
|
|
441
|
+
): SearchResult[] {
|
|
186
442
|
const fromEvent =
|
|
187
443
|
event?.target && typeof event.target.value === "string" ? event.target.value : null;
|
|
188
444
|
const q =
|
|
189
445
|
fromEvent ?? (typeof componentState.searchQuery === "string" ? componentState.searchQuery : "");
|
|
190
446
|
componentState.searchQuery = q;
|
|
191
|
-
const results = query(q);
|
|
447
|
+
const results = query(q, { group: false, limit: UI_LIMIT }) as SearchResult[];
|
|
192
448
|
componentState.searchResults = results;
|
|
449
|
+
componentState.searchCount = results.length;
|
|
193
450
|
componentState.searchActive = 0;
|
|
194
451
|
return results;
|
|
195
452
|
}
|
package/src/search-index.ts
CHANGED
|
@@ -10,9 +10,10 @@
|
|
|
10
10
|
* @docs extending/extensions/search
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
import { canonicalizeLocale, localeUrlPrefix, resolveI18n } from "@jxsuite/schema/locale";
|
|
13
14
|
import { entryUrl, jxTreeToText, normalizeSearchConfig, splitSections } from "./shared.ts";
|
|
14
15
|
import type { NormalizedSearchConfig } from "./shared.ts";
|
|
15
|
-
import type { ContentLoaderEntry, JxElement } from "@jxsuite/schema/types";
|
|
16
|
+
import type { ContentLoaderEntry, JxElement, ProjectConfig } from "@jxsuite/schema/types";
|
|
16
17
|
|
|
17
18
|
/** One searchable document in the emitted index. */
|
|
18
19
|
export interface SearchDocument {
|
|
@@ -29,6 +30,12 @@ export interface SearchDocument {
|
|
|
29
30
|
/** Section heading text; empty for page-level documents. */
|
|
30
31
|
heading: string;
|
|
31
32
|
text: string;
|
|
33
|
+
/**
|
|
34
|
+
* The entry's language, present only for a collection spread over one directory per locale
|
|
35
|
+
* (site-architecture.md §13.3). Absent means "every language" — an unlocalized collection is not
|
|
36
|
+
* in one language, it is outside the question.
|
|
37
|
+
*/
|
|
38
|
+
locale?: string;
|
|
32
39
|
}
|
|
33
40
|
|
|
34
41
|
/** The emitted index file: engine-tagged envelope around the document list. */
|
|
@@ -66,6 +73,13 @@ export const SearchIndex = {
|
|
|
66
73
|
const config = normalizeSearchConfig(sectionValue);
|
|
67
74
|
const content = ctx.sections?.content as Map<string, ContentLoaderEntry[]> | undefined;
|
|
68
75
|
const trailingSlash = ctx.projectConfig?.build?.trailingSlash ?? "always";
|
|
76
|
+
/*
|
|
77
|
+
* A localized entry lives at a locale-prefixed URL, and the index is the only place that knew
|
|
78
|
+
* the entry without knowing the prefix: every French post was indexed at the English post's
|
|
79
|
+
* URL, so a reader who searched in French was sent to the English page. The prefix is the
|
|
80
|
+
* routing rule, read from the same resolver the build reads.
|
|
81
|
+
*/
|
|
82
|
+
const { i18n } = resolveI18n((ctx.projectConfig ?? {}) as ProjectConfig);
|
|
69
83
|
|
|
70
84
|
const documents: SearchDocument[] = [];
|
|
71
85
|
const fields = new Set<string>();
|
|
@@ -86,13 +100,31 @@ export const SearchIndex = {
|
|
|
86
100
|
|
|
87
101
|
for (const entry of entries) {
|
|
88
102
|
const data = (entry.data ?? {}) as Record<string, unknown>;
|
|
89
|
-
|
|
103
|
+
/*
|
|
104
|
+
* Only when the project declares locales. An entry cannot carry one otherwise — the loader
|
|
105
|
+
* stamps it while expanding `{locale}`, which needs a list to expand over — and a stray tag
|
|
106
|
+
* would make the client filter results away for a language the site does not have.
|
|
107
|
+
*/
|
|
108
|
+
const locale =
|
|
109
|
+
i18n === null ? undefined : (canonicalizeLocale(entry._meta?.locale) ?? undefined);
|
|
110
|
+
const url = entryUrl(
|
|
111
|
+
`${localeUrlPrefix(locale, i18n)}${collection.basePath}`,
|
|
112
|
+
entry.id,
|
|
113
|
+
trailingSlash,
|
|
114
|
+
);
|
|
90
115
|
const title = typeof data.title === "string" ? data.title : entry.id;
|
|
91
116
|
const description = typeof data.description === "string" ? data.description : "";
|
|
92
117
|
const children = entry.$children as (JxElement | string)[] | undefined;
|
|
118
|
+
/*
|
|
119
|
+
* Two translations of one post share an id (§13.3), so the document id has to carry the
|
|
120
|
+
* locale as well — without it the French entry and the English one are one document and
|
|
121
|
+
* whichever is indexed second wins.
|
|
122
|
+
*/
|
|
123
|
+
const documentId =
|
|
124
|
+
locale === undefined ? `${name}:${entry.id}` : `${name}:${locale}:${entry.id}`;
|
|
93
125
|
|
|
94
126
|
documents.push({
|
|
95
|
-
id:
|
|
127
|
+
id: documentId,
|
|
96
128
|
collection: name,
|
|
97
129
|
slug: entry.id,
|
|
98
130
|
url,
|
|
@@ -100,12 +132,13 @@ export const SearchIndex = {
|
|
|
100
132
|
description,
|
|
101
133
|
heading: "",
|
|
102
134
|
text: jxTreeToText(children),
|
|
135
|
+
...(locale === undefined ? {} : { locale }),
|
|
103
136
|
});
|
|
104
137
|
|
|
105
138
|
if (collection.sections) {
|
|
106
139
|
for (const section of splitSections(children, collection.sectionDepth)) {
|
|
107
140
|
documents.push({
|
|
108
|
-
id: `${
|
|
141
|
+
id: `${documentId}#${section.anchor}`,
|
|
109
142
|
collection: name,
|
|
110
143
|
slug: entry.id,
|
|
111
144
|
url: `${url}#${section.anchor}`,
|
|
@@ -113,6 +146,7 @@ export const SearchIndex = {
|
|
|
113
146
|
description,
|
|
114
147
|
heading: section.heading,
|
|
115
148
|
text: section.text,
|
|
149
|
+
...(locale === undefined ? {} : { locale }),
|
|
116
150
|
});
|
|
117
151
|
}
|
|
118
152
|
}
|
package/src/search-state.ts
CHANGED
|
@@ -32,6 +32,12 @@ export interface SearchDef {
|
|
|
32
32
|
group?: boolean;
|
|
33
33
|
/** Override the index URL (defaults to the project's `search.output`). */
|
|
34
34
|
index?: string;
|
|
35
|
+
/**
|
|
36
|
+
* Language to search in. Omitted, the client uses the page's own — `<html lang>`, which the build
|
|
37
|
+
* wrote from the route's locale — so a search box needs no configuration to stop handing a French
|
|
38
|
+
* reader the English copy of the page they are on. `null` searches every language.
|
|
39
|
+
*/
|
|
40
|
+
locale?: string | null;
|
|
35
41
|
default?: unknown;
|
|
36
42
|
[key: string]: unknown;
|
|
37
43
|
}
|
|
@@ -75,13 +81,17 @@ export class Search {
|
|
|
75
81
|
const clientUrl = sidecarAssetPath(CLIENT_SPECIFIER);
|
|
76
82
|
const client = (await import(clientUrl)) as {
|
|
77
83
|
preload: (url?: string) => Promise<void>;
|
|
78
|
-
query: (
|
|
84
|
+
query: (
|
|
85
|
+
q: string,
|
|
86
|
+
opts?: { limit?: number; group?: boolean; locale?: string | null },
|
|
87
|
+
) => unknown;
|
|
79
88
|
};
|
|
80
89
|
await client.preload(this.indexUrl());
|
|
81
90
|
const query = typeof this.config.query === "string" ? this.config.query : "";
|
|
82
91
|
return client.query(query, {
|
|
83
92
|
group: this.config.group ?? true,
|
|
84
93
|
limit: this.config.limit ?? 8,
|
|
94
|
+
...(this.config.locale === undefined ? {} : { locale: this.config.locale }),
|
|
85
95
|
});
|
|
86
96
|
}
|
|
87
97
|
|
|
@@ -98,7 +108,16 @@ export class Search {
|
|
|
98
108
|
static lower(def: SearchDef, context: LowerContext = {}): Record<string, unknown> {
|
|
99
109
|
const indexUrl = def.index ?? context.projectConfig?.search?.output ?? "/search-index.json";
|
|
100
110
|
const clientUrl = sidecarAssetPath(CLIENT_SPECIFIER);
|
|
101
|
-
|
|
111
|
+
/*
|
|
112
|
+
* `locale` is passed only when the def names one. Omitting the key is not the same as passing
|
|
113
|
+
* `undefined` through JSON — the client reads "absent" as "the page's own language", which is
|
|
114
|
+
* the default a search box should have and the one nobody has to configure.
|
|
115
|
+
*/
|
|
116
|
+
const opts = JSON.stringify({
|
|
117
|
+
group: def.group ?? true,
|
|
118
|
+
limit: def.limit ?? 8,
|
|
119
|
+
...(def.locale === undefined ? {} : { locale: def.locale }),
|
|
120
|
+
});
|
|
102
121
|
const body = [
|
|
103
122
|
// Reading the ready flag makes the computed re-run when the client finishes loading.
|
|
104
123
|
"void state.__jxSearchReady;",
|
package/src/shared.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type { JxElement } from "@jxsuite/schema/types";
|
|
10
|
+
import { displayTagName } from "@jxsuite/schema/guards";
|
|
10
11
|
|
|
11
12
|
/** One collection's index configuration inside the `search` project section. */
|
|
12
13
|
export interface SearchCollectionConfig {
|
|
@@ -159,7 +160,7 @@ function headingOf(
|
|
|
159
160
|
if (typeof node !== "object" || node === null || !node.tagName) {
|
|
160
161
|
return null;
|
|
161
162
|
}
|
|
162
|
-
const match = HEADING_TAG.exec(node.tagName);
|
|
163
|
+
const match = HEADING_TAG.exec(displayTagName(node.tagName));
|
|
163
164
|
if (!match) {
|
|
164
165
|
return null;
|
|
165
166
|
}
|