@jxsuite/search 0.1.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/dist/search-index.js +165 -0
- package/dist/search-state.js +82 -0
- package/jx-extension.json +12 -0
- package/package.json +46 -0
- package/schemas/project.fragment.schema.json +67 -0
- package/src/Search.class.json +71 -0
- package/src/SearchIndex.class.json +95 -0
- package/src/client.ts +195 -0
- package/src/search-index.ts +131 -0
- package/src/search-state.ts +123 -0
- package/src/shared.ts +171 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// src/shared.ts
|
|
2
|
+
var DEFAULT_OUTPUT = "/search-index.json";
|
|
3
|
+
var DEFAULT_FIELDS = ["title", "heading", "text"];
|
|
4
|
+
var DEFAULT_SECTION_DEPTH = 3;
|
|
5
|
+
function normalizeSearchConfig(sectionValue) {
|
|
6
|
+
const section = sectionValue ?? {};
|
|
7
|
+
const collections = {};
|
|
8
|
+
for (const [name, raw] of Object.entries(section.collections ?? {})) {
|
|
9
|
+
collections[name] = {
|
|
10
|
+
basePath: normalizeBasePath(raw.basePath ?? "/"),
|
|
11
|
+
boost: raw.boost ?? {},
|
|
12
|
+
fields: raw.fields ?? DEFAULT_FIELDS,
|
|
13
|
+
sectionDepth: raw.sectionDepth ?? DEFAULT_SECTION_DEPTH,
|
|
14
|
+
sections: raw.sections ?? true
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
return {
|
|
18
|
+
engine: section.engine ?? "minisearch",
|
|
19
|
+
output: section.output ?? DEFAULT_OUTPUT,
|
|
20
|
+
collections
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
function normalizeBasePath(basePath) {
|
|
24
|
+
let path = basePath.startsWith("/") ? basePath : `/${basePath}`;
|
|
25
|
+
if (!path.endsWith("/")) {
|
|
26
|
+
path = `${path}/`;
|
|
27
|
+
}
|
|
28
|
+
return path;
|
|
29
|
+
}
|
|
30
|
+
function entryUrl(basePath, id, trailingSlash) {
|
|
31
|
+
const base = `${basePath}${id.replace(/^\//, "")}`;
|
|
32
|
+
return trailingSlash === "never" ? base : `${base}/`;
|
|
33
|
+
}
|
|
34
|
+
function jxTreeToText(children) {
|
|
35
|
+
return collectText(children).join(" ").replaceAll(/\s+/g, " ").trim();
|
|
36
|
+
}
|
|
37
|
+
function collectText(children) {
|
|
38
|
+
const parts = [];
|
|
39
|
+
for (const node of children ?? []) {
|
|
40
|
+
if (typeof node === "string") {
|
|
41
|
+
parts.push(node);
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (typeof node !== "object" || node === null) {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (typeof node.textContent === "string") {
|
|
48
|
+
parts.push(node.textContent);
|
|
49
|
+
}
|
|
50
|
+
if (Array.isArray(node.children)) {
|
|
51
|
+
parts.push(...collectText(node.children));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return parts;
|
|
55
|
+
}
|
|
56
|
+
var HEADING_TAG = /^h([1-6])$/;
|
|
57
|
+
function splitSections(children, sectionDepth) {
|
|
58
|
+
const sections = [];
|
|
59
|
+
let current = null;
|
|
60
|
+
for (const node of children ?? []) {
|
|
61
|
+
const heading = headingOf(node);
|
|
62
|
+
if (heading && heading.depth <= sectionDepth && heading.anchor) {
|
|
63
|
+
if (current) {
|
|
64
|
+
sections.push(finishSection(current));
|
|
65
|
+
}
|
|
66
|
+
current = { ...heading, parts: [], text: "" };
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (current && typeof node === "string") {
|
|
70
|
+
current.parts.push(node);
|
|
71
|
+
} else if (current && typeof node === "object" && node !== null) {
|
|
72
|
+
current.parts.push(jxTreeToText([node]));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (current) {
|
|
76
|
+
sections.push(finishSection(current));
|
|
77
|
+
}
|
|
78
|
+
return sections;
|
|
79
|
+
}
|
|
80
|
+
function finishSection(section) {
|
|
81
|
+
const { heading, anchor, depth } = section;
|
|
82
|
+
return { anchor, depth, heading, text: section.parts.join(" ").replaceAll(/\s+/g, " ").trim() };
|
|
83
|
+
}
|
|
84
|
+
function headingOf(node) {
|
|
85
|
+
if (typeof node !== "object" || node === null || !node.tagName) {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
const match = HEADING_TAG.exec(node.tagName);
|
|
89
|
+
if (!match) {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
anchor: typeof node.id === "string" ? node.id : "",
|
|
94
|
+
depth: Number(match[1]),
|
|
95
|
+
heading: jxTreeToText([node])
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// src/search-index.ts
|
|
100
|
+
var SearchIndex = {
|
|
101
|
+
projectData(sectionValue) {
|
|
102
|
+
return normalizeSearchConfig(sectionValue);
|
|
103
|
+
},
|
|
104
|
+
emit(sectionValue, ctx) {
|
|
105
|
+
const config = normalizeSearchConfig(sectionValue);
|
|
106
|
+
const content = ctx.sections?.content;
|
|
107
|
+
const trailingSlash = ctx.projectConfig?.build?.trailingSlash ?? "always";
|
|
108
|
+
const documents = [];
|
|
109
|
+
const fields = new Set;
|
|
110
|
+
const boost = {};
|
|
111
|
+
for (const [name, collection] of Object.entries(config.collections)) {
|
|
112
|
+
const entries = content?.get(name);
|
|
113
|
+
if (!entries) {
|
|
114
|
+
console.warn(`@jxsuite/search: collection "${name}" is not a loaded content collection — skipped`);
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
for (const field of collection.fields) {
|
|
118
|
+
fields.add(field);
|
|
119
|
+
}
|
|
120
|
+
Object.assign(boost, collection.boost);
|
|
121
|
+
for (const entry of entries) {
|
|
122
|
+
const data = entry.data ?? {};
|
|
123
|
+
const url = entryUrl(collection.basePath, entry.id, trailingSlash);
|
|
124
|
+
const title = typeof data.title === "string" ? data.title : entry.id;
|
|
125
|
+
const description = typeof data.description === "string" ? data.description : "";
|
|
126
|
+
const children = entry.$children;
|
|
127
|
+
documents.push({
|
|
128
|
+
id: `${name}:${entry.id}`,
|
|
129
|
+
collection: name,
|
|
130
|
+
slug: entry.id,
|
|
131
|
+
url,
|
|
132
|
+
title,
|
|
133
|
+
description,
|
|
134
|
+
heading: "",
|
|
135
|
+
text: jxTreeToText(children)
|
|
136
|
+
});
|
|
137
|
+
if (collection.sections) {
|
|
138
|
+
for (const section of splitSections(children, collection.sectionDepth)) {
|
|
139
|
+
documents.push({
|
|
140
|
+
id: `${name}:${entry.id}#${section.anchor}`,
|
|
141
|
+
collection: name,
|
|
142
|
+
slug: entry.id,
|
|
143
|
+
url: `${url}#${section.anchor}`,
|
|
144
|
+
title,
|
|
145
|
+
description,
|
|
146
|
+
heading: section.heading,
|
|
147
|
+
text: section.text
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const envelope = {
|
|
154
|
+
version: 1,
|
|
155
|
+
engine: config.engine,
|
|
156
|
+
fields: [...fields],
|
|
157
|
+
boost,
|
|
158
|
+
documents
|
|
159
|
+
};
|
|
160
|
+
return [{ path: config.output, content: JSON.stringify(envelope) }];
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
export {
|
|
164
|
+
SearchIndex
|
|
165
|
+
};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// ../../packages/schema/src/asset-paths.ts
|
|
2
|
+
var NPM_SPECIFIER_PREFIX = "npm:";
|
|
3
|
+
var SIDECAR_ASSET_DIR = "/assets/";
|
|
4
|
+
function isNpmSpecifier(specifier) {
|
|
5
|
+
return specifier.startsWith(NPM_SPECIFIER_PREFIX);
|
|
6
|
+
}
|
|
7
|
+
function sidecarAssetPath(specifier) {
|
|
8
|
+
let base = specifier;
|
|
9
|
+
if (isNpmSpecifier(base)) {
|
|
10
|
+
base = base.slice(NPM_SPECIFIER_PREFIX.length);
|
|
11
|
+
}
|
|
12
|
+
base = base.replace(/^\.\//, "").replace(/\.(js|ts|mjs|mts)$/, "").replaceAll("@", "").replaceAll(/[/\\]+/g, "-").replaceAll(/[^\w.-]/g, "-").replaceAll(/-+/g, "-").replaceAll(/^-|-$/g, "");
|
|
13
|
+
return `${SIDECAR_ASSET_DIR}${base}.js`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// src/search-state.ts
|
|
17
|
+
var CLIENT_SPECIFIER = "npm:@jxsuite/search/client";
|
|
18
|
+
function inBrowser() {
|
|
19
|
+
return typeof document !== "undefined" && typeof fetch === "function";
|
|
20
|
+
}
|
|
21
|
+
function queryExpression(query) {
|
|
22
|
+
if (typeof query === "object" && query !== null && typeof query.$ref === "string") {
|
|
23
|
+
const path = query.$ref.replace(/^#\//, "").split("/");
|
|
24
|
+
if (path[0] === "state" && path.length > 1) {
|
|
25
|
+
return ["state", ...path.slice(1)].join(".");
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return JSON.stringify(typeof query === "string" ? query : "");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
class Search {
|
|
32
|
+
config;
|
|
33
|
+
constructor(config) {
|
|
34
|
+
this.config = config;
|
|
35
|
+
}
|
|
36
|
+
async resolve() {
|
|
37
|
+
if (!inBrowser()) {
|
|
38
|
+
console.warn("Search resolves to [] outside the browser — search is a client interaction");
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
41
|
+
const clientUrl = sidecarAssetPath(CLIENT_SPECIFIER);
|
|
42
|
+
const client = await import(clientUrl);
|
|
43
|
+
await client.preload(this.indexUrl());
|
|
44
|
+
const query = typeof this.config.query === "string" ? this.config.query : "";
|
|
45
|
+
return client.query(query, {
|
|
46
|
+
group: this.config.group ?? true,
|
|
47
|
+
limit: this.config.limit ?? 8
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
indexUrl() {
|
|
51
|
+
return this.config.index ?? this.config._project?.search?.output ?? "/search-index.json";
|
|
52
|
+
}
|
|
53
|
+
static lower(def, context = {}) {
|
|
54
|
+
const indexUrl = def.index ?? context.projectConfig?.search?.output ?? "/search-index.json";
|
|
55
|
+
const clientUrl = sidecarAssetPath(CLIENT_SPECIFIER);
|
|
56
|
+
const opts = JSON.stringify({ group: def.group ?? true, limit: def.limit ?? 8 });
|
|
57
|
+
const body = [
|
|
58
|
+
"void state.__jxSearchReady;",
|
|
59
|
+
"if (!globalThis.__jxSearch) {",
|
|
60
|
+
" globalThis.__jxSearch = {};",
|
|
61
|
+
` import(${JSON.stringify(clientUrl)})`,
|
|
62
|
+
` .then((m) => { globalThis.__jxSearch.m = m; return m.preload(${JSON.stringify(indexUrl)}); })`,
|
|
63
|
+
" .then(() => { state.__jxSearchReady = true; })",
|
|
64
|
+
" .catch((err) => console.warn('search client failed to load:', err));",
|
|
65
|
+
"}",
|
|
66
|
+
"const m = globalThis.__jxSearch.m;",
|
|
67
|
+
`return m && m.isReady() ? m.query(${queryExpression(def.query)}, ${opts}) : [];`
|
|
68
|
+
].join(`
|
|
69
|
+
`);
|
|
70
|
+
return {
|
|
71
|
+
$bundle: [CLIENT_SPECIFIER],
|
|
72
|
+
$prototype: "Function",
|
|
73
|
+
body,
|
|
74
|
+
timing: "client",
|
|
75
|
+
...def.default === undefined ? {} : { default: def.default }
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
export {
|
|
80
|
+
Search,
|
|
81
|
+
CLIENT_SPECIFIER
|
|
82
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@jxsuite/search",
|
|
3
|
+
"title": "Search",
|
|
4
|
+
"description": "Build-time search index emission from content collections (MiniSearch) with section-level deep links and a headless browser client",
|
|
5
|
+
"classes": {
|
|
6
|
+
"SearchIndex": "./src/SearchIndex.class.json",
|
|
7
|
+
"Search": "./src/Search.class.json"
|
|
8
|
+
},
|
|
9
|
+
"schemas": {
|
|
10
|
+
"project": "./schemas/project.fragment.schema.json"
|
|
11
|
+
}
|
|
12
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@jxsuite/search",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Static site search for Jx: build-time index emission from content collections and a headless MiniSearch browser client",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/jxsuite/jx.git",
|
|
9
|
+
"directory": "extensions/search"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"src/",
|
|
13
|
+
"dist",
|
|
14
|
+
"jx-extension.json",
|
|
15
|
+
"schemas/"
|
|
16
|
+
],
|
|
17
|
+
"type": "module",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": "./src/search-index.ts",
|
|
20
|
+
"./client": "./src/client.ts",
|
|
21
|
+
"./search-state": "./src/search-state.ts",
|
|
22
|
+
"./shared": "./src/shared.ts",
|
|
23
|
+
"./jx-extension.json": "./jx-extension.json",
|
|
24
|
+
"./schemas/project.fragment.schema.json": "./schemas/project.fragment.schema.json",
|
|
25
|
+
"./SearchIndex.class.json": "./src/SearchIndex.class.json",
|
|
26
|
+
"./Search.class.json": "./src/Search.class.json"
|
|
27
|
+
},
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"provenance": false
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"prepare": "bun build src/search-index.ts src/search-state.ts --outdir=dist --format=esm --target=node",
|
|
33
|
+
"build": "bun run prepare",
|
|
34
|
+
"test": "bun test --isolate",
|
|
35
|
+
"test:coverage": "bun test --isolate --coverage",
|
|
36
|
+
"upgrade": "bunx npm-check-updates -u && bun install"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@jxsuite/schema": "workspace:^",
|
|
40
|
+
"minisearch": "^7.2.0"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"ajv": "^8.20.0"
|
|
44
|
+
},
|
|
45
|
+
"jx": "./jx-extension.json"
|
|
46
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://jxsuite.com/schema/ext/search/project/v1",
|
|
4
|
+
"title": "Jx search section",
|
|
5
|
+
"description": "Site search: which content collections to index at build time and where the index is emitted",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"properties": {
|
|
8
|
+
"search": {
|
|
9
|
+
"type": "object",
|
|
10
|
+
"description": "Build-time search index configuration (@jxsuite/search)",
|
|
11
|
+
"properties": {
|
|
12
|
+
"engine": {
|
|
13
|
+
"type": "string",
|
|
14
|
+
"enum": ["minisearch"],
|
|
15
|
+
"default": "minisearch",
|
|
16
|
+
"description": "Client search engine the emitted index targets"
|
|
17
|
+
},
|
|
18
|
+
"output": {
|
|
19
|
+
"type": "string",
|
|
20
|
+
"default": "/search-index.json",
|
|
21
|
+
"description": "Site-relative path of the emitted index file"
|
|
22
|
+
},
|
|
23
|
+
"collections": {
|
|
24
|
+
"type": "object",
|
|
25
|
+
"description": "Content collections to index, keyed by collection name",
|
|
26
|
+
"minProperties": 1,
|
|
27
|
+
"additionalProperties": {
|
|
28
|
+
"type": "object",
|
|
29
|
+
"properties": {
|
|
30
|
+
"basePath": {
|
|
31
|
+
"type": "string",
|
|
32
|
+
"description": "URL prefix mapping entry ids to routes, e.g. \"/docs/\""
|
|
33
|
+
},
|
|
34
|
+
"fields": {
|
|
35
|
+
"type": "array",
|
|
36
|
+
"items": { "type": "string" },
|
|
37
|
+
"default": ["title", "heading", "text"],
|
|
38
|
+
"description": "Document fields the index searches"
|
|
39
|
+
},
|
|
40
|
+
"boost": {
|
|
41
|
+
"type": "object",
|
|
42
|
+
"additionalProperties": { "type": "number" },
|
|
43
|
+
"description": "Per-field score boosts, e.g. { \"title\": 4, \"heading\": 2 }"
|
|
44
|
+
},
|
|
45
|
+
"sections": {
|
|
46
|
+
"type": "boolean",
|
|
47
|
+
"default": true,
|
|
48
|
+
"description": "Also index one document per heading section with #anchor deep links"
|
|
49
|
+
},
|
|
50
|
+
"sectionDepth": {
|
|
51
|
+
"type": "integer",
|
|
52
|
+
"minimum": 1,
|
|
53
|
+
"maximum": 6,
|
|
54
|
+
"default": 3,
|
|
55
|
+
"description": "Deepest heading level that starts its own section document"
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
"required": ["basePath"],
|
|
59
|
+
"additionalProperties": false
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
"required": ["collections"],
|
|
64
|
+
"additionalProperties": false
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://jxsuite.com/schemas/search/Search.class.json",
|
|
4
|
+
"title": "Search",
|
|
5
|
+
"description": "Reactive site-search results for a query — lazily loads the bundled headless client in browsers and lowers to a core Function computed in compiled sites",
|
|
6
|
+
"$prototype": "Class",
|
|
7
|
+
"extends": "Object",
|
|
8
|
+
"$implementation": "./search-state.js",
|
|
9
|
+
|
|
10
|
+
"$studio": {
|
|
11
|
+
"stateDefaults": { "timing": "client" }
|
|
12
|
+
},
|
|
13
|
+
|
|
14
|
+
"$defs": {
|
|
15
|
+
"parameters": {
|
|
16
|
+
"query": {
|
|
17
|
+
"identifier": "query",
|
|
18
|
+
"type": { "type": "string" },
|
|
19
|
+
"description": "Query text: a literal string or a $ref into page state (e.g. #/state/q)"
|
|
20
|
+
},
|
|
21
|
+
"limit": {
|
|
22
|
+
"identifier": "limit",
|
|
23
|
+
"type": { "type": "integer", "default": 8 },
|
|
24
|
+
"description": "Maximum result groups returned"
|
|
25
|
+
},
|
|
26
|
+
"group": {
|
|
27
|
+
"identifier": "group",
|
|
28
|
+
"type": { "type": "boolean", "default": true },
|
|
29
|
+
"description": "Group section hits under their page"
|
|
30
|
+
},
|
|
31
|
+
"index": {
|
|
32
|
+
"identifier": "index",
|
|
33
|
+
"type": { "type": "string" },
|
|
34
|
+
"description": "Index URL override (defaults to the project's search.output)"
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"constructor": {
|
|
38
|
+
"role": "constructor",
|
|
39
|
+
"parameters": [
|
|
40
|
+
{ "$ref": "#/$defs/parameters/query" },
|
|
41
|
+
{ "$ref": "#/$defs/parameters/limit" },
|
|
42
|
+
{ "$ref": "#/$defs/parameters/group" },
|
|
43
|
+
{ "$ref": "#/$defs/parameters/index" }
|
|
44
|
+
],
|
|
45
|
+
"body": ["this.config = config;"]
|
|
46
|
+
},
|
|
47
|
+
"methods": {
|
|
48
|
+
"resolve": {
|
|
49
|
+
"role": "method",
|
|
50
|
+
"scope": "instance",
|
|
51
|
+
"identifier": "resolve",
|
|
52
|
+
"returnType": { "type": "array" },
|
|
53
|
+
"description": "Grouped search results for the configured query (browser only; [] under node)"
|
|
54
|
+
},
|
|
55
|
+
"lower": {
|
|
56
|
+
"role": "lower",
|
|
57
|
+
"$prototype": "Function",
|
|
58
|
+
"access": "public",
|
|
59
|
+
"scope": "static",
|
|
60
|
+
"identifier": "lower",
|
|
61
|
+
"timing": ["compiler"],
|
|
62
|
+
"parameters": [
|
|
63
|
+
{ "identifier": "def", "type": { "type": "object" } },
|
|
64
|
+
{ "identifier": "context", "type": { "type": "object" } }
|
|
65
|
+
],
|
|
66
|
+
"returnType": { "type": "object" },
|
|
67
|
+
"description": "Rewrite into a core Function computed that lazy-imports the bundled client, preloads the index, and queries reactively; registers the client bundle via $bundle"
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://jxsuite.com/schemas/search/SearchIndex.class.json",
|
|
4
|
+
"title": "SearchIndex",
|
|
5
|
+
"description": "Search project section for Jx: normalizes the project.json `search` section into _project.search and emits the site's search index into the build output",
|
|
6
|
+
"$prototype": "Class",
|
|
7
|
+
"extends": "Object",
|
|
8
|
+
"$implementation": "./search-index.js",
|
|
9
|
+
|
|
10
|
+
"project": {
|
|
11
|
+
"key": "search",
|
|
12
|
+
"title": "Site Search",
|
|
13
|
+
"description": "Build-time search index generated from content collections"
|
|
14
|
+
},
|
|
15
|
+
|
|
16
|
+
"$studio": {
|
|
17
|
+
"settings": {
|
|
18
|
+
"icon": "sp-icon-search",
|
|
19
|
+
"label": "Site Search",
|
|
20
|
+
"order": 70,
|
|
21
|
+
"layout": "form"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
|
|
25
|
+
"$defs": {
|
|
26
|
+
"returnTypes": {
|
|
27
|
+
"NormalizedSearchConfig": {
|
|
28
|
+
"type": "object",
|
|
29
|
+
"description": "The search section with schema defaults applied (engine, output, per-collection fields/boost/sections)"
|
|
30
|
+
},
|
|
31
|
+
"EmitFiles": {
|
|
32
|
+
"type": "array",
|
|
33
|
+
"description": "One emitted file: the search-index JSON envelope at the configured output path",
|
|
34
|
+
"items": {
|
|
35
|
+
"type": "object",
|
|
36
|
+
"properties": {
|
|
37
|
+
"path": { "type": "string" },
|
|
38
|
+
"content": { "type": "string" }
|
|
39
|
+
},
|
|
40
|
+
"required": ["path", "content"]
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
"methods": {
|
|
46
|
+
"projectData": {
|
|
47
|
+
"role": "projectData",
|
|
48
|
+
"$prototype": "Function",
|
|
49
|
+
"access": "public",
|
|
50
|
+
"scope": "static",
|
|
51
|
+
"identifier": "projectData",
|
|
52
|
+
"timing": ["compiler", "server"],
|
|
53
|
+
"parameters": [
|
|
54
|
+
{
|
|
55
|
+
"identifier": "sectionValue",
|
|
56
|
+
"type": { "type": "object" },
|
|
57
|
+
"description": "The project.json `search` section value"
|
|
58
|
+
}
|
|
59
|
+
],
|
|
60
|
+
"returnType": { "$ref": "#/$defs/returnTypes/NormalizedSearchConfig" },
|
|
61
|
+
"description": "Normalize the search section (defaults applied) into _project.search"
|
|
62
|
+
},
|
|
63
|
+
"emit": {
|
|
64
|
+
"role": "emit",
|
|
65
|
+
"$prototype": "Function",
|
|
66
|
+
"access": "public",
|
|
67
|
+
"scope": "static",
|
|
68
|
+
"identifier": "emit",
|
|
69
|
+
"timing": ["compiler"],
|
|
70
|
+
"parameters": [
|
|
71
|
+
{
|
|
72
|
+
"identifier": "sectionValue",
|
|
73
|
+
"type": { "type": "object" },
|
|
74
|
+
"description": "The project.json `search` section value"
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
"identifier": "ctx",
|
|
78
|
+
"type": {
|
|
79
|
+
"type": "object",
|
|
80
|
+
"properties": {
|
|
81
|
+
"projectConfig": { "type": "object" },
|
|
82
|
+
"root": { "type": "string" },
|
|
83
|
+
"sections": { "type": "object" },
|
|
84
|
+
"routes": { "type": "array" }
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
"description": "Host context: { projectConfig, root, sections, routes } (extensions.md §8.4)"
|
|
88
|
+
}
|
|
89
|
+
],
|
|
90
|
+
"returnType": { "$ref": "#/$defs/returnTypes/EmitFiles" },
|
|
91
|
+
"description": "Build the search-index JSON from the loaded content collections; the host writes it into dist"
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client — the headless browser search client.
|
|
3
|
+
*
|
|
4
|
+
* Bundled into `/assets/` by the site build (spec.md §5.3); no UI. Fetches the emitted index
|
|
5
|
+
* envelope once, builds a MiniSearch index in-memory (tens of milliseconds at docs scale), and
|
|
6
|
+
* answers synchronous queries with page-grouped, section-anchored results. Field boosts and indexed
|
|
7
|
+
* fields come from the envelope — the emitter bakes the project's `search` section in, so the
|
|
8
|
+
* client needs no configuration.
|
|
9
|
+
*
|
|
10
|
+
* Two integration surfaces: - `preload` / `isReady` / `query` — the core API (used by lowered
|
|
11
|
+
* `Search` defs). - `searchInit` / `runSearch` — state-convention helpers for `$src` use inside
|
|
12
|
+
* compiled components (export name = state key; spec.md §5.3).
|
|
13
|
+
*
|
|
14
|
+
* @docs framework/site/search
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import MiniSearch from "minisearch";
|
|
18
|
+
import type { SearchDocument, SearchIndexEnvelope } from "./search-index.ts";
|
|
19
|
+
|
|
20
|
+
/** One section hit inside a result group. */
|
|
21
|
+
export interface SearchHit {
|
|
22
|
+
heading: string;
|
|
23
|
+
url: string;
|
|
24
|
+
score: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** One result group: a page and its matching sections, best-score first. */
|
|
28
|
+
export interface SearchResultGroup {
|
|
29
|
+
collection: string;
|
|
30
|
+
slug: string;
|
|
31
|
+
title: string;
|
|
32
|
+
description: string;
|
|
33
|
+
/** Page URL without a fragment. */
|
|
34
|
+
url: string;
|
|
35
|
+
/** Best score across the page and its section hits. */
|
|
36
|
+
score: number;
|
|
37
|
+
hits: SearchHit[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Query options. */
|
|
41
|
+
export interface QueryOptions {
|
|
42
|
+
/** Maximum result groups (default 8). */
|
|
43
|
+
limit?: number;
|
|
44
|
+
/** Group section hits under their page (default true); false returns flat documents. */
|
|
45
|
+
group?: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface ClientState {
|
|
49
|
+
mini: MiniSearch<SearchDocument> | null;
|
|
50
|
+
loading: Promise<void> | null;
|
|
51
|
+
indexUrl: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const state: ClientState = { indexUrl: "/search-index.json", loading: null, mini: null };
|
|
55
|
+
|
|
56
|
+
const STORE_FIELDS = ["collection", "slug", "url", "title", "description", "heading"];
|
|
57
|
+
|
|
58
|
+
/** Override the default index URL for subsequent `preload()` calls. */
|
|
59
|
+
export function configure(opts: { indexUrl?: string }): void {
|
|
60
|
+
if (opts.indexUrl) {
|
|
61
|
+
state.indexUrl = opts.indexUrl;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** True once the index is fetched and built. */
|
|
66
|
+
export function isReady(): boolean {
|
|
67
|
+
return state.mini !== null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Fetch the index envelope and build the MiniSearch index. Memoized: concurrent and repeat calls
|
|
72
|
+
* share one load. A failed load clears the memo so a later call can retry.
|
|
73
|
+
*/
|
|
74
|
+
export function preload(indexUrl?: string): Promise<void> {
|
|
75
|
+
if (indexUrl) {
|
|
76
|
+
state.indexUrl = indexUrl;
|
|
77
|
+
}
|
|
78
|
+
state.loading ??= (async () => {
|
|
79
|
+
const response = await fetch(state.indexUrl);
|
|
80
|
+
if (!response.ok) {
|
|
81
|
+
throw new Error(`search index fetch failed: ${response.status} ${state.indexUrl}`);
|
|
82
|
+
}
|
|
83
|
+
const envelope = (await response.json()) as SearchIndexEnvelope;
|
|
84
|
+
const mini = new MiniSearch<SearchDocument>({
|
|
85
|
+
fields: envelope.fields,
|
|
86
|
+
searchOptions: {
|
|
87
|
+
boost: envelope.boost,
|
|
88
|
+
fuzzy: 0.2,
|
|
89
|
+
prefix: true,
|
|
90
|
+
},
|
|
91
|
+
storeFields: STORE_FIELDS,
|
|
92
|
+
});
|
|
93
|
+
mini.addAll(envelope.documents);
|
|
94
|
+
state.mini = mini;
|
|
95
|
+
})().catch((error: unknown) => {
|
|
96
|
+
state.loading = null;
|
|
97
|
+
throw error;
|
|
98
|
+
});
|
|
99
|
+
return state.loading;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Raw MiniSearch hit with our stored fields. */
|
|
103
|
+
interface RawHit {
|
|
104
|
+
id: string;
|
|
105
|
+
score: number;
|
|
106
|
+
collection: string;
|
|
107
|
+
slug: string;
|
|
108
|
+
url: string;
|
|
109
|
+
title: string;
|
|
110
|
+
description: string;
|
|
111
|
+
heading: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Search synchronously. Returns `[]` until `preload` completes (and kicks it off if nobody has).
|
|
116
|
+
* With `group: true` (default), section hits nest under their page, groups sorted by best score;
|
|
117
|
+
* `group: false` returns the flat scored documents.
|
|
118
|
+
*/
|
|
119
|
+
export function query(q: string, opts: QueryOptions = {}): SearchResultGroup[] | RawHit[] {
|
|
120
|
+
const limit = opts.limit ?? 8;
|
|
121
|
+
const group = opts.group ?? true;
|
|
122
|
+
if (!state.mini) {
|
|
123
|
+
void preload().catch(() => {});
|
|
124
|
+
return [];
|
|
125
|
+
}
|
|
126
|
+
const text = q.trim();
|
|
127
|
+
if (!text) {
|
|
128
|
+
return [];
|
|
129
|
+
}
|
|
130
|
+
const raw = state.mini.search(text) as unknown as RawHit[];
|
|
131
|
+
if (!group) {
|
|
132
|
+
return raw.slice(0, limit);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const groups = new Map<string, SearchResultGroup>();
|
|
136
|
+
for (const hit of raw) {
|
|
137
|
+
const key = `${hit.collection}:${hit.slug}`;
|
|
138
|
+
let entry = groups.get(key);
|
|
139
|
+
if (!entry) {
|
|
140
|
+
entry = {
|
|
141
|
+
collection: hit.collection,
|
|
142
|
+
description: hit.description,
|
|
143
|
+
hits: [],
|
|
144
|
+
score: hit.score,
|
|
145
|
+
slug: hit.slug,
|
|
146
|
+
title: hit.title,
|
|
147
|
+
url: hit.url.replace(/#.*$/, ""),
|
|
148
|
+
};
|
|
149
|
+
groups.set(key, entry);
|
|
150
|
+
}
|
|
151
|
+
entry.score = Math.max(entry.score, hit.score);
|
|
152
|
+
if (hit.heading) {
|
|
153
|
+
entry.hits.push({ heading: hit.heading, score: hit.score, url: hit.url });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return [...groups.values()].toSorted((a, b) => b.score - a.score).slice(0, limit);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* State-convention init for `$src` component use: preload the index and flip `state.searchReady`;
|
|
161
|
+
* re-runs the pending query once ready.
|
|
162
|
+
*/
|
|
163
|
+
export function searchInit(componentState: Record<string, unknown>): boolean {
|
|
164
|
+
void preload(
|
|
165
|
+
typeof componentState.searchIndexUrl === "string" ? componentState.searchIndexUrl : undefined,
|
|
166
|
+
)
|
|
167
|
+
.then(() => {
|
|
168
|
+
componentState.searchReady = true;
|
|
169
|
+
if (typeof componentState.searchQuery === "string" && componentState.searchQuery) {
|
|
170
|
+
runSearch(componentState);
|
|
171
|
+
}
|
|
172
|
+
})
|
|
173
|
+
.catch((error: unknown) => console.warn("search client failed to load:", error));
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* State-convention query handler for `$src` component use: reads the query from the input event (or
|
|
179
|
+
* `state.searchQuery`), stores grouped results on `state.searchResults`, and resets the active-row
|
|
180
|
+
* index.
|
|
181
|
+
*/
|
|
182
|
+
export function runSearch(
|
|
183
|
+
componentState: Record<string, unknown>,
|
|
184
|
+
event?: { target?: { value?: unknown } | null },
|
|
185
|
+
): unknown[] {
|
|
186
|
+
const fromEvent =
|
|
187
|
+
event?.target && typeof event.target.value === "string" ? event.target.value : null;
|
|
188
|
+
const q =
|
|
189
|
+
fromEvent ?? (typeof componentState.searchQuery === "string" ? componentState.searchQuery : "");
|
|
190
|
+
componentState.searchQuery = q;
|
|
191
|
+
const results = query(q);
|
|
192
|
+
componentState.searchResults = results;
|
|
193
|
+
componentState.searchActive = 0;
|
|
194
|
+
return results;
|
|
195
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Search-index — the `search` project-section owner.
|
|
3
|
+
*
|
|
4
|
+
* `projectData` normalizes the section into `_project.search`; `emit` (extensions.md §8.4) builds
|
|
5
|
+
* the site's search index from the loaded content collections and returns it for the host to write
|
|
6
|
+
* into the build output. Documents come in two granularities: one per content entry (full text)
|
|
7
|
+
* and, when `sections` is on, one per heading section carrying a `#anchor` deep link (heading ids
|
|
8
|
+
* are assigned by the parser — parser.md §3.2).
|
|
9
|
+
*
|
|
10
|
+
* @docs extending/extensions/search
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { entryUrl, jxTreeToText, normalizeSearchConfig, splitSections } from "./shared.ts";
|
|
14
|
+
import type { NormalizedSearchConfig } from "./shared.ts";
|
|
15
|
+
import type { ContentLoaderEntry, JxElement } from "@jxsuite/schema/types";
|
|
16
|
+
|
|
17
|
+
/** One searchable document in the emitted index. */
|
|
18
|
+
export interface SearchDocument {
|
|
19
|
+
/**
|
|
20
|
+
* Unique id: `<collection>:<entry-id>` for pages, `<collection>:<entry-id>#<anchor>` for
|
|
21
|
+
* sections.
|
|
22
|
+
*/
|
|
23
|
+
id: string;
|
|
24
|
+
collection: string;
|
|
25
|
+
slug: string;
|
|
26
|
+
url: string;
|
|
27
|
+
title: string;
|
|
28
|
+
description: string;
|
|
29
|
+
/** Section heading text; empty for page-level documents. */
|
|
30
|
+
heading: string;
|
|
31
|
+
text: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The emitted index file: engine-tagged envelope around the document list. */
|
|
35
|
+
export interface SearchIndexEnvelope {
|
|
36
|
+
version: 1;
|
|
37
|
+
engine: "minisearch";
|
|
38
|
+
/** Fields the client indexes (union across collections). */
|
|
39
|
+
fields: string[];
|
|
40
|
+
/** Per-field score boosts (merged across collections). */
|
|
41
|
+
boost: Record<string, number>;
|
|
42
|
+
documents: SearchDocument[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Host context passed to `emit` (extensions.md §8.4). */
|
|
46
|
+
export interface EmitContext {
|
|
47
|
+
projectConfig?: { build?: { trailingSlash?: string } } & Record<string, unknown>;
|
|
48
|
+
root?: string;
|
|
49
|
+
sections?: Record<string, unknown>;
|
|
50
|
+
routes?: unknown[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The `SearchIndex` capability surface. The extension host dispatches capability methods on the
|
|
55
|
+
* export named by the descriptor title (specs/extensions.md §6.1); a plain object serves the
|
|
56
|
+
* static-dispatch contract exactly like a class with static methods.
|
|
57
|
+
*/
|
|
58
|
+
export const SearchIndex = {
|
|
59
|
+
/** Normalize the `search` section into `_project.search` (defaults applied). */
|
|
60
|
+
projectData(sectionValue: unknown): NormalizedSearchConfig {
|
|
61
|
+
return normalizeSearchConfig(sectionValue);
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
/** Build the search index from the loaded content collections. */
|
|
65
|
+
emit(sectionValue: unknown, ctx: EmitContext): { path: string; content: string }[] {
|
|
66
|
+
const config = normalizeSearchConfig(sectionValue);
|
|
67
|
+
const content = ctx.sections?.content as Map<string, ContentLoaderEntry[]> | undefined;
|
|
68
|
+
const trailingSlash = ctx.projectConfig?.build?.trailingSlash ?? "always";
|
|
69
|
+
|
|
70
|
+
const documents: SearchDocument[] = [];
|
|
71
|
+
const fields = new Set<string>();
|
|
72
|
+
const boost: Record<string, number> = {};
|
|
73
|
+
|
|
74
|
+
for (const [name, collection] of Object.entries(config.collections)) {
|
|
75
|
+
const entries = content?.get(name);
|
|
76
|
+
if (!entries) {
|
|
77
|
+
console.warn(
|
|
78
|
+
`@jxsuite/search: collection "${name}" is not a loaded content collection — skipped`,
|
|
79
|
+
);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
for (const field of collection.fields) {
|
|
83
|
+
fields.add(field);
|
|
84
|
+
}
|
|
85
|
+
Object.assign(boost, collection.boost);
|
|
86
|
+
|
|
87
|
+
for (const entry of entries) {
|
|
88
|
+
const data = (entry.data ?? {}) as Record<string, unknown>;
|
|
89
|
+
const url = entryUrl(collection.basePath, entry.id, trailingSlash);
|
|
90
|
+
const title = typeof data.title === "string" ? data.title : entry.id;
|
|
91
|
+
const description = typeof data.description === "string" ? data.description : "";
|
|
92
|
+
const children = entry.$children as (JxElement | string)[] | undefined;
|
|
93
|
+
|
|
94
|
+
documents.push({
|
|
95
|
+
id: `${name}:${entry.id}`,
|
|
96
|
+
collection: name,
|
|
97
|
+
slug: entry.id,
|
|
98
|
+
url,
|
|
99
|
+
title,
|
|
100
|
+
description,
|
|
101
|
+
heading: "",
|
|
102
|
+
text: jxTreeToText(children),
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
if (collection.sections) {
|
|
106
|
+
for (const section of splitSections(children, collection.sectionDepth)) {
|
|
107
|
+
documents.push({
|
|
108
|
+
id: `${name}:${entry.id}#${section.anchor}`,
|
|
109
|
+
collection: name,
|
|
110
|
+
slug: entry.id,
|
|
111
|
+
url: `${url}#${section.anchor}`,
|
|
112
|
+
title,
|
|
113
|
+
description,
|
|
114
|
+
heading: section.heading,
|
|
115
|
+
text: section.text,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const envelope: SearchIndexEnvelope = {
|
|
123
|
+
version: 1,
|
|
124
|
+
engine: config.engine,
|
|
125
|
+
fields: [...fields],
|
|
126
|
+
boost,
|
|
127
|
+
documents,
|
|
128
|
+
};
|
|
129
|
+
return [{ path: config.output, content: JSON.stringify(envelope) }];
|
|
130
|
+
},
|
|
131
|
+
};
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Search-state — the `Search` state class.
|
|
3
|
+
*
|
|
4
|
+
* An ordinary external class (constructor + resolve) with a static `lower` capability
|
|
5
|
+
* (extensions.md §8.3). In browsers, resolve() lazily imports the bundled headless client and
|
|
6
|
+
* queries the emitted index. Under node (compiler-timing bakes, dev-server resolve proxy) it
|
|
7
|
+
* degrades to an empty result — search is a client-side interaction.
|
|
8
|
+
*
|
|
9
|
+
* Lowering: in compiled sites a `timing: "client"` Search def becomes a core `Function` computed
|
|
10
|
+
* that lazy-imports the client bundle, preloads the index once per page, and re-runs reactively
|
|
11
|
+
* when the query state or the ready flag changes. The client module itself reaches the browser
|
|
12
|
+
* through the sidecar bundler via the `$bundle` hint (spec.md §5.3).
|
|
13
|
+
*
|
|
14
|
+
* @docs extending/extensions/search
|
|
15
|
+
* @docs framework/site/search
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { sidecarAssetPath } from "@jxsuite/schema/asset-paths";
|
|
19
|
+
|
|
20
|
+
/** The npm specifier of the headless browser client, bundled by the site build. */
|
|
21
|
+
export const CLIENT_SPECIFIER = "npm:@jxsuite/search/client";
|
|
22
|
+
|
|
23
|
+
/** A `Search` state def as authored in page state. */
|
|
24
|
+
export interface SearchDef {
|
|
25
|
+
$prototype?: string;
|
|
26
|
+
timing?: string;
|
|
27
|
+
/** Query text: a literal string or a `$ref` into page state. */
|
|
28
|
+
query?: string | { $ref: string };
|
|
29
|
+
/** Maximum result groups returned. */
|
|
30
|
+
limit?: number;
|
|
31
|
+
/** Group section hits under their page (default true). */
|
|
32
|
+
group?: boolean;
|
|
33
|
+
/** Override the index URL (defaults to the project's `search.output`). */
|
|
34
|
+
index?: string;
|
|
35
|
+
default?: unknown;
|
|
36
|
+
[key: string]: unknown;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The context object hosts pass to `lower(def, context)`. */
|
|
40
|
+
export interface LowerContext {
|
|
41
|
+
projectConfig?: { search?: { output?: string } } & Record<string, unknown>;
|
|
42
|
+
root?: string;
|
|
43
|
+
route?: Record<string, unknown>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** True when running in a browser; node hosts degrade to empty results. */
|
|
47
|
+
function inBrowser(): boolean {
|
|
48
|
+
return typeof document !== "undefined" && typeof fetch === "function";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Compile a def's `query` into a client JS expression (`$ref` → state path, literal → string). */
|
|
52
|
+
function queryExpression(query: SearchDef["query"]): string {
|
|
53
|
+
if (typeof query === "object" && query !== null && typeof query.$ref === "string") {
|
|
54
|
+
const path = query.$ref.replace(/^#\//, "").split("/");
|
|
55
|
+
if (path[0] === "state" && path.length > 1) {
|
|
56
|
+
return ["state", ...path.slice(1)].join(".");
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return JSON.stringify(typeof query === "string" ? query : "");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export class Search {
|
|
63
|
+
config: SearchDef & { _project?: { search?: { output?: string } } };
|
|
64
|
+
|
|
65
|
+
constructor(config: SearchDef) {
|
|
66
|
+
this.config = config;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Grouped search results for the configured query — browser only. */
|
|
70
|
+
async resolve(): Promise<unknown> {
|
|
71
|
+
if (!inBrowser()) {
|
|
72
|
+
console.warn("Search resolves to [] outside the browser — search is a client interaction");
|
|
73
|
+
return [];
|
|
74
|
+
}
|
|
75
|
+
const clientUrl = sidecarAssetPath(CLIENT_SPECIFIER);
|
|
76
|
+
const client = (await import(clientUrl)) as {
|
|
77
|
+
preload: (url?: string) => Promise<void>;
|
|
78
|
+
query: (q: string, opts?: { limit?: number; group?: boolean }) => unknown;
|
|
79
|
+
};
|
|
80
|
+
await client.preload(this.indexUrl());
|
|
81
|
+
const query = typeof this.config.query === "string" ? this.config.query : "";
|
|
82
|
+
return client.query(query, {
|
|
83
|
+
group: this.config.group ?? true,
|
|
84
|
+
limit: this.config.limit ?? 8,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The index URL: def override, then the project's `search.output`, then the default. */
|
|
89
|
+
indexUrl(): string {
|
|
90
|
+
return this.config.index ?? this.config._project?.search?.output ?? "/search-index.json";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Static `lower` capability: compile away into a core Function computed that lazily loads the
|
|
95
|
+
* bundled client + index, then queries reactively. `$bundle` registers the client module with the
|
|
96
|
+
* sidecar bundler (extensions.md §8.3).
|
|
97
|
+
*/
|
|
98
|
+
static lower(def: SearchDef, context: LowerContext = {}): Record<string, unknown> {
|
|
99
|
+
const indexUrl = def.index ?? context.projectConfig?.search?.output ?? "/search-index.json";
|
|
100
|
+
const clientUrl = sidecarAssetPath(CLIENT_SPECIFIER);
|
|
101
|
+
const opts = JSON.stringify({ group: def.group ?? true, limit: def.limit ?? 8 });
|
|
102
|
+
const body = [
|
|
103
|
+
// Reading the ready flag makes the computed re-run when the client finishes loading.
|
|
104
|
+
"void state.__jxSearchReady;",
|
|
105
|
+
"if (!globalThis.__jxSearch) {",
|
|
106
|
+
" globalThis.__jxSearch = {};",
|
|
107
|
+
` import(${JSON.stringify(clientUrl)})`,
|
|
108
|
+
` .then((m) => { globalThis.__jxSearch.m = m; return m.preload(${JSON.stringify(indexUrl)}); })`,
|
|
109
|
+
" .then(() => { state.__jxSearchReady = true; })",
|
|
110
|
+
" .catch((err) => console.warn('search client failed to load:', err));",
|
|
111
|
+
"}",
|
|
112
|
+
"const m = globalThis.__jxSearch.m;",
|
|
113
|
+
`return m && m.isReady() ? m.query(${queryExpression(def.query)}, ${opts}) : [];`,
|
|
114
|
+
].join("\n");
|
|
115
|
+
return {
|
|
116
|
+
$bundle: [CLIENT_SPECIFIER],
|
|
117
|
+
$prototype: "Function",
|
|
118
|
+
body,
|
|
119
|
+
timing: "client",
|
|
120
|
+
...(def.default === undefined ? {} : { default: def.default }),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
}
|
package/src/shared.ts
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared — search section normalization and content-tree text extraction.
|
|
3
|
+
*
|
|
4
|
+
* Pure helpers used by the build-time emitter (search-index.ts) and tests. Browser-safe.
|
|
5
|
+
*
|
|
6
|
+
* @docs extending/extensions/search
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { JxElement } from "@jxsuite/schema/types";
|
|
10
|
+
|
|
11
|
+
/** One collection's index configuration inside the `search` project section. */
|
|
12
|
+
export interface SearchCollectionConfig {
|
|
13
|
+
/** URL prefix mapping entry ids to routes, e.g. `"/docs/"`. */
|
|
14
|
+
basePath: string;
|
|
15
|
+
/** Document fields the index searches. */
|
|
16
|
+
fields?: string[];
|
|
17
|
+
/** Per-field score boosts. */
|
|
18
|
+
boost?: Record<string, number>;
|
|
19
|
+
/** Also index one document per heading section. */
|
|
20
|
+
sections?: boolean;
|
|
21
|
+
/** Deepest heading level that starts its own section document. */
|
|
22
|
+
sectionDepth?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The raw `search` project section (schemas/project.fragment.schema.json). */
|
|
26
|
+
export interface SearchSection {
|
|
27
|
+
engine?: "minisearch";
|
|
28
|
+
output?: string;
|
|
29
|
+
collections?: Record<string, SearchCollectionConfig>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A fully-defaulted collection config. */
|
|
33
|
+
export type NormalizedCollectionConfig = Required<SearchCollectionConfig>;
|
|
34
|
+
|
|
35
|
+
/** The normalized section, as stored in `_project.search` and consumed by `emit`. */
|
|
36
|
+
export interface NormalizedSearchConfig {
|
|
37
|
+
engine: "minisearch";
|
|
38
|
+
output: string;
|
|
39
|
+
collections: Record<string, NormalizedCollectionConfig>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const DEFAULT_OUTPUT = "/search-index.json";
|
|
43
|
+
export const DEFAULT_FIELDS = ["title", "heading", "text"];
|
|
44
|
+
export const DEFAULT_SECTION_DEPTH = 3;
|
|
45
|
+
|
|
46
|
+
/** Apply schema defaults to a raw `search` section value. */
|
|
47
|
+
export function normalizeSearchConfig(sectionValue?: unknown): NormalizedSearchConfig {
|
|
48
|
+
const section = (sectionValue ?? {}) as SearchSection;
|
|
49
|
+
const collections: Record<string, NormalizedCollectionConfig> = {};
|
|
50
|
+
for (const [name, raw] of Object.entries(section.collections ?? {})) {
|
|
51
|
+
collections[name] = {
|
|
52
|
+
basePath: normalizeBasePath(raw.basePath ?? "/"),
|
|
53
|
+
boost: raw.boost ?? {},
|
|
54
|
+
fields: raw.fields ?? DEFAULT_FIELDS,
|
|
55
|
+
sectionDepth: raw.sectionDepth ?? DEFAULT_SECTION_DEPTH,
|
|
56
|
+
sections: raw.sections ?? true,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
engine: section.engine ?? "minisearch",
|
|
61
|
+
output: section.output ?? DEFAULT_OUTPUT,
|
|
62
|
+
collections,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Ensure a basePath starts and ends with `/`. */
|
|
67
|
+
function normalizeBasePath(basePath: string): string {
|
|
68
|
+
let path = basePath.startsWith("/") ? basePath : `/${basePath}`;
|
|
69
|
+
if (!path.endsWith("/")) {
|
|
70
|
+
path = `${path}/`;
|
|
71
|
+
}
|
|
72
|
+
return path;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Route URL of a content entry: basePath + id, honoring the site's trailingSlash setting. */
|
|
76
|
+
export function entryUrl(basePath: string, id: string, trailingSlash: string): string {
|
|
77
|
+
const base = `${basePath}${id.replace(/^\//, "")}`;
|
|
78
|
+
return trailingSlash === "never" ? base : `${base}/`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Concatenated visible text of a Jx node tree, whitespace-collapsed. */
|
|
82
|
+
export function jxTreeToText(children?: (JxElement | string)[]): string {
|
|
83
|
+
return collectText(children).join(" ").replaceAll(/\s+/g, " ").trim();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function collectText(children: (JxElement | string)[] | undefined): string[] {
|
|
87
|
+
const parts: string[] = [];
|
|
88
|
+
for (const node of children ?? []) {
|
|
89
|
+
if (typeof node === "string") {
|
|
90
|
+
parts.push(node);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (typeof node !== "object" || node === null) {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (typeof node.textContent === "string") {
|
|
97
|
+
parts.push(node.textContent);
|
|
98
|
+
}
|
|
99
|
+
if (Array.isArray(node.children)) {
|
|
100
|
+
parts.push(...collectText(node.children as (JxElement | string)[]));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return parts;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** One heading-delimited slice of a content entry. */
|
|
107
|
+
export interface ContentSection {
|
|
108
|
+
heading: string;
|
|
109
|
+
anchor: string;
|
|
110
|
+
depth: number;
|
|
111
|
+
text: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const HEADING_TAG = /^h([1-6])$/;
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Split a content entry's rendered tree into heading sections for section-level search documents. A
|
|
118
|
+
* heading of depth ≤ `sectionDepth` (with an assigned `id` — see parser.md §3.2) starts a section;
|
|
119
|
+
* its text runs until the next section-starting heading. Content before the first heading belongs
|
|
120
|
+
* to no section (the page-level document already indexes the full text). Deeper headings' text
|
|
121
|
+
* stays inside the enclosing section.
|
|
122
|
+
*/
|
|
123
|
+
export function splitSections(
|
|
124
|
+
children: (JxElement | string)[] | undefined,
|
|
125
|
+
sectionDepth: number,
|
|
126
|
+
): ContentSection[] {
|
|
127
|
+
const sections: ContentSection[] = [];
|
|
128
|
+
let current: (ContentSection & { parts: string[] }) | null = null;
|
|
129
|
+
|
|
130
|
+
for (const node of children ?? []) {
|
|
131
|
+
const heading = headingOf(node);
|
|
132
|
+
if (heading && heading.depth <= sectionDepth && heading.anchor) {
|
|
133
|
+
if (current) {
|
|
134
|
+
sections.push(finishSection(current));
|
|
135
|
+
}
|
|
136
|
+
current = { ...heading, parts: [], text: "" };
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (current && typeof node === "string") {
|
|
140
|
+
current.parts.push(node);
|
|
141
|
+
} else if (current && typeof node === "object" && node !== null) {
|
|
142
|
+
current.parts.push(jxTreeToText([node]));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (current) {
|
|
146
|
+
sections.push(finishSection(current));
|
|
147
|
+
}
|
|
148
|
+
return sections;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function finishSection(section: ContentSection & { parts: string[] }): ContentSection {
|
|
152
|
+
const { heading, anchor, depth } = section;
|
|
153
|
+
return { anchor, depth, heading, text: section.parts.join(" ").replaceAll(/\s+/g, " ").trim() };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function headingOf(
|
|
157
|
+
node: JxElement | string,
|
|
158
|
+
): { heading: string; anchor: string; depth: number } | null {
|
|
159
|
+
if (typeof node !== "object" || node === null || !node.tagName) {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
const match = HEADING_TAG.exec(node.tagName);
|
|
163
|
+
if (!match) {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
return {
|
|
167
|
+
anchor: typeof node.id === "string" ? node.id : "",
|
|
168
|
+
depth: Number(match[1]),
|
|
169
|
+
heading: jxTreeToText([node]),
|
|
170
|
+
};
|
|
171
|
+
}
|