jekyll-client-search 0.1.0 → 0.3.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.
@@ -58,6 +58,12 @@
58
58
  return;
59
59
  }
60
60
 
61
+ var coreFields = [
62
+ "id", "title", "url", "excerpt", "content",
63
+ "categories", "tags", "categoriesText", "tagsText",
64
+ "date", "date_timestamp", "embedding"
65
+ ];
66
+
61
67
  function normalize(entry) {
62
68
  var id = entry.id || entry.url;
63
69
  if (!id) {
@@ -78,9 +84,20 @@
78
84
  categoriesText: categories.join(" "),
79
85
  tagsText: tags.join(" ")
80
86
  };
87
+ if (entry.source) {
88
+ normalized.source = entry.source;
89
+ }
81
90
  if (Array.isArray(entry.embedding) && entry.embedding.length > 0) {
82
91
  normalized.embedding = entry.embedding;
83
92
  }
93
+ Object.keys(entry).forEach(function (key) {
94
+ if (coreFields.indexOf(key) === -1 && key !== "source" && !(key in normalized)) {
95
+ var value = entry[key];
96
+ if (value !== null && value !== undefined && value !== "") {
97
+ normalized[key] = value;
98
+ }
99
+ }
100
+ });
84
101
  return normalized;
85
102
  }
86
103
 
@@ -143,17 +160,52 @@
143
160
  var url = safeUrl(entry.url);
144
161
 
145
162
  article.className = "box client-search-result";
163
+ if (entry.source) {
164
+ article.dataset.source = entry.source;
165
+ }
166
+ if (entry.categories.length) {
167
+ article.dataset.categories = entry.categories.join(" ");
168
+ }
169
+ if (entry.tags.length) {
170
+ article.dataset.tags = entry.tags.join(" ");
171
+ }
172
+ Object.keys(entry).forEach(function (key) {
173
+ if (coreFields.indexOf(key) !== -1 || key === "source") {
174
+ return;
175
+ }
176
+ var value = entry[key];
177
+ if (typeof value === "string" || typeof value === "number") {
178
+ article.dataset[toCamelCase(key)] = String(value);
179
+ }
180
+ });
146
181
  heading.className = "title is-4";
147
182
  titleLink.href = url;
148
183
  titleLink.textContent = entry.title;
184
+ heading.appendChild(titleLink);
185
+ var iconField = options.iconField;
186
+ if (iconField && entry[iconField]) {
187
+ var icon = document.createElement("img");
188
+ icon.className = "client-search-result-icon";
189
+ icon.src = safeUrl(entry[iconField]);
190
+ icon.alt = entry.file_type || entry.source || "";
191
+ icon.loading = "lazy";
192
+ icon.style.width = "1em";
193
+ icon.style.height = "1em";
194
+ icon.style.verticalAlign = "middle";
195
+ icon.style.marginRight = "0.3em";
196
+ heading.insertBefore(icon, titleLink);
197
+ }
149
198
  excerpt.textContent = entry.excerpt;
150
199
  readMore.href = url;
151
200
  readMore.textContent = "Read more";
152
- heading.appendChild(titleLink);
153
201
  article.append(heading, excerpt, readMore);
154
202
  return article;
155
203
  }
156
204
 
205
+ function toCamelCase(key) {
206
+ return key.replace(/_([a-z])/g, function (_, char) { return char.toUpperCase(); });
207
+ }
208
+
157
209
  function sortMatches(matches) {
158
210
  var sortControl = document.querySelector(options.sortControl);
159
211
  var sortOrder = sortControl ? sortControl.value : options.sort;
@@ -0,0 +1,434 @@
1
+ (function () {
2
+ "use strict";
3
+
4
+ /**
5
+ * ClientSearch dropdown runtime — compact live-search dropdown for
6
+ * navbars and headers. Framework-agnostic: emits semantic HTML with
7
+ * data attributes, no CSS classes from any framework.
8
+ *
9
+ * Features:
10
+ * - Lazy index loading (fetches search-index.json on first keystroke)
11
+ * - Two-stage search (AND first, fuzzy OR fallback) via engine adapter
12
+ * - Compact <li><a> items with optional icon rendering
13
+ * - Keyboard navigation (Arrow Up/Down, Enter, Escape)
14
+ * - Enter with no selection → redirect to redirect_url (?q=...)
15
+ * - Click outside or Escape → close dropdown
16
+ * - Multi-instance via [data-client-search-dropdown] attributes
17
+ * - Shared index cache with the base runtime when present
18
+ */
19
+
20
+ var sharedIndex = null;
21
+ var sharedDocuments = null;
22
+ var indexLoadPromise = null;
23
+
24
+ function safeUrl(value) {
25
+ if (value === "#") {
26
+ return "#";
27
+ }
28
+ try {
29
+ var url = new URL(value, window.location.origin);
30
+ if (url.origin === window.location.origin &&
31
+ ["http:", "https:"].includes(url.protocol)) {
32
+ return url.pathname + url.search + url.hash;
33
+ }
34
+ } catch (_error) {
35
+ return "#";
36
+ }
37
+ return "#";
38
+ }
39
+
40
+ function toCamelCase(key) {
41
+ return key.replace(/_([a-z])/g, function (_, char) { return char.toUpperCase(); });
42
+ }
43
+
44
+ function normalize(entry) {
45
+ var id = entry.id || entry.url;
46
+ if (!id) {
47
+ return null;
48
+ }
49
+ var categories = Array.isArray(entry.categories) ? entry.categories : [];
50
+ var tags = Array.isArray(entry.tags) ? entry.tags : [];
51
+ var normalized = {
52
+ id: id,
53
+ title: entry.title || "Untitled",
54
+ url: entry.url || "#",
55
+ excerpt: entry.excerpt || "",
56
+ content: entry.content || "",
57
+ date: entry.date || "",
58
+ date_timestamp: Number(entry.date_timestamp) || 0,
59
+ categories: categories,
60
+ tags: tags,
61
+ categoriesText: categories.join(" "),
62
+ tagsText: tags.join(" ")
63
+ };
64
+ if (entry.source) {
65
+ normalized.source = entry.source;
66
+ }
67
+ Object.keys(entry).forEach(function (key) {
68
+ if (!(key in normalized) && key !== "embedding") {
69
+ var value = entry[key];
70
+ if (value !== null && value !== undefined && value !== "") {
71
+ normalized[key] = value;
72
+ }
73
+ }
74
+ });
75
+ return normalized;
76
+ }
77
+
78
+ function loadIndex(config) {
79
+ if (sharedIndex && sharedDocuments) {
80
+ return Promise.resolve({ index: sharedIndex, documents: sharedDocuments });
81
+ }
82
+ if (indexLoadPromise) {
83
+ return indexLoadPromise;
84
+ }
85
+
86
+ var indexUrl = config.indexUrl;
87
+ if (!indexUrl) {
88
+ return Promise.reject(new Error("No index URL configured"));
89
+ }
90
+
91
+ indexLoadPromise = fetch(indexUrl, { headers: { Accept: "application/json" } })
92
+ .then(function (response) {
93
+ if (!response.ok) {
94
+ throw new Error("Unable to load search index");
95
+ }
96
+ return response.json();
97
+ })
98
+ .then(function (data) {
99
+ if (!Array.isArray(data)) {
100
+ throw new TypeError("Search index must be an array");
101
+ }
102
+ var documents = data.map(normalize).filter(Boolean);
103
+ var adapter = window.ClientSearchAdapters &&
104
+ (window.ClientSearchAdapters[config.engine] ||
105
+ window.ClientSearchAdapters.minisearch);
106
+ if (!adapter) {
107
+ throw new Error("Search adapter not loaded");
108
+ }
109
+ sharedDocuments = new Map(documents.map(function (entry) {
110
+ return [entry.id, entry];
111
+ }));
112
+ sharedIndex = adapter.buildIndex(documents);
113
+ return { index: sharedIndex, documents: sharedDocuments };
114
+ })
115
+ .catch(function (error) {
116
+ indexLoadPromise = null;
117
+ throw error;
118
+ });
119
+
120
+ return indexLoadPromise;
121
+ }
122
+
123
+ function search(index, query, adapter) {
124
+ var exact = adapter.search(index, query, {
125
+ combineWith: "AND",
126
+ fuzzy: false,
127
+ prefix: true
128
+ });
129
+ if (exact && typeof exact.then === "function") {
130
+ return exact.then(function (results) {
131
+ if (results && results.length > 0) {
132
+ return results;
133
+ }
134
+ return adapter.search(index, query, {
135
+ combineWith: "OR",
136
+ fuzzy: true,
137
+ prefix: true
138
+ });
139
+ });
140
+ }
141
+ if (exact && exact.length > 0) {
142
+ return exact;
143
+ }
144
+ return adapter.search(index, query, {
145
+ combineWith: "OR",
146
+ fuzzy: true,
147
+ prefix: true
148
+ });
149
+ }
150
+
151
+ function createResultItem(match, documents, iconField) {
152
+ var entry = documents.get(match.ref);
153
+ if (!entry) {
154
+ return null;
155
+ }
156
+
157
+ var li = document.createElement("li");
158
+ li.setAttribute("role", "option");
159
+ li.className = "client-search-dropdown-item";
160
+ li.dataset.url = safeUrl(entry.url);
161
+
162
+ if (entry.source) {
163
+ li.dataset.source = entry.source;
164
+ }
165
+ if (entry.categories && entry.categories.length) {
166
+ li.dataset.categories = entry.categories.join(" ");
167
+ }
168
+ if (entry.tags && entry.tags.length) {
169
+ li.dataset.tags = entry.tags.join(" ");
170
+ }
171
+ Object.keys(entry).forEach(function (key) {
172
+ var skip = ["id", "title", "url", "excerpt", "content",
173
+ "categories", "tags", "source", "embedding",
174
+ "date_timestamp"];
175
+ if (skip.indexOf(key) !== -1) {
176
+ return;
177
+ }
178
+ var value = entry[key];
179
+ if (typeof value === "string" || typeof value === "number") {
180
+ li.dataset[toCamelCase(key)] = String(value);
181
+ }
182
+ });
183
+
184
+ if (iconField && entry[iconField]) {
185
+ var icon = document.createElement("img");
186
+ icon.className = "client-search-dropdown-icon";
187
+ icon.src = safeUrl(entry[iconField]);
188
+ icon.alt = entry.file_type || entry.source || "";
189
+ icon.loading = "lazy";
190
+ icon.style.width = "1em";
191
+ icon.style.height = "1em";
192
+ icon.style.verticalAlign = "middle";
193
+ icon.style.marginRight = "0.3em";
194
+ li.appendChild(icon);
195
+ }
196
+
197
+ var link = document.createElement("a");
198
+ link.href = safeUrl(entry.url);
199
+ link.textContent = entry.title;
200
+ link.className = "client-search-dropdown-link";
201
+ li.appendChild(link);
202
+
203
+ return li;
204
+ }
205
+
206
+ function DropdownInstance(root, config) {
207
+ this.root = root;
208
+ this.form = root.querySelector("[data-cs-dropdown-form]");
209
+ this.input = root.querySelector("[data-cs-dropdown-input]");
210
+ this.results = root.querySelector("[data-cs-dropdown-results]");
211
+ this.config = config;
212
+ this.maxItems = parseInt(this.results.dataset.maxItems, 10) || config.maxItems || 5;
213
+ this.selectedIndex = -1;
214
+ this.currentItems = [];
215
+ this.debounceTimer = null;
216
+ this.renderVersion = 0;
217
+ this.adapter = null;
218
+ }
219
+
220
+ DropdownInstance.prototype.init = function () {
221
+ if (!this.form || !this.input || !this.results) {
222
+ return;
223
+ }
224
+
225
+ var self = this;
226
+
227
+ this.input.addEventListener("input", function () {
228
+ self.onInput();
229
+ });
230
+
231
+ this.input.addEventListener("keydown", function (event) {
232
+ self.onKeydown(event);
233
+ });
234
+
235
+ this.form.addEventListener("submit", function (event) {
236
+ event.preventDefault();
237
+ self.onSubmit();
238
+ });
239
+
240
+ document.addEventListener("click", function (event) {
241
+ if (!self.root.contains(event.target)) {
242
+ self.hide();
243
+ }
244
+ });
245
+
246
+ this.results.addEventListener("click", function (event) {
247
+ var li = event.target.closest("li");
248
+ if (li && li.dataset.url && li.dataset.url !== "#") {
249
+ window.location.href = li.dataset.url;
250
+ }
251
+ });
252
+ };
253
+
254
+ DropdownInstance.prototype.onInput = function () {
255
+ var self = this;
256
+ this.renderVersion += 1;
257
+ clearTimeout(this.debounceTimer);
258
+
259
+ var query = this.input.value.trim();
260
+ if (!query || query.length < this.config.minChars) {
261
+ this.hide();
262
+ return;
263
+ }
264
+
265
+ this.debounceTimer = setTimeout(function () {
266
+ self.performSearch(query);
267
+ }, this.config.debounceMs);
268
+ };
269
+
270
+ DropdownInstance.prototype.onKeydown = function (event) {
271
+ if (this.results.getAttribute("aria-hidden") === "true") {
272
+ return;
273
+ }
274
+ var visible = this.results.children.length > 0;
275
+ if (!visible) {
276
+ return;
277
+ }
278
+
279
+ switch (event.key) {
280
+ case "ArrowDown":
281
+ event.preventDefault();
282
+ this.selectItem(Math.min(this.selectedIndex + 1, this.currentItems.length - 1));
283
+ break;
284
+ case "ArrowUp":
285
+ event.preventDefault();
286
+ this.selectItem(Math.max(this.selectedIndex - 1, 0));
287
+ break;
288
+ case "Enter":
289
+ if (this.selectedIndex >= 0 && this.currentItems[this.selectedIndex]) {
290
+ event.preventDefault();
291
+ var url = this.currentItems[this.selectedIndex].dataset.url;
292
+ if (url && url !== "#") {
293
+ window.location.href = url;
294
+ }
295
+ }
296
+ break;
297
+ case "Escape":
298
+ this.hide();
299
+ this.input.blur();
300
+ break;
301
+ case "Tab":
302
+ this.hide();
303
+ break;
304
+ }
305
+ };
306
+
307
+ DropdownInstance.prototype.onSubmit = function () {
308
+ var query = this.input.value.trim();
309
+ if (!query) {
310
+ return;
311
+ }
312
+ var redirectUrl = this.config.redirectUrl || "/search/";
313
+ var separator = redirectUrl.indexOf("?") !== -1 ? "&" : "?";
314
+ window.location.href = redirectUrl + separator + "q=" + encodeURIComponent(query);
315
+ };
316
+
317
+ DropdownInstance.prototype.performSearch = function (query) {
318
+ var self = this;
319
+ var version = this.renderVersion;
320
+
321
+ var config = this.config;
322
+ var engineName = config.engine || "minisearch";
323
+
324
+ loadIndex(config).then(function (loaded) {
325
+ if (version !== self.renderVersion) {
326
+ return;
327
+ }
328
+
329
+ var adapter = window.ClientSearchAdapters &&
330
+ (window.ClientSearchAdapters[engineName] ||
331
+ window.ClientSearchAdapters.minisearch);
332
+ if (!adapter || !adapter.available()) {
333
+ return;
334
+ }
335
+
336
+ var results = search(loaded.index, query, adapter);
337
+ return Promise.resolve(results).then(function (matches) {
338
+ if (version === self.renderVersion) {
339
+ self.renderResults(matches, loaded.documents);
340
+ }
341
+ });
342
+ }).catch(function () {
343
+ if (version === self.renderVersion) {
344
+ self.hide();
345
+ }
346
+ });
347
+ };
348
+
349
+ DropdownInstance.prototype.renderResults = function (matches, documents) {
350
+ this.results.replaceChildren();
351
+ this.currentItems = [];
352
+ this.selectedIndex = -1;
353
+
354
+ if (!matches || matches.length === 0) {
355
+ this.hide();
356
+ return;
357
+ }
358
+
359
+ var iconField = this.config.iconField || null;
360
+ var count = Math.min(matches.length, this.maxItems);
361
+
362
+ for (var i = 0; i < count; i++) {
363
+ var item = createResultItem(matches[i], documents, iconField);
364
+ if (item) {
365
+ this.results.appendChild(item);
366
+ this.currentItems.push(item);
367
+ }
368
+ }
369
+
370
+ if (this.currentItems.length === 0) {
371
+ this.hide();
372
+ return;
373
+ }
374
+
375
+ this.show();
376
+ };
377
+
378
+ DropdownInstance.prototype.selectItem = function (index) {
379
+ this.selectedIndex = index;
380
+ for (var i = 0; i < this.currentItems.length; i++) {
381
+ if (i === index) {
382
+ this.currentItems[i].setAttribute("aria-selected", "true");
383
+ } else {
384
+ this.currentItems[i].removeAttribute("aria-selected");
385
+ }
386
+ }
387
+ if (this.currentItems[index]) {
388
+ this.currentItems[index].scrollIntoView({ block: "nearest" });
389
+ }
390
+ };
391
+
392
+ DropdownInstance.prototype.show = function () {
393
+ this.results.setAttribute("aria-hidden", "false");
394
+ this.input.setAttribute("aria-expanded", "true");
395
+ };
396
+
397
+ DropdownInstance.prototype.hide = function () {
398
+ this.results.setAttribute("aria-hidden", "true");
399
+ this.input.setAttribute("aria-expanded", "false");
400
+ this.results.replaceChildren();
401
+ this.currentItems = [];
402
+ this.selectedIndex = -1;
403
+ };
404
+
405
+ function initAll() {
406
+ var config = window.clientSearchConfig || {};
407
+ var dropdownConfig = config.dropdown || {};
408
+ if (dropdownConfig.enabled === false) {
409
+ return;
410
+ }
411
+
412
+ var mergedConfig = {
413
+ indexUrl: config.indexUrl,
414
+ engine: config.engine || "minisearch",
415
+ iconField: config.iconField || null,
416
+ minChars: typeof dropdownConfig.minChars === "number" ? dropdownConfig.minChars : 2,
417
+ debounceMs: typeof dropdownConfig.debounceMs === "number" ? dropdownConfig.debounceMs : 150,
418
+ maxItems: typeof dropdownConfig.maxItems === "number" ? dropdownConfig.maxItems : 5,
419
+ redirectUrl: dropdownConfig.redirectUrl || "/search/"
420
+ };
421
+
422
+ var roots = document.querySelectorAll("[data-client-search-dropdown]");
423
+ roots.forEach(function (root) {
424
+ var instance = new DropdownInstance(root, mergedConfig);
425
+ instance.init();
426
+ });
427
+ }
428
+
429
+ if (document.readyState === "loading") {
430
+ document.addEventListener("DOMContentLoaded", initAll);
431
+ } else {
432
+ initAll();
433
+ }
434
+ }());
@@ -100,6 +100,11 @@
100
100
  }
101
101
  sortItems(items, sortOrder);
102
102
 
103
+ var maxItems = options.maxItems || 0;
104
+ if (maxItems && maxItems > 0) {
105
+ items = items.slice(0, maxItems);
106
+ }
107
+
103
108
  container.replaceChildren();
104
109
  if (items.length === 0) {
105
110
  return;
@@ -138,6 +143,7 @@
138
143
  relationsUrl: window.clientSearchConfig && window.clientSearchConfig.relatedUrl,
139
144
  currentUrl: window.location.pathname,
140
145
  sort: (window.clientSearchConfig && window.clientSearchConfig.relatedSort) || "relevance",
146
+ maxItems: 0,
141
147
  renderItem: null,
142
148
  filter: null
143
149
  }, options || {});
@@ -146,6 +152,10 @@
146
152
  return Promise.resolve();
147
153
  }
148
154
 
155
+ if (container.dataset.relatedMax) {
156
+ options.maxItems = parseInt(container.dataset.relatedMax, 10) || 0;
157
+ }
158
+
149
159
  return fetch(options.relationsUrl, { headers: { Accept: "application/json" } })
150
160
  .then(function (response) {
151
161
  if (!response.ok) {
@@ -23,6 +23,8 @@ module Jekyll
23
23
  "collections" => ["posts"],
24
24
  "include_pages" => false,
25
25
  "copy_runtime" => true,
26
+ "passthrough_fields" => [],
27
+ "icon_field" => "icon_url",
26
28
  "embedding" => {
27
29
  "enabled" => false,
28
30
  "model" => "embeddinggemma:300m",
@@ -37,17 +39,11 @@ module Jekyll
37
39
  }.freeze
38
40
 
39
41
  def initialize(site)
40
- configured = site.config["client_search"]
41
- configured = { "enabled" => false } if configured == false
42
- configured ||= {}
43
- unless configured.is_a?(Hash)
44
- raise Jekyll::Errors::FatalException,
45
- "client_search configuration must be a mapping or false"
46
- end
47
-
42
+ configured = normalize_configured(site.config["client_search"])
48
43
  @values = DEFAULTS.merge(configured)
49
44
  @live_search = LiveSearchConfiguration.new(configured["live_search"] || {}, engine: engine)
50
45
  @related = RelatedConfiguration.new(configured["related"] || {})
46
+ @dropdown = DropdownConfiguration.new(configured["dropdown"] || {})
51
47
  merge_embedding_config(configured["embedding"])
52
48
  validate_engine!
53
49
  return unless embedding_enabled?
@@ -58,6 +54,15 @@ module Jekyll
58
54
 
59
55
  private
60
56
 
57
+ def normalize_configured(value)
58
+ return { "enabled" => false } if value == false
59
+ return value if value.is_a?(Hash)
60
+ return {} if value.nil?
61
+
62
+ raise Jekyll::Errors::FatalException,
63
+ "client_search configuration must be a mapping or false"
64
+ end
65
+
61
66
  def merge_embedding_config(configured_embedding)
62
67
  embedding = configured_embedding || {}
63
68
  unless embedding.is_a?(Hash)
@@ -103,6 +108,7 @@ module Jekyll
103
108
  assets = ["assets/client-search-base.js", "assets/adapters/#{engine}.js"]
104
109
  assets.concat(query_embedder_assets) if engine == "semantic" && embedding_enabled?
105
110
  assets << "assets/client-search-related.js" if related_enabled?
111
+ assets << "assets/client-search-dropdown.js" if dropdown_enabled?
106
112
  assets
107
113
  end
108
114
 
@@ -111,11 +117,7 @@ module Jekyll
111
117
  end
112
118
 
113
119
  def collections
114
- Array(@values["collections"])
115
- .compact
116
- .map(&:to_s)
117
- .reject(&:empty?)
118
- .uniq
120
+ Array(@values["collections"]).compact.map(&:to_s).reject(&:empty?).uniq
119
121
  end
120
122
 
121
123
  def include_pages?
@@ -126,6 +128,31 @@ module Jekyll
126
128
  @values["copy_runtime"] != false
127
129
  end
128
130
 
131
+ def passthrough_fields
132
+ Array(@values["passthrough_fields"]).compact.each_with_object([]) do |e, f|
133
+ e.is_a?(Hash) ? add_hash_fields(e, f) : add_string_field(e, f)
134
+ end.uniq
135
+ end
136
+
137
+ def add_hash_fields(hash, fields)
138
+ hash.each do |s, t|
139
+ fields << [s.to_s, t.to_s] unless s.to_s.empty? || t.to_s.empty?
140
+ end
141
+ end
142
+
143
+ def add_string_field(entry, fields)
144
+ fields << [entry.to_s, entry.to_s] unless entry.to_s.empty?
145
+ end
146
+
147
+ def icon_field
148
+ v = @values["icon_field"]
149
+ v.nil? || v == false ? nil : v.to_s
150
+ end
151
+
152
+ def runtime_icon_field
153
+ icon_field if passthrough_fields.map(&:last).include?(icon_field)
154
+ end
155
+
129
156
  private
130
157
 
131
158
  def validate_query_embedder!
@@ -28,10 +28,6 @@ module Jekyll
28
28
  @query_embedder.model
29
29
  end
30
30
 
31
- def query_embedder_api_url
32
- @query_embedder.api_url
33
- end
34
-
35
31
  def query_embedder_asset
36
32
  @query_embedder.asset
37
33
  end
@@ -43,6 +39,18 @@ module Jekyll
43
39
  def query_embedder_config_json
44
40
  @query_embedder.to_json
45
41
  end
42
+
43
+ def dropdown_enabled?
44
+ @dropdown.enabled?
45
+ end
46
+
47
+ def dropdown_max_items
48
+ @dropdown.max_items
49
+ end
50
+
51
+ def dropdown_config
52
+ @dropdown.to_h
53
+ end
46
54
  end
47
55
  end
48
56
  end