jekyll-client-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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +121 -0
- data/LICENSE +22 -0
- data/NOTICE +49 -0
- data/README.developer.md +204 -0
- data/README.md +948 -0
- data/assets/adapters/elasticlunr.js +59 -0
- data/assets/adapters/minisearch.js +57 -0
- data/assets/adapters/semantic.js +154 -0
- data/assets/client-search-base.js +294 -0
- data/assets/client-search-related.js +176 -0
- data/assets/includes/related-articles.html +36 -0
- data/assets/layouts/post-with-related.html +54 -0
- data/assets/query-embedders/ollama-api.js +63 -0
- data/assets/query-embedders/transformers-worker.js +130 -0
- data/assets/query-embedders/transformers.js +223 -0
- data/docs/assets/icon-256.png +0 -0
- data/docs/assets/icon.svg +133 -0
- data/lib/jekyll/client_search/configuration.rb +152 -0
- data/lib/jekyll/client_search/configuration_accessors.rb +48 -0
- data/lib/jekyll/client_search/document_builder.rb +66 -0
- data/lib/jekyll/client_search/embedder_config_page.rb +14 -0
- data/lib/jekyll/client_search/embedding_configuration.rb +95 -0
- data/lib/jekyll/client_search/generator.rb +134 -0
- data/lib/jekyll/client_search/index_cache.rb +82 -0
- data/lib/jekyll/client_search/live_search_configuration.rb +70 -0
- data/lib/jekyll/client_search/ollama_embedding_adapter.rb +59 -0
- data/lib/jekyll/client_search/query_embedder_configuration.rb +127 -0
- data/lib/jekyll/client_search/related_analyzer.rb +152 -0
- data/lib/jekyll/client_search/related_configuration.rb +103 -0
- data/lib/jekyll/client_search/related_page.rb +14 -0
- data/lib/jekyll/client_search/related_tag.rb +76 -0
- data/lib/jekyll/client_search/runtime_config_page.rb +30 -0
- data/lib/jekyll/client_search/search_index_page.rb +15 -0
- data/lib/jekyll/client_search/search_tag.rb +100 -0
- data/lib/jekyll/client_search/tasks.rb +137 -0
- data/lib/jekyll/client_search/version.rb +7 -0
- data/lib/jekyll/client_search.rb +25 -0
- data/lib/jekyll-client-search.rb +3 -0
- metadata +104 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
(function () {
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* ElasticLunr adapter — translates the uniform ClientSearch query model
|
|
6
|
+
* into ElasticLunr's native API.
|
|
7
|
+
*
|
|
8
|
+
* The base runtime owns the two-stage strategy (AND first, fuzzy OR
|
|
9
|
+
* fallback). This adapter only translates each stage's uniform options
|
|
10
|
+
* into ElasticLunr's search options and normalises results to
|
|
11
|
+
* { ref, score }. ElasticLunr already returns { ref, score } so the
|
|
12
|
+
* result translation is a pass-through.
|
|
13
|
+
*/
|
|
14
|
+
var FIELD_OPTIONS = {
|
|
15
|
+
title: { boost: 10 },
|
|
16
|
+
categoriesText: { boost: 5 },
|
|
17
|
+
tagsText: { boost: 4 },
|
|
18
|
+
excerpt: { boost: 2 },
|
|
19
|
+
content: { boost: 1 }
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
window.ClientSearchAdapters = window.ClientSearchAdapters || {};
|
|
23
|
+
window.ClientSearchAdapters.elasticlunr = {
|
|
24
|
+
name: "elasticlunr",
|
|
25
|
+
|
|
26
|
+
available: function () {
|
|
27
|
+
return typeof window.elasticlunr !== "undefined";
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
buildIndex: function (documents) {
|
|
31
|
+
var index = window.elasticlunr(function () {
|
|
32
|
+
this.setRef("id");
|
|
33
|
+
Object.keys(FIELD_OPTIONS).forEach(function (field) {
|
|
34
|
+
this.addField(field, FIELD_OPTIONS[field]);
|
|
35
|
+
}, this);
|
|
36
|
+
});
|
|
37
|
+
documents.forEach(function (document) {
|
|
38
|
+
index.addDoc(document);
|
|
39
|
+
});
|
|
40
|
+
return index;
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
search: function (index, query, options) {
|
|
44
|
+
var nativeOptions = {
|
|
45
|
+
fields: FIELD_OPTIONS,
|
|
46
|
+
bool: options.combineWith,
|
|
47
|
+
expand: options.prefix
|
|
48
|
+
};
|
|
49
|
+
if (options.fuzzy) {
|
|
50
|
+
nativeOptions.fuzzy = true;
|
|
51
|
+
}
|
|
52
|
+
return index.search(query, nativeOptions);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
if (window.ClientSearch) {
|
|
57
|
+
window.ClientSearch.run(window.ClientSearchAdapters.elasticlunr);
|
|
58
|
+
}
|
|
59
|
+
}());
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
(function () {
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* MiniSearch adapter — translates the uniform ClientSearch query model
|
|
6
|
+
* into MiniSearch's native API.
|
|
7
|
+
*
|
|
8
|
+
* The base runtime owns the two-stage strategy (AND first, fuzzy OR
|
|
9
|
+
* fallback). This adapter only translates each stage's uniform options
|
|
10
|
+
* into MiniSearch's options and normalises results to { ref, score }.
|
|
11
|
+
*/
|
|
12
|
+
var FIELDS = ["title", "excerpt", "content", "categoriesText", "tagsText"];
|
|
13
|
+
var BOOST = {
|
|
14
|
+
title: 10,
|
|
15
|
+
categoriesText: 5,
|
|
16
|
+
tagsText: 4,
|
|
17
|
+
excerpt: 2,
|
|
18
|
+
content: 1
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
window.ClientSearchAdapters = window.ClientSearchAdapters || {};
|
|
22
|
+
window.ClientSearchAdapters.minisearch = {
|
|
23
|
+
name: "minisearch",
|
|
24
|
+
|
|
25
|
+
available: function () {
|
|
26
|
+
return typeof window.MiniSearch !== "undefined";
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
buildIndex: function (documents) {
|
|
30
|
+
var miniSearch = new window.MiniSearch({
|
|
31
|
+
fields: FIELDS,
|
|
32
|
+
storeFields: ["title", "url", "excerpt", "categories", "tags"],
|
|
33
|
+
idField: "id"
|
|
34
|
+
});
|
|
35
|
+
miniSearch.addAll(documents);
|
|
36
|
+
return miniSearch;
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
search: function (index, query, options) {
|
|
40
|
+
var nativeOptions = {
|
|
41
|
+
combineWith: options.combineWith,
|
|
42
|
+
prefix: options.prefix,
|
|
43
|
+
boost: BOOST
|
|
44
|
+
};
|
|
45
|
+
if (options.fuzzy) {
|
|
46
|
+
nativeOptions.fuzzy = 0.2;
|
|
47
|
+
}
|
|
48
|
+
return index.search(query, nativeOptions).map(function (match) {
|
|
49
|
+
return { ref: match.id, score: match.score };
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
if (window.ClientSearch) {
|
|
55
|
+
window.ClientSearch.run(window.ClientSearchAdapters.minisearch);
|
|
56
|
+
}
|
|
57
|
+
}());
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
(function () {
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Semantic adapter — vector similarity search against pre-computed
|
|
6
|
+
* embeddings in the JSON index.
|
|
7
|
+
*
|
|
8
|
+
* This adapter expects each document to have an `embedding` field
|
|
9
|
+
* (a float vector) generated at Jekyll build time by the
|
|
10
|
+
* OllamaEmbeddingAdapter. At search time it calls
|
|
11
|
+
* ClientSearchQueryEmbedder, provided by a packaged query embedder or the
|
|
12
|
+
* consuming site, then ranks documents by cosine similarity.
|
|
13
|
+
*
|
|
14
|
+
* The base runtime's two-stage AND/OR strategy does not apply to
|
|
15
|
+
* vector search. This adapter implements its own ranking: it computes
|
|
16
|
+
* cosine similarity between the query embedding and every document
|
|
17
|
+
* embedding, returns those above a threshold, sorted by similarity.
|
|
18
|
+
*
|
|
19
|
+
* To use this adapter:
|
|
20
|
+
* 1. Configure embedding.enabled: true in _config.yml
|
|
21
|
+
* 2. Load the generated config and selected query-embedder script first
|
|
22
|
+
* 3. Use the same model and preprocessing at build and query time
|
|
23
|
+
*
|
|
24
|
+
* The embedder may return an array, typed array, or Promise of either.
|
|
25
|
+
*/
|
|
26
|
+
var SIMILARITY_THRESHOLD = 0.3;
|
|
27
|
+
var MAX_QUERY_CACHE_ENTRIES = 100;
|
|
28
|
+
var queryEmbeddingCache = new Map();
|
|
29
|
+
|
|
30
|
+
function cacheQueryEmbedding(query, value) {
|
|
31
|
+
queryEmbeddingCache.delete(query);
|
|
32
|
+
queryEmbeddingCache.set(query, value);
|
|
33
|
+
if (queryEmbeddingCache.size > MAX_QUERY_CACHE_ENTRIES) {
|
|
34
|
+
queryEmbeddingCache.delete(queryEmbeddingCache.keys().next().value);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function normalizeVector(value) {
|
|
39
|
+
var vector = null;
|
|
40
|
+
if (Array.isArray(value)) {
|
|
41
|
+
vector = value;
|
|
42
|
+
} else if (typeof ArrayBuffer !== "undefined" && ArrayBuffer.isView(value)) {
|
|
43
|
+
vector = Array.from(value);
|
|
44
|
+
}
|
|
45
|
+
if (!vector || vector.length === 0 || !vector.every(function (entry) {
|
|
46
|
+
return typeof entry === "number" && Number.isFinite(entry);
|
|
47
|
+
})) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
return vector;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function cosineSimilarity(a, b) {
|
|
54
|
+
if (!a || !b || a.length !== b.length) {
|
|
55
|
+
return 0;
|
|
56
|
+
}
|
|
57
|
+
var dot = 0;
|
|
58
|
+
var normA = 0;
|
|
59
|
+
var normB = 0;
|
|
60
|
+
for (var i = 0; i < a.length; i++) {
|
|
61
|
+
dot += a[i] * b[i];
|
|
62
|
+
normA += a[i] * a[i];
|
|
63
|
+
normB += b[i] * b[i];
|
|
64
|
+
}
|
|
65
|
+
var denom = Math.sqrt(normA) * Math.sqrt(normB);
|
|
66
|
+
return denom === 0 ? 0 : dot / denom;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
window.ClientSearchAdapters = window.ClientSearchAdapters || {};
|
|
70
|
+
window.ClientSearchAdapters.semantic = {
|
|
71
|
+
name: "semantic",
|
|
72
|
+
|
|
73
|
+
available: function () {
|
|
74
|
+
return typeof window.ClientSearchQueryEmbedder === "function";
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
buildIndex: function (documents) {
|
|
78
|
+
var expectedDimension = null;
|
|
79
|
+
return documents.filter(function (doc) {
|
|
80
|
+
var embedding = normalizeVector(doc.embedding);
|
|
81
|
+
if (!embedding) {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
if (expectedDimension === null) {
|
|
85
|
+
expectedDimension = embedding.length;
|
|
86
|
+
} else if (embedding.length !== expectedDimension) {
|
|
87
|
+
throw new RangeError("Document embedding dimensions do not match");
|
|
88
|
+
}
|
|
89
|
+
return true;
|
|
90
|
+
});
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
search: function (index, query, _options) {
|
|
94
|
+
if (typeof window.ClientSearchQueryEmbedder !== "function" || index.length === 0) {
|
|
95
|
+
return [];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
var cached = queryEmbeddingCache.get(query);
|
|
99
|
+
if (cached) {
|
|
100
|
+
cacheQueryEmbedding(query, cached);
|
|
101
|
+
return cached && typeof cached.then === "function"
|
|
102
|
+
? cached.then(function (embedding) { return rank(index, embedding); })
|
|
103
|
+
: rank(index, cached);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
var result = window.ClientSearchQueryEmbedder(query);
|
|
107
|
+
if (result && typeof result.then === "function") {
|
|
108
|
+
var pending = result.then(function (queryEmbedding) {
|
|
109
|
+
var normalized = normalizeVector(queryEmbedding);
|
|
110
|
+
if (!normalized) {
|
|
111
|
+
throw new TypeError("Query embedding must contain finite numeric values");
|
|
112
|
+
}
|
|
113
|
+
cacheQueryEmbedding(query, normalized);
|
|
114
|
+
return normalized;
|
|
115
|
+
}).catch(function (error) {
|
|
116
|
+
queryEmbeddingCache.delete(query);
|
|
117
|
+
throw error;
|
|
118
|
+
});
|
|
119
|
+
cacheQueryEmbedding(query, pending);
|
|
120
|
+
return pending.then(function (embedding) { return rank(index, embedding); });
|
|
121
|
+
}
|
|
122
|
+
var normalized = normalizeVector(result);
|
|
123
|
+
if (!normalized) {
|
|
124
|
+
throw new TypeError("Query embedding must contain finite numeric values");
|
|
125
|
+
}
|
|
126
|
+
cacheQueryEmbedding(query, normalized);
|
|
127
|
+
return rank(index, normalized);
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
function rank(index, queryEmbedding) {
|
|
132
|
+
var expectedDimension = index[0] && index[0].embedding.length;
|
|
133
|
+
if (expectedDimension && queryEmbedding.length !== expectedDimension) {
|
|
134
|
+
throw new RangeError(
|
|
135
|
+
"Query embedding dimension " + queryEmbedding.length +
|
|
136
|
+
" does not match document dimension " + expectedDimension
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
return index.map(function (doc) {
|
|
140
|
+
return {
|
|
141
|
+
ref: doc.id,
|
|
142
|
+
score: cosineSimilarity(queryEmbedding, doc.embedding)
|
|
143
|
+
};
|
|
144
|
+
}).filter(function (match) {
|
|
145
|
+
return match.score >= SIMILARITY_THRESHOLD;
|
|
146
|
+
}).sort(function (a, b) {
|
|
147
|
+
return b.score - a.score;
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (window.ClientSearch) {
|
|
152
|
+
window.ClientSearch.run(window.ClientSearchAdapters.semantic);
|
|
153
|
+
}
|
|
154
|
+
}());
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
(function () {
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* ClientSearch base runtime — engine-agnostic shell.
|
|
6
|
+
*
|
|
7
|
+
* Owns the uniform query model and the two-stage search strategy:
|
|
8
|
+
* 1. Exact AND search with prefix matching.
|
|
9
|
+
* 2. Fuzzy OR fallback when AND yields no results.
|
|
10
|
+
*
|
|
11
|
+
* The search engine itself is supplied by an adapter that translates
|
|
12
|
+
* the uniform query into the engine's native format and translates
|
|
13
|
+
* results back. The adapter interface is:
|
|
14
|
+
*
|
|
15
|
+
* adapter.name — string identifier
|
|
16
|
+
* adapter.available() — returns true when the engine library is loaded
|
|
17
|
+
* adapter.buildIndex(documents) — returns an engine-specific index
|
|
18
|
+
* adapter.search(index, query, options) — translates the uniform
|
|
19
|
+
* query { combineWith: "AND"|"OR", fuzzy: bool, prefix: bool }
|
|
20
|
+
* into the engine's native call and returns [{ ref, score }] or a
|
|
21
|
+
* Promise of that array in descending score order
|
|
22
|
+
*
|
|
23
|
+
* This keeps the invocation contract and rendering uniform across adapters.
|
|
24
|
+
*/
|
|
25
|
+
window.ClientSearch = {
|
|
26
|
+
run: function (adapter) {
|
|
27
|
+
var options = Object.assign({
|
|
28
|
+
form: "#search-form",
|
|
29
|
+
input: "#search-query",
|
|
30
|
+
status: "#search-status",
|
|
31
|
+
results: "#search-results",
|
|
32
|
+
sortControl: "#search-sort",
|
|
33
|
+
sort: "relevance",
|
|
34
|
+
indexUrl: window.searchIndexUrl
|
|
35
|
+
}, window.clientSearchConfig || {});
|
|
36
|
+
options.liveSearch = Object.assign({
|
|
37
|
+
enabled: false,
|
|
38
|
+
minChars: 2,
|
|
39
|
+
debounceMs: 150,
|
|
40
|
+
updateUrl: true
|
|
41
|
+
}, options.liveSearch || {});
|
|
42
|
+
|
|
43
|
+
var form = document.querySelector(options.form);
|
|
44
|
+
var input = document.querySelector(options.input);
|
|
45
|
+
var status = document.querySelector(options.status);
|
|
46
|
+
var results = document.querySelector(options.results);
|
|
47
|
+
var index = null;
|
|
48
|
+
var documentsById = new Map();
|
|
49
|
+
var liveSearchTimer = null;
|
|
50
|
+
var renderVersion = 0;
|
|
51
|
+
var activeStatusVersion = 0;
|
|
52
|
+
|
|
53
|
+
if (!form || !input || !status || !results || !options.indexUrl) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (!adapter.available()) {
|
|
57
|
+
status.textContent = "Search is temporarily unavailable.";
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function normalize(entry) {
|
|
62
|
+
var id = entry.id || entry.url;
|
|
63
|
+
if (!id) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
var categories = Array.isArray(entry.categories) ? entry.categories : [];
|
|
67
|
+
var tags = Array.isArray(entry.tags) ? entry.tags : [];
|
|
68
|
+
var normalized = {
|
|
69
|
+
id: id,
|
|
70
|
+
title: entry.title || "Untitled",
|
|
71
|
+
url: entry.url || "#",
|
|
72
|
+
excerpt: entry.excerpt || "",
|
|
73
|
+
content: entry.content || "",
|
|
74
|
+
date: entry.date || "",
|
|
75
|
+
date_timestamp: Number(entry.date_timestamp) || 0,
|
|
76
|
+
categories: categories,
|
|
77
|
+
tags: tags,
|
|
78
|
+
categoriesText: categories.join(" "),
|
|
79
|
+
tagsText: tags.join(" ")
|
|
80
|
+
};
|
|
81
|
+
if (Array.isArray(entry.embedding) && entry.embedding.length > 0) {
|
|
82
|
+
normalized.embedding = entry.embedding;
|
|
83
|
+
}
|
|
84
|
+
return normalized;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function buildIndex(data) {
|
|
88
|
+
if (!Array.isArray(data)) {
|
|
89
|
+
throw new TypeError("Search index must be an array");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
var documents = data.map(normalize).filter(Boolean);
|
|
93
|
+
documentsById = new Map(documents.map(function (entry) {
|
|
94
|
+
return [entry.id, entry];
|
|
95
|
+
}));
|
|
96
|
+
index = adapter.buildIndex(documents);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function search(query) {
|
|
100
|
+
if (!index) {
|
|
101
|
+
return [];
|
|
102
|
+
}
|
|
103
|
+
var exact = await adapter.search(index, query, {
|
|
104
|
+
combineWith: "AND",
|
|
105
|
+
fuzzy: false,
|
|
106
|
+
prefix: true
|
|
107
|
+
});
|
|
108
|
+
if (exact.length > 0) {
|
|
109
|
+
return exact;
|
|
110
|
+
}
|
|
111
|
+
return await adapter.search(index, query, {
|
|
112
|
+
combineWith: "OR",
|
|
113
|
+
fuzzy: true,
|
|
114
|
+
prefix: true
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function safeUrl(value) {
|
|
119
|
+
if (value === "#") {
|
|
120
|
+
return "#";
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
var url = new URL(value, window.location.origin);
|
|
124
|
+
if (url.origin === window.location.origin && ["http:", "https:"].includes(url.protocol)) {
|
|
125
|
+
return url.pathname + url.search + url.hash;
|
|
126
|
+
}
|
|
127
|
+
} catch (_error) {
|
|
128
|
+
return "#";
|
|
129
|
+
}
|
|
130
|
+
return "#";
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function resultElement(match) {
|
|
134
|
+
var entry = documentsById.get(match.ref);
|
|
135
|
+
if (!entry) {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
var article = document.createElement("article");
|
|
139
|
+
var heading = document.createElement("h2");
|
|
140
|
+
var titleLink = document.createElement("a");
|
|
141
|
+
var excerpt = document.createElement("p");
|
|
142
|
+
var readMore = document.createElement("a");
|
|
143
|
+
var url = safeUrl(entry.url);
|
|
144
|
+
|
|
145
|
+
article.className = "box client-search-result";
|
|
146
|
+
heading.className = "title is-4";
|
|
147
|
+
titleLink.href = url;
|
|
148
|
+
titleLink.textContent = entry.title;
|
|
149
|
+
excerpt.textContent = entry.excerpt;
|
|
150
|
+
readMore.href = url;
|
|
151
|
+
readMore.textContent = "Read more";
|
|
152
|
+
heading.appendChild(titleLink);
|
|
153
|
+
article.append(heading, excerpt, readMore);
|
|
154
|
+
return article;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function sortMatches(matches) {
|
|
158
|
+
var sortControl = document.querySelector(options.sortControl);
|
|
159
|
+
var sortOrder = sortControl ? sortControl.value : options.sort;
|
|
160
|
+
if (sortOrder !== "date") {
|
|
161
|
+
return matches;
|
|
162
|
+
}
|
|
163
|
+
return matches.slice().sort(function (a, b) {
|
|
164
|
+
var first = documentsById.get(a.ref);
|
|
165
|
+
var second = documentsById.get(b.ref);
|
|
166
|
+
return (second ? second.date_timestamp : 0) - (first ? first.date_timestamp : 0) ||
|
|
167
|
+
(b.score || 0) - (a.score || 0);
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function updateUrl() {
|
|
172
|
+
var url = new URL(window.location.href);
|
|
173
|
+
if (input.value.trim()) {
|
|
174
|
+
url.searchParams.set("q", input.value.trim());
|
|
175
|
+
} else {
|
|
176
|
+
url.searchParams.delete("q");
|
|
177
|
+
}
|
|
178
|
+
window.history.replaceState({}, "", url);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function showSearchError() {
|
|
182
|
+
status.textContent = "Search is temporarily unavailable.";
|
|
183
|
+
results.replaceChildren();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function render() {
|
|
187
|
+
var query = input.value.trim();
|
|
188
|
+
var version = ++renderVersion;
|
|
189
|
+
activeStatusVersion = version;
|
|
190
|
+
if (!query) {
|
|
191
|
+
activeStatusVersion = 0;
|
|
192
|
+
status.textContent = "Search articles by title, text, category, or tag.";
|
|
193
|
+
results.replaceChildren();
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
status.textContent = "Searching…";
|
|
198
|
+
var matches;
|
|
199
|
+
try {
|
|
200
|
+
matches = await search(query);
|
|
201
|
+
} catch (error) {
|
|
202
|
+
if (version !== renderVersion) {
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
activeStatusVersion = 0;
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
208
|
+
if (version !== renderVersion) {
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
activeStatusVersion = 0;
|
|
212
|
+
matches = sortMatches(matches);
|
|
213
|
+
status.textContent = matches.length + " result" + (matches.length === 1 ? "" : "s");
|
|
214
|
+
if (matches.length === 0) {
|
|
215
|
+
var empty = document.createElement("div");
|
|
216
|
+
empty.className = "notification is-info";
|
|
217
|
+
empty.textContent = "No articles found.";
|
|
218
|
+
results.replaceChildren(empty);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
results.replaceChildren.apply(
|
|
222
|
+
results,
|
|
223
|
+
matches.map(resultElement).filter(Boolean)
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function runRender() {
|
|
228
|
+
render().catch(showSearchError);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
window.addEventListener("client-search:status", function (event) {
|
|
232
|
+
if (activeStatusVersion === renderVersion && event.detail && event.detail.message) {
|
|
233
|
+
status.textContent = event.detail.message;
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
input.addEventListener("input", function () {
|
|
238
|
+
renderVersion += 1;
|
|
239
|
+
activeStatusVersion = 0;
|
|
240
|
+
clearTimeout(liveSearchTimer);
|
|
241
|
+
if (!options.liveSearch.enabled || !index) {
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
var query = input.value.trim();
|
|
246
|
+
if (options.liveSearch.updateUrl) {
|
|
247
|
+
updateUrl();
|
|
248
|
+
}
|
|
249
|
+
if (!query) {
|
|
250
|
+
runRender();
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
if (query.length < options.liveSearch.minChars) {
|
|
254
|
+
status.textContent = "Type at least " + options.liveSearch.minChars + " characters.";
|
|
255
|
+
results.replaceChildren();
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
status.textContent = "Waiting to search…";
|
|
260
|
+
liveSearchTimer = setTimeout(runRender, options.liveSearch.debounceMs);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
form.addEventListener("submit", function (event) {
|
|
264
|
+
event.preventDefault();
|
|
265
|
+
clearTimeout(liveSearchTimer);
|
|
266
|
+
updateUrl();
|
|
267
|
+
runRender();
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
var sortControl = document.querySelector(options.sortControl);
|
|
271
|
+
if (sortControl) {
|
|
272
|
+
sortControl.addEventListener("change", runRender);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
status.textContent = "Loading search index…";
|
|
276
|
+
fetch(options.indexUrl, { headers: { Accept: "application/json" } })
|
|
277
|
+
.then(function (response) {
|
|
278
|
+
if (!response.ok) {
|
|
279
|
+
throw new Error("Unable to load search index");
|
|
280
|
+
}
|
|
281
|
+
return response.json();
|
|
282
|
+
})
|
|
283
|
+
.then(function (data) {
|
|
284
|
+
buildIndex(data);
|
|
285
|
+
input.value = new URLSearchParams(window.location.search).get("q") || "";
|
|
286
|
+
return render();
|
|
287
|
+
})
|
|
288
|
+
.catch(function () {
|
|
289
|
+
status.textContent = "Search is temporarily unavailable.";
|
|
290
|
+
results.replaceChildren();
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
}());
|