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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b07a5519cf45012ce0d198cedfe93e1e93e939ed9d9221386af87c10854f0cf4
4
- data.tar.gz: 630a3dd0f345e4e263c8228ac3a565610b435723860dac1125e86bf780d072b1
3
+ metadata.gz: ed1c7a635cc419d9fb130411ced8e64a473c016e8188e36ddd98ea6922a098f7
4
+ data.tar.gz: f320844ded59274df991b1aa3d56fb22560e818105c5a0e682ea6e6a0c67df88
5
5
  SHA512:
6
- metadata.gz: 0d53f70fa2e1f37facfebe56ebf3384f3d5c376672e08f93cc5a3a761b03a294843b0cf236cbda9ffa7a7ace87261683a525f3a5e12e9da61592946cc878024a
7
- data.tar.gz: 458dc19c9c0ad2b6c017057694b1af2103d918f16584205ec7c144a5e696fcb0d5609508fa414a7cb91facccf39a75dcd0c1c3e8c8a8e67bac8d1a09665419b5
6
+ metadata.gz: a12fb0052f9a727baf957735cd23fc9759f46fe7b54977acd16ed63edb14b0d63e2594d3c2bee936c9317541f8810a654acf7a5369746c9d11c39699457cf351
7
+ data.tar.gz: 46986b4c1a0107821f6932d8538c1aff0fcc386ddb9e9f787a4890da5f8af3ccd30a8c0b6b9257d928d3b52aa6fe625806136812cfbf781763a0f3a391cf9aa6
data/CHANGELOG.md CHANGED
@@ -1,5 +1,63 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
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
+
37
+ ## 0.2.0 — 2026-08-27
38
+
39
+ ### Added
40
+ - Generic `passthrough_fields` config option: external plugins declare which document `data` fields to forward into the search index (e.g. `file_type`, `icon_url`, `icon_set` from jekyll-documents)
41
+ - Field renaming in `passthrough_fields`: entries can be strings (same name) or hashes (`{source => target}`) for integration with other search conventions
42
+ - `icon_field` config option (default: `"icon_url"`): tells the browser runtime which field to render as an `<img>` icon before the result title; set to `null` to disable
43
+ - Search index now includes `source` (collection label: `posts`, `pages`, etc.) — always present, set by the generator
44
+ - Result `<article>` elements carry `data-source`, `data-categories`, `data-tags`, plus `data-*` for all passthrough fields (snake_case → data-kebab-case) for CSS-based result customization
45
+
46
+ ### Fixed
47
+ - Allow whitespace in script/style end tags in `DocumentBuilder#clean` (`</script\s*>` instead of `</script>`) — CodeQL: bad HTML filtering regexp
48
+ - Fix polynomial ReDoS in `SearchTag` and `RelatedTag` syntax regexes — strip markup before matching, remove leading/trailing `\s*` patterns that caused O(n²) backtracking (CodeQL: polynomial regex on uncontrolled data)
49
+
50
+ ### Changed
51
+ - Simplified release workflow to tag-push trigger (`push: tags: v*`) — no manual `gh release create` needed
52
+ - Switched to RubyGems trusted publishing (`rubygems/release-gem@v1` with OIDC)
53
+ - Centralized version in `version.rb` as single source of truth — removed `version` field from `package.json`
54
+ - Added `rake version:bump` and `rake version:check_changelog` tasks
55
+ - Added CHANGELOG gate to release workflow (fails if entry missing for the version)
56
+ - Added `npm audit` and `npm outdated` (non-blocking) to CI
57
+ - Replaced pre-commit hook with rubocop-only (fast); added pre-push hook (rubocop + rspec)
58
+ - Dropped reek from quality stack (KISS — marginal value on a small gem)
59
+ - Aligned AGENTS.md to the open convention; added CLAUDE.md and .windsurfrules pointer files
60
+
3
61
  ## 0.1.0 — 2026-08-25
4
62
 
5
63
  - Add a pluggable search engine architecture with a base runtime and adapters.
data/README.developer.md CHANGED
@@ -12,14 +12,66 @@ 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
20
21
  committed baseline JSON artifacts. No network access or external services
21
22
  are required for the default test run.
22
23
 
24
+ ## Quality checks and git hooks
25
+
26
+ The project uses a focused quality stack: RuboCop (style), bundler-audit
27
+ (security), RSpec (Ruby tests), and `npm test` (JavaScript tests). This
28
+ covers the actionable ground without the maintenance burden of additional
29
+ code-smell tools on a small focused gem.
30
+
31
+ ### Rake tasks
32
+
33
+ | Task | What it runs |
34
+ | --- | --- |
35
+ | `rake` (default) | `rake quality` — all checks below |
36
+ | `rake quality` | rubocop + bundler-audit + rspec + npm test |
37
+ | `rake quick` | rubocop + rspec (fast pre-push subset) |
38
+ | `rake rubocop` | RuboCop style check |
39
+ | `rake rubocop_fix` | RuboCop auto-fix |
40
+ | `rake bundler_audit` | `bundle-audit check --update` security scan |
41
+ | `rake spec` | RSpec test suite |
42
+ | `rake npm_test` | JavaScript test suite (`npm test`) |
43
+ | `rake ci` | rspec + rubocop + syntax checks + npm test + gem build |
44
+
45
+ ### Git hooks
46
+
47
+ Git hooks are not committed to the repository. Install them locally after
48
+ cloning:
49
+
50
+ ```bash
51
+ bin/install-hooks.sh
52
+ ```
53
+
54
+ This installs two hooks:
55
+
56
+ | Hook | What it runs | When |
57
+ | --- | --- | --- |
58
+ | `pre-commit` | `rubocop` only (~2s) | Before each commit |
59
+ | `pre-push` | `rubocop + rspec` (~15s) | Before each push |
60
+
61
+ The pre-commit hook is intentionally fast (style only) to avoid bypassing
62
+ with `--no-verify`. The full test suite runs on pre-push and in CI. Skip
63
+ either with `git commit --no-verify` or `git push --no-verify`.
64
+
65
+ ### CI checks
66
+
67
+ The [CI workflow](.github/workflows/ci.yml) runs on every push and pull
68
+ request across Ruby 3.2/3.3/3.4 and Node 22/24:
69
+
70
+ - `bundle exec rake ci` (rspec, rubocop, syntax checks, npm test, gem build)
71
+ - `bundle exec bundle-audit check --update` (Ruby dependency security)
72
+ - `npm audit --audit-level=high` (JavaScript dependency security)
73
+ - `npm outdated` (non-blocking — warns about outdated npm packages)
74
+
23
75
  ## Build-time related articles
24
76
 
25
77
  The optional related-analysis pass writes `search-relations.json` separately
@@ -62,14 +114,9 @@ content, self-exclusion, and score sorting.
62
114
 
63
115
  ### Related test files
64
116
 
65
- | File | Description |
66
- | --- | --- |
67
- | `spec/related_analyzer_spec.rb` | Unit tests for the relation analyzer |
68
- | `spec/related_tag_spec.rb` | Unit tests for the `{% related_articles %}` Liquid tag |
69
- | `spec/search_tag_spec.rb` | Unit tests for the `{% search_form %}` Liquid tag |
70
- | `spec/tasks_spec.rb` | Unit tests for the rake tasks (reference_files, install) |
71
- | `spec/system_spec.rb` | System tests building the fixture site with related and search_form |
72
- | `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`.
73
120
 
74
121
  ## What is committed
75
122
 
@@ -197,8 +244,223 @@ bundle exec rspec --tag system
197
244
  bundle exec rspec --tag ollama_integration
198
245
  ```
199
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
+
200
313
  ## CI
201
314
 
202
315
  The CI pipeline runs `bundle exec rspec` and `npm test` without arXiv
203
316
  fixtures or Ollama. The committed baseline JSON and semantic embeddings
204
317
  ensure all tests pass in CI without external dependencies.
318
+
319
+ ## Releasing
320
+
321
+ Releases are published to RubyGems.org via the
322
+ [`release.yml`](.github/workflows/release.yml) GitHub Actions workflow,
323
+ which triggers automatically when a GitHub Release is published. The
324
+ workflow uses RubyGems trusted publishing (OIDC) — no API key is stored
325
+ in the repository.
326
+
327
+ ### Prerequisites (one-time setup)
328
+
329
+ Trusted publishing must be configured on rubygems.org for the gem before
330
+ the first release:
331
+
332
+ 1. Log into <https://rubygems.org> and open the gem's page.
333
+ 2. Go to **Settings → Trusted Publishers → Add trusted publisher**.
334
+ 3. Enter:
335
+ - **Repository**: `gundestrup/jekyll-client-search`
336
+ - **Workflow filename**: `release.yml`
337
+ - **Environment**: `release`
338
+
339
+ If the gem has never been published before, rubygems.org may require a
340
+ one-time manual `gem push` with an API key to create the gem name before
341
+ a trusted publisher can be attached. After that, all subsequent releases
342
+ use trusted publishing automatically.
343
+
344
+ ### Version bumping
345
+
346
+ The version lives in [`lib/jekyll/client_search/version.rb`](lib/jekyll/client_search/version.rb)
347
+ and follows [Semantic Versioning](https://semver.org/). The gemspec reads
348
+ from this file — it is the **single source of truth**. No other file
349
+ stores the version (`package.json` is `private: true` and has no version
350
+ field). Use the rake task to bump it:
351
+
352
+ ```bash
353
+ bundle exec rake "version:bump[patch]" # 0.1.0 -> 0.1.1 (bug fixes)
354
+ bundle exec rake "version:bump[minor]" # 0.1.0 -> 0.2.0 (new features, backwards compatible)
355
+ bundle exec rake "version:bump[major]" # 0.1.0 -> 1.0.0 (incompatible API changes)
356
+ ```
357
+
358
+ The rake task only edits `version.rb`. It does not commit, tag, or update
359
+ the changelog — those are manual steps in the release checklist below.
360
+
361
+ You can verify that the CHANGELOG has an entry for the current version:
362
+
363
+ ```bash
364
+ bundle exec rake version:check_changelog
365
+ ```
366
+
367
+ This check also runs in the release workflow — the publish will fail if
368
+ `CHANGELOG.md` has no `## X.Y.Z` entry matching the version being released.
369
+
370
+ ### Release checklist
371
+
372
+ 1. **Ensure the working tree is clean** and on `main`:
373
+
374
+ ```bash
375
+ git status
376
+ ```
377
+
378
+ 2. **Ensure all 80 fixture posts are present** (needed for Ollama
379
+ integration tests and baseline regeneration):
380
+
381
+ ```bash
382
+ ruby spec/fixtures/download_arxiv.rb
383
+ ```
384
+
385
+ Requires `pdftotext` (`brew install poppler` on macOS). Skip if the
386
+ arXiv posts are already present and unchanged.
387
+
388
+ 3. **Run the full test suite locally**, including Ollama integration tests:
389
+
390
+ ```bash
391
+ bundle exec rspec
392
+ OLLAMA_INTEGRATION=1 bundle exec rspec spec/ollama_integration_spec.rb
393
+ OLLAMA_INTEGRATION=1 bundle exec rspec spec/llm_injection_spec.rb
394
+ bundle exec rubocop
395
+ npm test
396
+ npm run lint
397
+ gem build jekyll-client-search.gemspec
398
+ ```
399
+
400
+ The Ollama integration tests require a running [Ollama](https://ollama.ai/)
401
+ server with the `embeddinggemma:300m` model pulled (`ollama pull
402
+ embeddinggemma:300m`). Do not publish a release until the Ollama
403
+ integration tests pass locally — they are skipped by default and will
404
+ not block CI, so they must be run manually before each release.
405
+
406
+ 4. **Bump the version** (see above) and update the `## Unreleased` or new
407
+ `## X.Y.Z — YYYY-MM-DD` section at the top of
408
+ [`CHANGELOG.md`](CHANGELOG.md) with a user-facing summary of changes.
409
+
410
+ 5. **Commit the version bump and changelog**:
411
+
412
+ ```bash
413
+ git add lib/jekyll/client_search/version.rb CHANGELOG.md
414
+ git commit -m "Release X.Y.Z: <short summary>"
415
+ ```
416
+
417
+ 6. **Tag the release** with an annotated tag:
418
+
419
+ ```bash
420
+ git tag -a vX.Y.Z -m "Release X.Y.Z
421
+
422
+ <one-line summary of notable changes>"
423
+ ```
424
+
425
+ 7. **Push `main` and the tag** to GitHub — pushing the tag triggers the
426
+ release workflow automatically:
427
+
428
+ ```bash
429
+ git push origin main
430
+ git push origin vX.Y.Z
431
+ ```
432
+
433
+ No manual `gh release create` is needed. The workflow builds the gem,
434
+ attaches it to a GitHub release, and publishes to RubyGems.
435
+
436
+ 8. **Watch the workflow** and verify the gem appears on RubyGems:
437
+
438
+ ```bash
439
+ gh run watch --workflow=release.yml
440
+ gem list jekyll-client-search --remote --exact
441
+ ```
442
+
443
+ 9. **Verify the integration site** builds cleanly against the published
444
+ gem (update the path dependency to the released version in
445
+ `../gundestrup.dk` and run `bundle exec jekyll build`).
446
+
447
+ ### What the release workflow does
448
+
449
+ The [`release.yml`](.github/workflows/release.yml) workflow, triggered by
450
+ pushing a `v*` tag:
451
+
452
+ 1. Checks out the repository at the release tag (`persist-credentials: false`).
453
+ 2. Sets up Ruby 3.4.10.
454
+ 3. Verifies the tag name matches `v<gem version>` (fails on mismatch).
455
+ 4. Verifies `CHANGELOG.md` has a `## X.Y.Z` entry for the version
456
+ (`rake version:check_changelog` — fails if missing).
457
+ 5. Builds the gem (`gem build`).
458
+ 6. Creates a GitHub Release and attaches the `.gem` file as a downloadable
459
+ asset (`softprops/action-gh-release@v2`).
460
+ 7. Publishes the gem to RubyGems.org using trusted publishing
461
+ (`rubygems/release-gem@v1` with OIDC).
462
+
463
+ CI already validates tests on every push — the release workflow does not
464
+ re-run the test suite. It only builds and publishes. The workflow runs in
465
+ the `release` environment, which must match the environment name
466
+ configured on the trusted publisher.
data/README.md CHANGED
@@ -25,8 +25,10 @@ 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)
31
+ - [Indexing custom collections](#indexing-custom-collections)
30
32
  - [Architecture](#architecture)
31
33
  - [Embeddings and incremental indexing](#embeddings-and-incremental-indexing)
32
34
  - [Related articles reference](#related-articles-reference)
@@ -138,6 +140,23 @@ Then add one line to any post layout:
138
140
  {% related_articles %}
139
141
  ```
140
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
+
141
160
  Without embeddings, relations are based on shared tags, categories, and
142
161
  hierarchical parent domains. With embeddings enabled, vector similarity
143
162
  above the cutoff is also included. See
@@ -215,6 +234,160 @@ client_search:
215
234
  For manual `<script>` setup (engine-specific HTML), see
216
235
  [Browser usage reference](#browser-usage-reference).
217
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
+
308
+ ### Customizing search results with CSS
309
+
310
+ Each search result is rendered as an `<article>` with data attributes that
311
+ expose the document's metadata for CSS-based styling:
312
+
313
+ | Attribute | Value | Source |
314
+ | --- | --- | --- |
315
+ | `data-source` | Collection label (`posts`, `pages`, `documents`, …) | Generator (always present) |
316
+ | `data-categories` | Space-separated categories | Front matter |
317
+ | `data-tags` | Space-separated tags | Front matter |
318
+ | `data-*` | Any passthrough field (e.g. `data-file-type`, `data-icon-set`) | Config-driven |
319
+
320
+ **Passthrough fields:** External plugins can expose their data in search
321
+ results by listing field names in `passthrough_fields`. Each listed field is
322
+ forwarded from the document's front matter / data into the search index and
323
+ rendered as a `data-*` attribute on the result `<article>` (field names are
324
+ converted from `snake_case` to `data-kebab-case`).
325
+
326
+ **Field renaming:** Entries can be strings (same name) or hashes (rename
327
+ `{source => target}`) for integration with other search conventions:
328
+
329
+ ```yaml
330
+ client_search:
331
+ passthrough_fields:
332
+ - file_type: doctype # index as "doctype" instead of "file_type"
333
+ - icon_url: thumbnail # index as "thumbnail" instead of "icon_url"
334
+ - icon_set # stays as "icon_set"
335
+ icon_field: thumbnail # must match the target name
336
+ ```
337
+
338
+ **Auto-injection:** When jekyll-documents is installed and `documents` is
339
+ in the search collections, `file_type`, `icon_url`, and `icon_set` are
340
+ auto-injected into `passthrough_fields` — no manual config needed.
341
+
342
+ **Icon rendering:** When the configured `icon_field` (default: `"icon_url"`)
343
+ is present in a result, an `<img class="client-search-result-icon">` is
344
+ rendered before the title. Set `icon_field: null` to disable.
345
+
346
+ **Default icon sizing:** Icons are sized to `1em` (matching the heading's
347
+ font size) with `vertical-align: middle` and a small right margin — they
348
+ auto-scale with the text. The inline styles are self-contained (no CSS
349
+ dependency), so they work with any framework (Bulma, Bootstrap, Tailwind).
350
+
351
+ Override with CSS:
352
+
353
+ ```css
354
+ /* Make icons larger */
355
+ .client-search-result-icon { width: 1.5em; height: 1.5em; }
356
+
357
+ /* Fixed pixel sizes (if jekyll-documents' documents.css is included) */
358
+ .client-search-result-icon.icon-x2 { width: 32px; height: 32px; }
359
+
360
+ /* Theme-aware sizing using jekyll-documents' icon_set */
361
+ .client-search-result[data-icon-set="color"] .client-search-result-icon { width: 1.5em; }
362
+ .client-search-result[data-icon-set="ultra-minimal"] .client-search-result-icon { width: 1em; }
363
+ ```
364
+
365
+ **CSS examples:**
366
+
367
+ ```css
368
+ /* Show a different border color for posts vs pages */
369
+ .client-search-result[data-source="posts"] { border-left: 3px solid #3273dc; }
370
+ .client-search-result[data-source="pages"] { border-left: 3px solid #48c774; }
371
+
372
+ /* Add a badge showing the file type via CSS (no icon_url needed) */
373
+ .client-search-result[data-file-type="pdf"]::before {
374
+ content: "PDF";
375
+ background: #ff0000;
376
+ color: white;
377
+ padding: 0 0.3em;
378
+ font-size: 0.7em;
379
+ margin-right: 0.3em;
380
+ }
381
+
382
+ /* Show categories as tags */
383
+ .client-search-result[data-categories~="travel"] .title::after {
384
+ content: " ✈";
385
+ }
386
+ ```
387
+
388
+ This enables per-source icons, file-type badges, category indicators, and
389
+ tag-based styling — all through CSS, with no JavaScript changes needed.
390
+
218
391
  ## Rake tasks
219
392
 
220
393
  The gem ships reference layouts and includes that you can copy into your
@@ -343,6 +516,47 @@ Each document in the index contains:
343
516
  }
344
517
  ```
345
518
 
519
+ ## Indexing custom collections
520
+
521
+ By default only `posts` are indexed. Add any Jekyll collection to the
522
+ `collections` list to include it in the search index:
523
+
524
+ ```yaml
525
+ client_search:
526
+ collections:
527
+ - posts
528
+ - documents
529
+ ```
530
+
531
+ Each document in the index contains `id`, `title`, `url`, `excerpt`,
532
+ `content`, `categories`, `tags`, and normalized date fields. Collections
533
+ from other plugins work as long as their documents expose these fields.
534
+
535
+ ### Integration with jekyll-documents
536
+
537
+ [jekyll-documents](https://github.com/gundestrup/jekyll-documents) turns
538
+ files in `assets/documents/` into a browsable `documents` collection with
539
+ titles, categories, and searchable content baked in at build time. To
540
+ include uploaded documents in search results alongside posts:
541
+
542
+ ```yaml
543
+ # _config.yml
544
+ plugins:
545
+ - jekyll-documents
546
+ - jekyll-client-search
547
+
548
+ client_search:
549
+ collections:
550
+ - posts
551
+ - documents
552
+ related:
553
+ enabled: true
554
+ ```
555
+
556
+ No additional configuration is needed — `jekyll-documents` bakes
557
+ `categories` (plural array) and searchable `content` into each document
558
+ so the `DocumentBuilder` can index them directly.
559
+
346
560
  ## Architecture
347
561
 
348
562
  The plugin uses a base runtime + adapter architecture where the base owns
@@ -538,10 +752,16 @@ must return a DOM node (or `null` to skip the item):
538
752
  <script src="/assets/client-search-related.js"></script>
539
753
  <script>
540
754
  ClientSearchRelated.run({
541
- renderItem: function (item, document) {
542
- var li = document.createElement("li");
543
- li.innerHTML = '<a href="' + item.url + '">' + item.title + "</a>" +
544
- '<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);
545
765
  return li;
546
766
  }
547
767
  });