jekyll-client-search 0.2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 770d678df814030f4576214a39a7faca03f14d889cc284c5976486bc7c22f331
4
- data.tar.gz: 2eaf824353f2b981ec1c538c3a84b938198960d1092110b6efceb181311ce2ea
3
+ metadata.gz: ed1c7a635cc419d9fb130411ced8e64a473c016e8188e36ddd98ea6922a098f7
4
+ data.tar.gz: f320844ded59274df991b1aa3d56fb22560e818105c5a0e682ea6e6a0c67df88
5
5
  SHA512:
6
- metadata.gz: 44e285f7238df3e68523839fb80bdc3f67c25883e3311753b8b67d460a160dcb7e1b9c3c23c9066e04dc57b7d2e951924ebfc55ba205b4913e882b764e0fbff3
7
- data.tar.gz: e9b48c426a74311b235b33eda93d119ba3864be6d64175640de6971521461226baf5f8c5e88e89a47889beea28feae22833d3ec49a8cf461064e8e7101fab1fc
6
+ metadata.gz: a12fb0052f9a727baf957735cd23fc9759f46fe7b54977acd16ed63edb14b0d63e2594d3c2bee936c9317541f8810a654acf7a5369746c9d11c39699457cf351
7
+ data.tar.gz: 46986b4c1a0107821f6932d8538c1aff0fcc386ddb9e9f787a4890da5f8af3ccd30a8c0b6b9257d928d3b52aa6fe625806136812cfbf781763a0f3a391cf9aa6
data/CHANGELOG.md CHANGED
@@ -2,6 +2,38 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.3.0 — 2026-09-02
6
+
7
+ ### Added
8
+ - `{% search_dropdown %}` Liquid tag — compact live-search dropdown for navbars and headers with keyboard navigation (Arrow/Enter/Escape), lazy index loading, icon support, and framework-agnostic semantic HTML (no Bulma/Bootstrap/Tailwind classes)
9
+ - `DropdownConfiguration` class with `enabled`, `max_items`, `min_chars`, `debounce_ms`, `redirect_url` options
10
+ - `assets/client-search-dropdown.js` runtime (434 lines) — two-stage search (AND first, fuzzy OR fallback), shared index cache with the base runtime, multi-instance support via `[data-client-search-dropdown]` attributes, icon rendering from `icon_field`
11
+ - `max:N` parameter on `{% related_articles %}` tag (e.g. `{% related_articles max:3 %}`, `{% related_articles sort:date max:10 %}`)
12
+ - `max_items` config option for `related` section (default: 5)
13
+ - Dropdown config included in generated `search-runtime-config.js`
14
+ - `dropdown_enabled?` accessor and dropdown asset in `runtime_assets`
15
+ - HTML-escaping (via `CGI.escapeHTML`) for `engine_url`, `engine_sri`, and `engine_crossorigin` attributes in `SearchTag` script output
16
+ - Defensive guard with `simplecov:disable` annotation for unreachable `embedder_asset` nil branch in `SearchTag`
17
+ - Ollama embedding adapter tests for empty/invalid responses and standard error handling
18
+ - Related analyzer tests for semantic-only similarity matching
19
+ - System test assertions for embeddings/retrieval content matching and non-ASCII query robustness
20
+
21
+ ### Changed
22
+ - `related.max_items` default changed from `nil` (unlimited) to `5`
23
+ - `Configuration#initialize` refactored — extracted `normalize_configured` helper for cleaner config validation
24
+ - Runtime config page now merges `dropdown` config alongside `liveSearch`
25
+ - `SearchTag` and `RelatedTag` now return empty string when `site` is nil or `client_search` is `false` (previously crashed on nil site)
26
+ - Meta test (`test/meta.test.js`) converted from `forEach` to `for` loops to properly `await` settle in cross-engine comparison
27
+ - System tests strengthened with content assertions and non-ASCII robustness checks
28
+ - AGENTS.md restructured — split developer info to README.developer.md, added dropdown architecture entries
29
+ - README.developer.md expanded with coverage documentation, spec file table, and SimpleCov branch coverage notes
30
+ - README.md updated with dropdown section, related `max:N` docs, and CSS styling examples for dropdown
31
+
32
+ ### Fixed
33
+ - `SearchTag#render` no longer raises on nil site (now returns empty string)
34
+ - `RelatedTag#render` no longer raises on nil site or `client_search: false` config
35
+ - Meta test race condition — `forEach` callback couldn't `await settle()`, causing flaky cross-engine comparisons; fixed by switching to `for` loops
36
+
5
37
  ## 0.2.0 — 2026-08-27
6
38
 
7
39
  ### Added
data/README.developer.md CHANGED
@@ -12,8 +12,9 @@ rbenv install 3.4.10 # if not already installed
12
12
  rbenv local 3.4.10
13
13
  bundle install
14
14
  npm ci
15
- bundle exec rspec # Ruby tests (176 examples; 7 opt-in Ollama examples pending)
16
- npm test # JavaScript tests (130 tests)
15
+ bundle exec rspec # Ruby tests (248 examples; 7 Ollama pending)
16
+ OLLAMA_INTEGRATION=1 bundle exec rspec # all tests including Ollama integration
17
+ npm test # JavaScript tests (134 tests)
17
18
  ```
18
19
 
19
20
  Both test suites pass with only the committed Wikipedia fixtures and the
@@ -113,14 +114,9 @@ content, self-exclusion, and score sorting.
113
114
 
114
115
  ### Related test files
115
116
 
116
- | File | Description |
117
- | --- | --- |
118
- | `spec/related_analyzer_spec.rb` | Unit tests for the relation analyzer |
119
- | `spec/related_tag_spec.rb` | Unit tests for the `{% related_articles %}` Liquid tag |
120
- | `spec/search_tag_spec.rb` | Unit tests for the `{% search_form %}` Liquid tag |
121
- | `spec/tasks_spec.rb` | Unit tests for the rake tasks (reference_files, install) |
122
- | `spec/system_spec.rb` | System tests building the fixture site with related and search_form |
123
- | `test/related.test.js` | JS tests for the related renderer (default, renderItem, filter, sort) |
117
+ See the [Spec files](#spec-files) table for the full list. The key related
118
+ test files are `spec/related_analyzer_spec.rb`, `spec/related_tag_spec.rb`,
119
+ `spec/system_spec.rb`, and `test/related.test.js`.
124
120
 
125
121
  ## What is committed
126
122
 
@@ -248,6 +244,72 @@ bundle exec rspec --tag system
248
244
  bundle exec rspec --tag ollama_integration
249
245
  ```
250
246
 
247
+ ## Coverage
248
+
249
+ The project uses SimpleCov 1.1+ with branch coverage. The target is 100%
250
+ branch coverage and ~99.6% line coverage. Three lines are intentionally
251
+ excluded via `# simplecov:disable branch` for an unreachable defensive
252
+ guard in `search_tag.rb` (the `if embedder_asset` check that can never be
253
+ false with current embedder types — `semantic_with_embedder?` already
254
+ filters out the `none` type, and both `transformers` and `ollama_api`
255
+ always return an asset).
256
+
257
+ The coverage report is generated at `coverage/index.html` after each RSpec
258
+ run. Raw data is in `coverage/.resultset.json` (SimpleCov 1.0+ format with
259
+ `lines` and `branches` keys per file).
260
+
261
+ To check remaining uncovered lines and branches after a run:
262
+
263
+ ```bash
264
+ python3 -c "
265
+ import json
266
+ with open('coverage/.resultset.json') as f:
267
+ data = json.load(f)
268
+ for suite, info in data.items():
269
+ cov = info.get('coverage', {})
270
+ for file, lines in sorted(cov.items()):
271
+ if 'lib/jekyll' not in file: continue
272
+ short = file.split('/lib/')[-1]
273
+ line_data = lines.get('lines', []) if isinstance(lines, dict) else lines
274
+ uncovered = [i+1 for i, v in enumerate(line_data) if v == 0]
275
+ if uncovered:
276
+ print(f'{short}: lines {uncovered}')
277
+ branches = lines.get('branches', {}) if isinstance(lines, dict) else {}
278
+ for bk, bd in branches.items():
279
+ for sk, count in bd.items():
280
+ if count == 0:
281
+ print(f'{short}: {sk} (count=0)')
282
+ "
283
+ ```
284
+
285
+ ## Spec files
286
+
287
+ | File | Description |
288
+ | --- | --- |
289
+ | `spec/configuration_spec.rb` | Site configuration, defaults, validation |
290
+ | `spec/dropdown_configuration_spec.rb` | Dropdown config validation |
291
+ | `spec/dropdown_tag_spec.rb` | `{% search_dropdown %}` Liquid tag |
292
+ | `spec/search_tag_spec.rb` | `{% search_form %}` Liquid tag |
293
+ | `spec/related_tag_spec.rb` | `{% related_articles %}` Liquid tag |
294
+ | `spec/related_analyzer_spec.rb` | Build-time relation analysis |
295
+ | `spec/related_configuration_spec.rb` | Related config validation |
296
+ | `spec/generator_spec.rb` | Jekyll generator integration |
297
+ | `spec/document_builder_spec.rb` | Normalized search documents |
298
+ | `spec/search_index_page_spec.rb` | Generated JSON page |
299
+ | `spec/index_cache_spec.rb` | Index cache |
300
+ | `spec/embedding_cache_spec.rb` | Embedding cache |
301
+ | `spec/runtime_assets_spec.rb` | Runtime asset copying |
302
+ | `spec/runtime_config_page_spec.rb` | Runtime config page generation |
303
+ | `spec/ollama_embedding_adapter_spec.rb` | Ollama adapter unit tests |
304
+ | `spec/ollama_integration_spec.rb` | Ollama integration tests (pending) |
305
+ | `spec/llm_injection_spec.rb` | LLM injection baseline tests (pending) |
306
+ | `spec/tasks_spec.rb` | Rake tasks (reference_files, install) |
307
+ | `spec/gemspec_spec.rb` | Gemspec metadata |
308
+ | `spec/system_spec.rb` | End-to-end system tests |
309
+ | `test/runtime.test.js` | JS unit tests parameterized over adapters |
310
+ | `test/related.test.js` | JS tests for related renderer |
311
+ | `test/system.test.js` | JS system tests using committed baseline index |
312
+
251
313
  ## CI
252
314
 
253
315
  The CI pipeline runs `bundle exec rspec` and `npm test` without arXiv
data/README.md CHANGED
@@ -25,6 +25,7 @@ and vector similarity.
25
25
  - [Related articles](#related-articles)
26
26
  - [Semantic search (embeddings)](#semantic-search-embeddings)
27
27
  - [The search form](#the-search-form)
28
+ - [The search dropdown](#the-search-dropdown)
28
29
  - [Rake tasks](#rake-tasks)
29
30
  - [Configuration reference](#configuration-reference)
30
31
  - [Indexing custom collections](#indexing-custom-collections)
@@ -139,6 +140,23 @@ Then add one line to any post layout:
139
140
  {% related_articles %}
140
141
  ```
141
142
 
143
+ By default, 5 related articles are shown. Override per-tag with `max:N`:
144
+
145
+ ```liquid
146
+ {% related_articles max:3 %}
147
+ {% related_articles sort:date max:10 %}
148
+ ```
149
+
150
+ Or set the default in `_config.yml`:
151
+
152
+ ```yaml
153
+ client_search:
154
+ related:
155
+ enabled: true
156
+ max_items: 5 # default: 5; null = no limit
157
+ minimum_similarity: 0.55
158
+ ```
159
+
142
160
  Without embeddings, relations are based on shared tags, categories, and
143
161
  hierarchical parent domains. With embeddings enabled, vector similarity
144
162
  above the cutoff is also included. See
@@ -216,6 +234,77 @@ client_search:
216
234
  For manual `<script>` setup (engine-specific HTML), see
217
235
  [Browser usage reference](#browser-usage-reference).
218
236
 
237
+ ## The search dropdown
238
+
239
+ The `{% search_dropdown %}` Liquid tag renders a compact live-search
240
+ dropdown suitable for navbars and headers. It is framework-agnostic —
241
+ emits semantic HTML with data attributes, no CSS classes from Bulma,
242
+ Bootstrap, Tailwind, or any other framework. Style it with your own CSS.
243
+
244
+ ```liquid
245
+ {% search_dropdown %}
246
+ ```
247
+
248
+ Tag modes:
249
+
250
+ | Syntax | Effect |
251
+ | --- | --- |
252
+ | `{% search_dropdown %}` | Dropdown HTML + all scripts (default) |
253
+ | `{% search_dropdown max:10 %}` | Show up to 10 items (default: 5) |
254
+ | `{% search_dropdown scripts_only %}` | Just the scripts — use with custom HTML |
255
+ | `{% search_dropdown no_scripts %}` | Just the dropdown HTML — load scripts yourself |
256
+
257
+ **Behavior:**
258
+
259
+ - User types → results appear in a dropdown list (live, debounced)
260
+ - Enter with no item selected → redirect to `redirect_url` (default
261
+ `/search/`) with `?q=...` for full results on the search page
262
+ - Arrow Up/Down → highlight items, Enter on highlighted → navigate
263
+ - Click an item → navigate to that document's URL
264
+ - Escape or click outside → close dropdown
265
+ - Index is lazy-loaded on first keystroke (no cost on pages where the
266
+ visitor doesn't search)
267
+
268
+ **Configuration:**
269
+
270
+ ```yaml
271
+ client_search:
272
+ dropdown:
273
+ enabled: true # default
274
+ max_items: 5 # default, tag param max:N overrides
275
+ min_chars: 2 # minimum query length before search fires
276
+ debounce_ms: 150 # debounce delay
277
+ redirect_url: /search/ # where Enter redirects for full results
278
+ ```
279
+
280
+ **Icon support:** When `icon_field` is configured (default `icon_url`)
281
+ and the search index includes that field (e.g. from jekyll-documents),
282
+ an `<img>` icon is rendered before each result title. Set `icon_field:
283
+ null` to disable.
284
+
285
+ **CSS styling:** The dropdown emits no framework classes. Style with:
286
+
287
+ ```css
288
+ .client-search-dropdown { position: relative; }
289
+ .client-search-dropdown ul[role="listbox"] {
290
+ position: absolute; top: 100%; right: 0;
291
+ min-width: 300px; z-index: 30;
292
+ background: white; box-shadow: 0 2px 8px rgba(0,0,0,0.1);
293
+ list-style: none; margin: 0; padding: 0;
294
+ }
295
+ .client-search-dropdown li { padding: 0.5rem 1rem; }
296
+ .client-search-dropdown li:hover,
297
+ .client-search-dropdown li[aria-selected="true"] { background: #f5f5f5; }
298
+ .client-search-dropdown li a { text-decoration: none; color: inherit; }
299
+
300
+ /* Type-based styling via data attributes */
301
+ .client-search-dropdown li[data-source="documents"] { border-left: 3px solid #48c774; }
302
+ .client-search-dropdown li[data-file-type="pdf"]::before {
303
+ content: "PDF"; background: #ff0000; color: white;
304
+ padding: 0 0.3em; font-size: 0.7em; margin-right: 0.3em;
305
+ }
306
+ ```
307
+
219
308
  ### Customizing search results with CSS
220
309
 
221
310
  Each search result is rendered as an `<article>` with data attributes that
@@ -663,10 +752,16 @@ must return a DOM node (or `null` to skip the item):
663
752
  <script src="/assets/client-search-related.js"></script>
664
753
  <script>
665
754
  ClientSearchRelated.run({
666
- renderItem: function (item, document) {
667
- var li = document.createElement("li");
668
- li.innerHTML = '<a href="' + item.url + '">' + item.title + "</a>" +
669
- '<span class="score">' + (item.score * 100).toFixed(0) + "%</span>";
755
+ renderItem: function (item, dom) {
756
+ var li = dom.createElement("li");
757
+ var link = dom.createElement("a");
758
+ link.href = item.url;
759
+ link.textContent = item.title;
760
+ var score = dom.createElement("span");
761
+ score.className = "score";
762
+ score.textContent = (item.score * 100).toFixed(0) + "%";
763
+ li.appendChild(link);
764
+ li.appendChild(score);
670
765
  return li;
671
766
  }
672
767
  });
@@ -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) {
@@ -39,17 +39,11 @@ module Jekyll
39
39
  }.freeze
40
40
 
41
41
  def initialize(site)
42
- configured = site.config["client_search"]
43
- configured = { "enabled" => false } if configured == false
44
- configured ||= {}
45
- unless configured.is_a?(Hash)
46
- raise Jekyll::Errors::FatalException,
47
- "client_search configuration must be a mapping or false"
48
- end
49
-
42
+ configured = normalize_configured(site.config["client_search"])
50
43
  @values = DEFAULTS.merge(configured)
51
44
  @live_search = LiveSearchConfiguration.new(configured["live_search"] || {}, engine: engine)
52
45
  @related = RelatedConfiguration.new(configured["related"] || {})
46
+ @dropdown = DropdownConfiguration.new(configured["dropdown"] || {})
53
47
  merge_embedding_config(configured["embedding"])
54
48
  validate_engine!
55
49
  return unless embedding_enabled?
@@ -60,6 +54,15 @@ module Jekyll
60
54
 
61
55
  private
62
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
+
63
66
  def merge_embedding_config(configured_embedding)
64
67
  embedding = configured_embedding || {}
65
68
  unless embedding.is_a?(Hash)
@@ -105,6 +108,7 @@ module Jekyll
105
108
  assets = ["assets/client-search-base.js", "assets/adapters/#{engine}.js"]
106
109
  assets.concat(query_embedder_assets) if engine == "semantic" && embedding_enabled?
107
110
  assets << "assets/client-search-related.js" if related_enabled?
111
+ assets << "assets/client-search-dropdown.js" if dropdown_enabled?
108
112
  assets
109
113
  end
110
114
 
@@ -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
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Jekyll
6
+ module ClientSearch
7
+ # Validates optional compact dropdown live-search settings.
8
+ class DropdownConfiguration
9
+ DEFAULTS = {
10
+ "enabled" => true,
11
+ "max_items" => 5,
12
+ "min_chars" => 2,
13
+ "debounce_ms" => 150,
14
+ "redirect_url" => "/search/"
15
+ }.freeze
16
+
17
+ def initialize(config)
18
+ unless config.is_a?(Hash)
19
+ raise Jekyll::Errors::FatalException,
20
+ "client_search dropdown configuration must be a mapping"
21
+ end
22
+
23
+ @values = DEFAULTS.merge(config)
24
+ validate!
25
+ end
26
+
27
+ def enabled?
28
+ @values.fetch("enabled") != false
29
+ end
30
+
31
+ def max_items
32
+ @values.fetch("max_items")
33
+ end
34
+
35
+ def min_chars
36
+ @values.fetch("min_chars")
37
+ end
38
+
39
+ def debounce_ms
40
+ @values.fetch("debounce_ms")
41
+ end
42
+
43
+ def redirect_url
44
+ @values.fetch("redirect_url").to_s
45
+ end
46
+
47
+ def to_h
48
+ {
49
+ "enabled" => enabled?,
50
+ "maxItems" => max_items,
51
+ "minChars" => min_chars,
52
+ "debounceMs" => debounce_ms,
53
+ "redirectUrl" => redirect_url
54
+ }
55
+ end
56
+
57
+ private
58
+
59
+ def validate!
60
+ validate_boolean!("enabled")
61
+ validate_integer!("max_items", minimum: 1)
62
+ validate_integer!("min_chars", minimum: 0)
63
+ validate_integer!("debounce_ms", minimum: 0)
64
+ if redirect_url.empty?
65
+ raise Jekyll::Errors::FatalException,
66
+ "client_search dropdown redirect_url must not be empty"
67
+ end
68
+
69
+ uri = URI.parse(redirect_url)
70
+ return if uri.scheme.nil? && uri.host.nil? && !redirect_url.start_with?("//")
71
+
72
+ raise Jekyll::Errors::FatalException,
73
+ "client_search dropdown redirect_url must be a relative path"
74
+ end
75
+
76
+ def validate_boolean!(key)
77
+ return if [true, false].include?(@values[key])
78
+
79
+ raise Jekyll::Errors::FatalException,
80
+ "client_search dropdown.#{key} must be true or false"
81
+ end
82
+
83
+ def validate_integer!(key, minimum:)
84
+ value = @values.fetch(key)
85
+ return if value.is_a?(Integer) && value >= minimum
86
+
87
+ raise Jekyll::Errors::FatalException,
88
+ "client_search dropdown.#{key} must be an integer greater than or equal to #{minimum}"
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+
5
+ module Jekyll
6
+ module ClientSearch
7
+ # Liquid tag that renders a compact live-search dropdown suitable for
8
+ # navbars and headers. Framework-agnostic — emits semantic HTML with
9
+ # data attributes, no CSS classes from any framework.
10
+ #
11
+ # {% search_dropdown %}
12
+ # {% search_dropdown max:10 %}
13
+ # {% search_dropdown scripts_only %}
14
+ # {% search_dropdown no_scripts %}
15
+ #
16
+ # When client_search is disabled the tag renders nothing.
17
+ class DropdownTag < Liquid::Tag
18
+ SYNTAX = /\A(max:(\d+))?\s*(scripts_only|no_scripts)?\z/
19
+
20
+ def initialize(tag_name, markup, tokens)
21
+ super
22
+ @markup = markup.to_s.strip
23
+ unless (match = @markup.match(SYNTAX))
24
+ raise Liquid::SyntaxError,
25
+ "search_dropdown: invalid syntax. Use {% search_dropdown %}, " \
26
+ "{% search_dropdown max:10 %}, {% search_dropdown scripts_only %}, " \
27
+ "or {% search_dropdown no_scripts %}"
28
+ end
29
+
30
+ @max_items = match[2].to_i if match[2]
31
+ raise Liquid::SyntaxError, "search_dropdown: max must be greater than zero" if @max_items && @max_items < 1
32
+
33
+ @mode = match[3] || "full"
34
+ @input_id = "cs-dropdown-input-#{object_id}"
35
+ @results_id = "cs-dropdown-results-#{object_id}"
36
+ end
37
+
38
+ def render(context)
39
+ site = context.registers[:site]
40
+ return "" unless enabled?(site)
41
+
42
+ configuration = Configuration.new(site)
43
+ return "" unless configuration.dropdown_enabled?
44
+
45
+ render_mode(configuration, site)
46
+ end
47
+
48
+ private
49
+
50
+ def enabled?(site)
51
+ return false if site.nil?
52
+
53
+ config_hash = site.config.fetch("client_search", {})
54
+ config_hash != false && config_hash["enabled"] != false
55
+ end
56
+
57
+ def render_mode(configuration, site)
58
+ baseurl = site.config["baseurl"].to_s.gsub(%r{\A/+|/+$}, "")
59
+ prefix = baseurl.empty? ? "" : "/#{baseurl}"
60
+ max = resolve_max(configuration)
61
+
62
+ form_html = build_form(max)
63
+ return form_html if @mode == "no_scripts"
64
+
65
+ scripts = build_scripts(configuration, prefix)
66
+ return scripts if @mode == "scripts_only"
67
+
68
+ "#{form_html}\n#{scripts}"
69
+ end
70
+
71
+ def resolve_max(configuration)
72
+ @max_items || configuration.dropdown_max_items
73
+ end
74
+
75
+ def build_form(max)
76
+ <<~HTML
77
+ <div class="client-search-dropdown" data-client-search-dropdown>
78
+ <form role="search" autocomplete="off" data-cs-dropdown-form>
79
+ <label class="sr-only" for="#{@input_id}">Search</label>
80
+ <input id="#{@input_id}" type="search" name="q"
81
+ placeholder="Search…" autocomplete="off"
82
+ aria-expanded="false" aria-controls="#{@results_id}"
83
+ data-cs-dropdown-input>
84
+ </form>
85
+ <ul id="#{@results_id}" role="listbox" aria-hidden="true"
86
+ data-cs-dropdown-results data-max-items="#{max}"></ul>
87
+ </div>
88
+ HTML
89
+ end
90
+
91
+ def build_scripts(configuration, prefix)
92
+ scripts = []
93
+ engine = configuration.engine_url
94
+ if engine
95
+ attrs = ["src=\"#{CGI.escapeHTML(engine)}\""]
96
+ if configuration.engine_crossorigin
97
+ crossorigin = CGI.escapeHTML(configuration.engine_crossorigin)
98
+ attrs << "crossorigin=\"#{crossorigin}\""
99
+ end
100
+ if configuration.engine_sri
101
+ integrity = CGI.escapeHTML(configuration.engine_sri)
102
+ attrs << "integrity=\"#{integrity}\""
103
+ end
104
+ scripts << "<script #{attrs.join(' ')}></script>"
105
+ end
106
+ scripts << "<script src=\"#{prefix}/assets/search-runtime-config.js\"></script>"
107
+ scripts << "<script src=\"#{prefix}/assets/client-search-dropdown.js\"></script>"
108
+ scripts << "<script src=\"#{prefix}/assets/adapters/#{configuration.engine}.js\"></script>"
109
+ scripts.map { |script| " #{script}" }.join("\n")
110
+ end
111
+ end
112
+ end
113
+ end
114
+
115
+ Liquid::Template.register_tag("search_dropdown", Jekyll::ClientSearch::DropdownTag)
@@ -12,7 +12,7 @@ module Jekyll
12
12
  "include_parent_domains" => true,
13
13
  "semantic" => true,
14
14
  "minimum_similarity" => 0.55,
15
- "max_items" => nil
15
+ "max_items" => 5
16
16
  }.freeze
17
17
 
18
18
  def initialize(config)
@@ -7,12 +7,14 @@ module Jekyll
7
7
  #
8
8
  # {% related_articles %}
9
9
  # {% related_articles sort:date %}
10
+ # {% related_articles max:3 %}
11
+ # {% related_articles sort:date max:3 %}
10
12
  # {% related_articles no_scripts %}
11
13
  #
12
14
  # When +related.enabled+ is false the tag renders nothing, so it is safe
13
15
  # to leave in a layout even when the feature is off.
14
16
  class RelatedTag < Liquid::Tag
15
- SYNTAX = /\A(sort:(\w+))?\s*(no_scripts)?\z/
17
+ SYNTAX = /\A(sort:(\w+))?\s*(max:(\d+))?\s*(no_scripts)?\z/
16
18
 
17
19
  def initialize(tag_name, markup, tokens)
18
20
  super
@@ -20,22 +22,30 @@ module Jekyll
20
22
  unless (match = @markup.match(SYNTAX))
21
23
  raise Liquid::SyntaxError,
22
24
  "related_articles: invalid syntax. Use {% related_articles %}, " \
23
- "{% related_articles sort:date %}, or {% related_articles no_scripts %}"
25
+ "{% related_articles sort:date %}, {% related_articles max:3 %}, " \
26
+ "or {% related_articles no_scripts %}"
24
27
  end
25
28
 
26
29
  @sort = match[2] if match[2]
27
- @include_scripts = match[3].nil?
30
+ @max_items = match[4].to_i if match[4]
31
+ raise Liquid::SyntaxError, "related_articles: max must be greater than zero" if @max_items && @max_items < 1
32
+
33
+ @include_scripts = match[5].nil?
28
34
  end
29
35
 
30
36
  def render(context)
31
37
  site = context.registers[:site]
32
- config = site&.config&.fetch("client_search", {})
38
+ return "" if site.nil?
39
+
40
+ config = site.config.fetch("client_search", {})
41
+ return "" if config == false
33
42
  return "" unless related_enabled?(config)
34
43
 
35
44
  asset_prefix = asset_prefix(site)
36
45
  sort_attr = @sort ? " data-related-sort=\"#{@sort}\"" : ""
46
+ max_attr = resolve_max_attr(config)
37
47
  scripts = build_scripts(asset_prefix)
38
- build_html(sort_attr, scripts)
48
+ build_html(sort_attr, max_attr, scripts)
39
49
  end
40
50
 
41
51
  private
@@ -57,7 +67,19 @@ module Jekyll
57
67
  "<script src=\"#{prefix}/assets/client-search-related.js\"></script>"
58
68
  end
59
69
 
60
- def build_html(sort_attr, scripts)
70
+ def resolve_max_attr(config)
71
+ related = config.fetch("related", {})
72
+ max = if @max_items
73
+ @max_items
74
+ elsif related.key?("max_items")
75
+ related["max_items"]
76
+ else
77
+ 5
78
+ end
79
+ max.nil? ? "" : " data-related-max=\"#{max}\""
80
+ end
81
+
82
+ def build_html(sort_attr, max_attr, scripts)
61
83
  <<~HTML
62
84
  <section class="related-articles-section">
63
85
  <label for="related-sort">Sort related articles</label>
@@ -65,7 +87,7 @@ module Jekyll
65
87
  <option value="relevance">Most related</option>
66
88
  <option value="date">Newest</option>
67
89
  </select>
68
- <div id="related-articles"#{sort_attr}></div>
90
+ <div id="related-articles"#{sort_attr}#{max_attr}></div>
69
91
  </section>#{scripts}
70
92
  HTML
71
93
  end
@@ -13,10 +13,13 @@ module Jekyll
13
13
  }
14
14
  defaults["relatedUrl"] = index_url(site, configuration.related_output) if configuration.related_enabled?
15
15
  defaults["iconField"] = configuration.runtime_icon_field if configuration.runtime_icon_field
16
+ defaults["dropdown"] = configuration.dropdown_config if configuration.dropdown_enabled?
16
17
  json = JSON.generate(defaults)
17
18
  self.content = "window.clientSearchConfig = (function (generated, existing) {" \
18
19
  "var liveSearch = Object.assign({}, generated.liveSearch, existing.liveSearch || {});" \
19
- "return Object.assign(generated, existing, { liveSearch: liveSearch });" \
20
+ "var dropdown = Object.assign({}, generated.dropdown, existing.dropdown || {});" \
21
+ "return Object.assign(generated, existing, " \
22
+ "{ liveSearch: liveSearch, dropdown: dropdown });" \
20
23
  "}(#{json}, window.clientSearchConfig || {}));\n"
21
24
  end
22
25
 
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "cgi"
4
+
3
5
  module Jekyll
4
6
  module ClientSearch
5
7
  # Liquid tag that renders the search form, status/results containers,
@@ -28,7 +30,10 @@ module Jekyll
28
30
 
29
31
  def render(context)
30
32
  site = context.registers[:site]
31
- config_hash = site&.config&.fetch("client_search", {})
33
+ return "" if site.nil?
34
+
35
+ config_hash = site.config.fetch("client_search", {})
36
+ return "" if config_hash == false
32
37
  return "" unless config_hash["enabled"] != false
33
38
 
34
39
  configuration = Configuration.new(site)
@@ -73,9 +78,15 @@ module Jekyll
73
78
  url = configuration.engine_url
74
79
  return nil unless url
75
80
 
76
- attrs = ["src=\"#{url}\""]
77
- attrs << "crossorigin=\"#{configuration.engine_crossorigin}\"" if configuration.engine_crossorigin
78
- attrs << "integrity=\"#{configuration.engine_sri}\"" if configuration.engine_sri
81
+ attrs = ["src=\"#{CGI.escapeHTML(url)}\""]
82
+ if configuration.engine_crossorigin
83
+ crossorigin = CGI.escapeHTML(configuration.engine_crossorigin)
84
+ attrs << "crossorigin=\"#{crossorigin}\""
85
+ end
86
+ if configuration.engine_sri
87
+ integrity = CGI.escapeHTML(configuration.engine_sri)
88
+ attrs << "integrity=\"#{integrity}\""
89
+ end
79
90
  "<script #{attrs.join(' ')}></script>"
80
91
  end
81
92
 
@@ -84,7 +95,13 @@ module Jekyll
84
95
 
85
96
  scripts = ["<script src=\"#{prefix}/assets/search-embedder-config.js\"></script>"]
86
97
  embedder_asset = configuration.query_embedder_asset
98
+ # simplecov:disable branch
99
+ # Defensive guard: semantic_with_embedder? already excludes the "none"
100
+ # type, and both "transformers" and "ollama_api" always return an asset.
101
+ # This branch is unreachable with current embedder types but protects
102
+ # against future types that may have no script asset.
87
103
  scripts << "<script src=\"#{prefix}/#{embedder_asset}\"></script>" if embedder_asset
104
+ # simplecov:enable branch
88
105
  scripts
89
106
  end
90
107
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Jekyll
4
4
  module ClientSearch
5
- VERSION = "0.2.0"
5
+ VERSION = "0.3.0"
6
6
  end
7
7
  end
@@ -9,6 +9,7 @@ require_relative "client_search/version"
9
9
  require_relative "client_search/embedding_configuration"
10
10
  require_relative "client_search/live_search_configuration"
11
11
  require_relative "client_search/related_configuration"
12
+ require_relative "client_search/dropdown_configuration"
12
13
  require_relative "client_search/query_embedder_configuration"
13
14
  require_relative "client_search/configuration_accessors"
14
15
  require_relative "client_search/configuration"
@@ -22,4 +23,5 @@ require_relative "client_search/index_cache"
22
23
  require_relative "client_search/ollama_embedding_adapter"
23
24
  require_relative "client_search/generator"
24
25
  require_relative "client_search/related_tag"
26
+ require_relative "client_search/dropdown_tag"
25
27
  require_relative "client_search/search_tag"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: jekyll-client-search
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Svend Gundestrup
@@ -46,6 +46,7 @@ files:
46
46
  - assets/adapters/minisearch.js
47
47
  - assets/adapters/semantic.js
48
48
  - assets/client-search-base.js
49
+ - assets/client-search-dropdown.js
49
50
  - assets/client-search-related.js
50
51
  - assets/includes/related-articles.html
51
52
  - assets/layouts/post-with-related.html
@@ -59,6 +60,8 @@ files:
59
60
  - lib/jekyll/client_search/configuration.rb
60
61
  - lib/jekyll/client_search/configuration_accessors.rb
61
62
  - lib/jekyll/client_search/document_builder.rb
63
+ - lib/jekyll/client_search/dropdown_configuration.rb
64
+ - lib/jekyll/client_search/dropdown_tag.rb
62
65
  - lib/jekyll/client_search/embedder_config_page.rb
63
66
  - lib/jekyll/client_search/embedding_configuration.rb
64
67
  - lib/jekyll/client_search/generator.rb