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.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +121 -0
  3. data/LICENSE +22 -0
  4. data/NOTICE +49 -0
  5. data/README.developer.md +204 -0
  6. data/README.md +948 -0
  7. data/assets/adapters/elasticlunr.js +59 -0
  8. data/assets/adapters/minisearch.js +57 -0
  9. data/assets/adapters/semantic.js +154 -0
  10. data/assets/client-search-base.js +294 -0
  11. data/assets/client-search-related.js +176 -0
  12. data/assets/includes/related-articles.html +36 -0
  13. data/assets/layouts/post-with-related.html +54 -0
  14. data/assets/query-embedders/ollama-api.js +63 -0
  15. data/assets/query-embedders/transformers-worker.js +130 -0
  16. data/assets/query-embedders/transformers.js +223 -0
  17. data/docs/assets/icon-256.png +0 -0
  18. data/docs/assets/icon.svg +133 -0
  19. data/lib/jekyll/client_search/configuration.rb +152 -0
  20. data/lib/jekyll/client_search/configuration_accessors.rb +48 -0
  21. data/lib/jekyll/client_search/document_builder.rb +66 -0
  22. data/lib/jekyll/client_search/embedder_config_page.rb +14 -0
  23. data/lib/jekyll/client_search/embedding_configuration.rb +95 -0
  24. data/lib/jekyll/client_search/generator.rb +134 -0
  25. data/lib/jekyll/client_search/index_cache.rb +82 -0
  26. data/lib/jekyll/client_search/live_search_configuration.rb +70 -0
  27. data/lib/jekyll/client_search/ollama_embedding_adapter.rb +59 -0
  28. data/lib/jekyll/client_search/query_embedder_configuration.rb +127 -0
  29. data/lib/jekyll/client_search/related_analyzer.rb +152 -0
  30. data/lib/jekyll/client_search/related_configuration.rb +103 -0
  31. data/lib/jekyll/client_search/related_page.rb +14 -0
  32. data/lib/jekyll/client_search/related_tag.rb +76 -0
  33. data/lib/jekyll/client_search/runtime_config_page.rb +30 -0
  34. data/lib/jekyll/client_search/search_index_page.rb +15 -0
  35. data/lib/jekyll/client_search/search_tag.rb +100 -0
  36. data/lib/jekyll/client_search/tasks.rb +137 -0
  37. data/lib/jekyll/client_search/version.rb +7 -0
  38. data/lib/jekyll/client_search.rb +25 -0
  39. data/lib/jekyll-client-search.rb +3 -0
  40. 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
+ }());