jekyll-client-search 0.4.0 → 0.5.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: 749a1708fe34bce1624db0eb4d91ea58a569f2663f8d78b8200365188faf7ae4
4
- data.tar.gz: 833b1c87a57b2f78a05f42103efef6f9aa2ec09ee0726cad734e0f28f268fc50
3
+ metadata.gz: 3e0accecae865157aa09bda9bf9df5060bcad7a45eeb55555137f963fe1cca93
4
+ data.tar.gz: 8bb447d56a507fb5c7a632ae38433606317bccfda3e2cb01388c129617a49b8a
5
5
  SHA512:
6
- metadata.gz: 169936c38c680b2f4ec657243909aaad0a902b6bbbc52e9c586d3d986651e1529fdfb2d2a9dcde69f997075b8c901c5178b7148d86f064242ee018a228b2e457
7
- data.tar.gz: 3e75798e1b41dac7982ca34e83e61e8b28b03b347f6ca60b6e527570a72d4a69a9f5ddf6293197410c6935a699b54a7001b601145c57c7e09302e4f1edf4d426
6
+ metadata.gz: bcb6ee7ce75d7b75facd5c69db2f0c8479f4e70c0d5e6830b92f65cec936235596f3eb8e97db6621e9b6ec3559868b1f34d3b3ea1b8f5610464b880b88261104
7
+ data.tar.gz: 1c35127143ea928d0156ba202c0c4e6a485729de69afd69e3203f45d5625e0f00848a17e2f4cfe50f9085b8c61e76d16f81b36e05ffa8f27352cb655a42f5eaf
data/CHANGELOG.md CHANGED
@@ -1,5 +1,54 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0 — 2026-11-08
4
+
5
+ ### Added
6
+
7
+ - `rake docs:demo` — captures the checked-in illustrative overview as
8
+ animated AVIF + WebP + static PNG (headless Chrome + ffmpeg + img2webp;
9
+ build-host tools), embedded in README via `<picture>`. Examples are
10
+ schematic, not generated from a live Jekyll build.
11
+
12
+ - Dev-tooling bumps: `markdownlint-cli2` 0.24.0, `eslint` ~10.14.0,
13
+ `globals` 17.14.0, `rubocop` 1.92.2, `ollama-ruby` 1.26.0,
14
+ `excon` 1.11.0, `sass-embedded` 1.108.1, `addressable` 2.9.2,
15
+ `csv` 3.3.7, `io-console` 0.9.5, `parser` 3.3.13.0,
16
+ `irb` 1.19.0, `tins` 1.62.0; `BUNDLED WITH` 4.0.9 → 4.0.24.
17
+
18
+ ### Changed
19
+
20
+ - The live dropdown now follows the ARIA combobox/listbox pattern:
21
+ focused input with `aria-activedescendant`, uniquely identified plain
22
+ text options, and no nested links inside listbox options.
23
+ - CI Node.js matrix is now `24` / `26` — matching the `engines:
24
+ >=24` floor. Node 22 dropped (maintenance-only).
25
+ - CI action pins bumped: `ruby/setup-ruby` v1.338.0,
26
+ `sonarqube-scan-action` v8.4.1, `semgrep` image 1.183.0.
27
+ - `spec/search_index_page_spec.rb` expanded — now covers the default
28
+ root-level output path (`search-index.json`), an empty document
29
+ list, and field/order preservation.
30
+ - `test/meta.test.js` cross-engine check split into one named `test()`
31
+ per exact-match query — a single failure no longer hides the
32
+ remaining queries.
33
+
34
+ ### Fixed
35
+
36
+ - Benchmark scripts access environment via `ENV.fetch("KEY", default)`
37
+ instead of `ENV["KEY"] || default` (SonarQube: environment variable
38
+ access without a default value).
39
+ - SonarQube coverage denominator no longer counts `benchmarks/**` —
40
+ the ~850 lines of manual benchmark tooling are not instrumented by
41
+ SimpleCov, which diluted the reported project coverage to ~50%.
42
+
43
+ ### Security
44
+
45
+ - `overrides` pins `re2js` 2.8.8 — markdownlint 0.42.0 (via
46
+ markdownlint-cli2 0.24.0) pins the vulnerable 2.8.6 (ReDoS in
47
+ lookbehind matching, GHSA-gg6h-894w-4m9c).
48
+ - `nokogiri` 1.19.7 (dev-only, via `ollama-ruby`) — fixes
49
+ GHSA-3w22-6jfg-527w (use-after-free), GHSA-4p39-36wj-23h4
50
+ (mutation XSS), GHSA-v995-44v6-5fqm (JRuby SSRF).
51
+
3
52
  ## 0.4.0 — 2026-10-24
4
53
 
5
54
  - Dev-tooling bump: `jsdom` 30.1.3 (patch).
data/README.developer.md CHANGED
@@ -20,12 +20,27 @@ Both test suites pass with only the committed Wikipedia fixtures and the
20
20
  committed baseline JSON artifacts. No network access or external services
21
21
  are required for the default test run.
22
22
 
23
+ Specs that need a Jekyll site use `jekyll-test-harness` (`jekyll_build` +
24
+ `jekyll_files` DSL, installed in `spec/spec_helper.rb`) instead of hand-rolled
25
+ `Dir.mktmpdir`/`Jekyll::Site.new` scaffolding — see
26
+ `spec/support/site_helpers.rb` and `spec/generator_spec.rb` for the pattern.
27
+ Note that the gem's `Jekyll::ClientSearch::Generator` subclasses
28
+ `Jekyll::Generator`, so it runs automatically inside `jekyll_build`'s
29
+ `site.process`; specs should assert on the built site directly and stub
30
+ `build_embedding_adapter` (see `stub_embedding_adapter`) before building
31
+ rather than calling `generate` manually. Specs that rebuild the same source
32
+ directory multiple times (`spec/embedding_cache_spec.rb`,
33
+ `spec/system_spec.rb`, Ollama integration) still use their own helpers,
34
+ since the harness builds each site in a fresh temporary directory.
35
+
23
36
  ## Quality checks and git hooks
24
37
 
25
38
  The project uses a focused quality stack: RuboCop (style), bundler-audit
26
39
  (security), RSpec (Ruby tests), `npm test` (JavaScript tests), and Semgrep
27
40
  (security scanning). This covers the actionable ground without the
28
41
  maintenance burden of additional code-smell tools on a small focused gem.
42
+ `npm test` also runs ESLint, Markdownlint, and jsdom tests for the browser
43
+ runtime, including the dropdown combobox/listbox keyboard and ARIA contract.
29
44
 
30
45
  External automated code review services:
31
46
 
@@ -112,7 +127,7 @@ CI. Skip either with `git commit --no-verify` or `git push --no-verify`.
112
127
  ### CI checks
113
128
 
114
129
  The [CI workflow](.github/workflows/ci.yml) runs on every push and pull
115
- request across Ruby 3.3/3.4 and Node 22/24:
130
+ request across Ruby 3.3/3.4 and Node 24/26:
116
131
 
117
132
  - `bundle exec rake ci` (rspec, rubocop, syntax checks, npm test, gem build)
118
133
  - `bundle exec bundle-audit check --update` (Ruby dependency security)
data/README.md CHANGED
@@ -23,6 +23,19 @@ via pre-computed embeddings from a local [Ollama](https://ollama.ai) server.
23
23
  Related articles can be generated at build time from shared tags, categories,
24
24
  and vector similarity.
25
25
 
26
+ <!-- markdownlint-disable MD033 -->
27
+ <picture>
28
+ <source
29
+ srcset="docs/assets/jekyll-client-search-demo.avif"
30
+ type="image/avif">
31
+ <source
32
+ srcset="docs/assets/jekyll-client-search-demo.webp"
33
+ type="image/webp">
34
+ <img src="docs/assets/jekyll-client-search-demo.png"
35
+ alt="jekyll-client-search demo">
36
+ </picture>
37
+ <!-- markdownlint-enable MD033 -->
38
+
26
39
  ## Table of contents
27
40
 
28
41
  - [Lightning start](#lightning-start)
@@ -268,7 +281,8 @@ Tag modes:
268
281
  `/search/`) with `?q=...` for full results on the search page
269
282
  - Arrow Up/Down → highlight items, Enter on highlighted → navigate
270
283
  - Click an item → navigate to that document's URL
271
- - Escape or click outside → close dropdown
284
+ - Escape → close the popup and keep focus in the combobox; click outside
285
+ → close the dropdown
272
286
  - Index is lazy-loaded on first keystroke (no cost on pages where the
273
287
  visitor doesn't search)
274
288
 
@@ -289,24 +303,32 @@ and the search index includes that field (e.g. from jekyll-documents),
289
303
  an `<img>` icon is rendered before each result title. Set `icon_field:
290
304
  null` to disable.
291
305
 
292
- **CSS styling:** The dropdown emits no framework classes. Style with:
306
+ **CSS styling:** The dropdown emits no framework classes. Its input uses
307
+ the ARIA combobox pattern; the popup is a listbox whose options are plain
308
+ text containers (not nested links or controls). Style with:
293
309
 
294
310
  ```css
295
311
  .client-search-dropdown { position: relative; }
296
- .client-search-dropdown ul[role="listbox"] {
312
+ .client-search-dropdown [role="listbox"] {
297
313
  position: absolute; top: 100%; right: 0;
298
314
  min-width: 300px; z-index: 30;
299
315
  background: white; box-shadow: 0 2px 8px rgba(0,0,0,0.1);
316
+ margin: 0; padding: 0;
317
+ }
318
+ .client-search-dropdown [role="listbox"] ul[role="presentation"] {
300
319
  list-style: none; margin: 0; padding: 0;
301
320
  }
302
- .client-search-dropdown li { padding: 0.5rem 1rem; }
303
- .client-search-dropdown li:hover,
304
- .client-search-dropdown li[aria-selected="true"] { background: #f5f5f5; }
305
- .client-search-dropdown li a { text-decoration: none; color: inherit; }
321
+ .client-search-dropdown [role="option"] {
322
+ cursor: pointer; padding: 0.5rem 1rem;
323
+ }
324
+ .client-search-dropdown [role="option"]:hover,
325
+ .client-search-dropdown [role="option"][aria-selected="true"] {
326
+ background: #f5f5f5;
327
+ }
306
328
 
307
329
  /* Type-based styling via data attributes */
308
- .client-search-dropdown li[data-source="documents"] { border-left: 3px solid #48c774; }
309
- .client-search-dropdown li[data-file-type="pdf"]::before {
330
+ .client-search-dropdown [role="option"][data-source="documents"] { border-left: 3px solid #48c774; }
331
+ .client-search-dropdown [role="option"][data-file-type="pdf"]::before {
310
332
  content: "PDF"; background: #ff0000; color: white;
311
333
  padding: 0 0.3em; font-size: 0.7em; margin-right: 0.3em;
312
334
  }
@@ -9,8 +9,8 @@
9
9
  * Features:
10
10
  * - Lazy index loading (fetches search-index.json on first keystroke)
11
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)
12
+ * - ARIA combobox + listbox with plain-text options and optional icons
13
+ * - Active-descendant keyboard navigation (Arrow Up/Down, Enter, Escape)
14
14
  * - Enter with no selection → redirect to redirect_url (?q=...)
15
15
  * - Click outside or Escape → close dropdown
16
16
  * - Multi-instance via [data-client-search-dropdown] attributes
@@ -20,6 +20,8 @@
20
20
  var sharedIndex = null;
21
21
  var sharedDocuments = null;
22
22
  var indexLoadPromise = null;
23
+ var nextDropdownInstanceId = 0;
24
+ var initializedRoots = new WeakSet();
23
25
 
24
26
  function safeUrl(value) {
25
27
  if (value === "#") {
@@ -116,25 +118,27 @@
116
118
  });
117
119
  }
118
120
 
119
- function createResultItem(match, documents, iconField) {
121
+ function createResultItem(match, documents, iconField, optionId) {
120
122
  var entry = documents.get(match.ref);
121
123
  if (!entry) {
122
124
  return null;
123
125
  }
124
126
 
125
- var li = document.createElement("li");
126
- li.setAttribute("role", "option");
127
- li.className = "client-search-dropdown-item";
128
- li.dataset.url = safeUrl(entry.url);
127
+ var option = document.createElement("li");
128
+ option.id = optionId;
129
+ option.setAttribute("role", "option");
130
+ option.setAttribute("aria-selected", "false");
131
+ option.className = "client-search-dropdown-item";
132
+ option.dataset.url = safeUrl(entry.url);
129
133
 
130
134
  if (entry.source) {
131
- li.dataset.source = entry.source;
135
+ option.dataset.source = entry.source;
132
136
  }
133
137
  if (entry.categories && entry.categories.length) {
134
- li.dataset.categories = entry.categories.join(" ");
138
+ option.dataset.categories = entry.categories.join(" ");
135
139
  }
136
140
  if (entry.tags && entry.tags.length) {
137
- li.dataset.tags = entry.tags.join(" ");
141
+ option.dataset.tags = entry.tags.join(" ");
138
142
  }
139
143
  Object.keys(entry).forEach(function (key) {
140
144
  var skip = ["id", "title", "url", "excerpt", "content",
@@ -145,7 +149,7 @@
145
149
  }
146
150
  var value = entry[key];
147
151
  if (typeof value === "string" || typeof value === "number") {
148
- li.dataset[toCamelCase(key)] = String(value);
152
+ option.dataset[toCamelCase(key)] = String(value);
149
153
  }
150
154
  });
151
155
 
@@ -153,22 +157,21 @@
153
157
  var icon = document.createElement("img");
154
158
  icon.className = "client-search-dropdown-icon";
155
159
  icon.src = safeUrl(entry[iconField]);
156
- icon.alt = entry.file_type || entry.source || "";
160
+ icon.alt = "";
161
+ icon.setAttribute("aria-hidden", "true");
157
162
  icon.loading = "lazy";
158
163
  icon.style.width = "1em";
159
164
  icon.style.height = "1em";
160
165
  icon.style.verticalAlign = "middle";
161
- icon.style.marginRight = "0.3em";
162
- li.appendChild(icon);
166
+ option.appendChild(icon);
163
167
  }
164
168
 
165
- var link = document.createElement("a");
166
- link.href = safeUrl(entry.url);
167
- link.textContent = entry.title;
168
- link.className = "client-search-dropdown-link";
169
- li.appendChild(link);
169
+ var title = document.createElement("span");
170
+ title.className = "client-search-dropdown-link";
171
+ title.textContent = entry.title;
172
+ option.appendChild(title);
170
173
 
171
- return li;
174
+ return option;
172
175
  }
173
176
 
174
177
  function DropdownInstance(root, config) {
@@ -176,6 +179,7 @@
176
179
  this.form = root.querySelector("[data-cs-dropdown-form]");
177
180
  this.input = root.querySelector("[data-cs-dropdown-input]");
178
181
  this.results = root.querySelector("[data-cs-dropdown-results]");
182
+ this.listbox = this.results.closest('[role="listbox"]') || this.results;
179
183
  this.config = config;
180
184
  this.maxItems = Number.parseInt(this.results.dataset.maxItems, 10) || config.maxItems || 5;
181
185
  this.selectedIndex = -1;
@@ -183,6 +187,9 @@
183
187
  this.debounceTimer = null;
184
188
  this.renderVersion = 0;
185
189
  this.adapter = null;
190
+ this.optionIdPrefix = this.results.id
191
+ ? this.results.id + "-option"
192
+ : "cs-dropdown-" + (++nextDropdownInstanceId) + "-option";
186
193
  }
187
194
 
188
195
  DropdownInstance.prototype.init = function () {
@@ -190,6 +197,11 @@
190
197
  return;
191
198
  }
192
199
 
200
+ this.results.setAttribute("aria-hidden", "true");
201
+ this.listbox.setAttribute("aria-hidden", "true");
202
+ this.input.setAttribute("aria-expanded", "false");
203
+ this.input.removeAttribute("aria-activedescendant");
204
+
193
205
  var self = this;
194
206
 
195
207
  this.input.addEventListener("input", function () {
@@ -212,9 +224,9 @@
212
224
  });
213
225
 
214
226
  this.results.addEventListener("click", function (event) {
215
- var li = event.target.closest("li");
216
- if (li && li.dataset.url && li.dataset.url !== "#") {
217
- window.location.href = li.dataset.url;
227
+ var option = event.target.closest('[role="option"]');
228
+ if (option && option.dataset.url && option.dataset.url !== "#") {
229
+ window.location.href = option.dataset.url;
218
230
  }
219
231
  });
220
232
  };
@@ -223,10 +235,10 @@
223
235
  var self = this;
224
236
  this.renderVersion += 1;
225
237
  clearTimeout(this.debounceTimer);
238
+ this.hide();
226
239
 
227
240
  var query = this.input.value.trim();
228
241
  if (!query || query.length < this.config.minChars) {
229
- this.hide();
230
242
  return;
231
243
  }
232
244
 
@@ -264,7 +276,6 @@
264
276
  break;
265
277
  case "Escape":
266
278
  this.hide();
267
- this.input.blur();
268
279
  break;
269
280
  case "Tab":
270
281
  this.hide();
@@ -318,6 +329,7 @@
318
329
  this.results.replaceChildren();
319
330
  this.currentItems = [];
320
331
  this.selectedIndex = -1;
332
+ this.input.removeAttribute("aria-activedescendant");
321
333
 
322
334
  if (!matches || matches.length === 0) {
323
335
  this.hide();
@@ -328,7 +340,12 @@
328
340
  var count = Math.min(matches.length, this.maxItems);
329
341
 
330
342
  for (var i = 0; i < count; i++) {
331
- var item = createResultItem(matches[i], documents, iconField);
343
+ var item = createResultItem(
344
+ matches[i],
345
+ documents,
346
+ iconField,
347
+ this.optionIdPrefix + "-" + i
348
+ );
332
349
  if (item) {
333
350
  this.results.appendChild(item);
334
351
  this.currentItems.push(item);
@@ -353,18 +370,26 @@
353
370
  }
354
371
  }
355
372
  if (this.currentItems[index]) {
356
- this.currentItems[index].scrollIntoView({ block: "nearest" });
373
+ this.input.setAttribute("aria-activedescendant", this.currentItems[index].id);
374
+ if (typeof this.currentItems[index].scrollIntoView === "function") {
375
+ this.currentItems[index].scrollIntoView({ block: "nearest" });
376
+ }
377
+ } else {
378
+ this.input.removeAttribute("aria-activedescendant");
357
379
  }
358
380
  };
359
381
 
360
382
  DropdownInstance.prototype.show = function () {
361
383
  this.results.setAttribute("aria-hidden", "false");
384
+ this.listbox.setAttribute("aria-hidden", "false");
362
385
  this.input.setAttribute("aria-expanded", "true");
363
386
  };
364
387
 
365
388
  DropdownInstance.prototype.hide = function () {
366
389
  this.results.setAttribute("aria-hidden", "true");
390
+ this.listbox.setAttribute("aria-hidden", "true");
367
391
  this.input.setAttribute("aria-expanded", "false");
392
+ this.input.removeAttribute("aria-activedescendant");
368
393
  this.results.replaceChildren();
369
394
  this.currentItems = [];
370
395
  this.selectedIndex = -1;
@@ -389,6 +414,11 @@
389
414
 
390
415
  var roots = document.querySelectorAll("[data-client-search-dropdown]");
391
416
  roots.forEach(function (root) {
417
+ if (initializedRoots.has(root)) {
418
+ return;
419
+ }
420
+ initializedRoots.add(root);
421
+
392
422
  var instance = new DropdownInstance(root, mergedConfig);
393
423
  instance.init();
394
424
  });
@@ -87,13 +87,17 @@ module Jekyll
87
87
  <div class="client-search-dropdown" data-client-search-dropdown>
88
88
  <form role="search" autocomplete="off" data-cs-dropdown-form>
89
89
  <label class="sr-only" for="#{@input_id}">Search</label>
90
- <input id="#{@input_id}" type="search" name="q"
90
+ <input id="#{@input_id}" type="search" name="q" role="combobox"
91
+ aria-autocomplete="list" aria-haspopup="listbox"
91
92
  placeholder="Search…" autocomplete="off"
92
93
  aria-expanded="false" aria-controls="#{@results_id}"
93
94
  data-cs-dropdown-input>
94
95
  </form>
95
- <ul id="#{@results_id}" role="listbox" aria-hidden="true"
96
- data-cs-dropdown-results data-max-items="#{max}"></ul>
96
+ <div id="#{@results_id}" role="listbox" aria-label="Search suggestions"
97
+ aria-hidden="true">
98
+ <ul role="presentation" aria-hidden="true"
99
+ data-cs-dropdown-results data-max-items="#{max}"></ul>
100
+ </div>
97
101
  </div>
98
102
  HTML
99
103
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Jekyll
4
4
  module ClientSearch
5
- VERSION = "0.4.0"
5
+ VERSION = "0.5.0"
6
6
  end
7
7
  end
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.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Svend Gundestrup