jekyll-client-search 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +121 -0
  3. data/LICENSE +22 -0
  4. data/NOTICE +49 -0
  5. data/README.developer.md +204 -0
  6. data/README.md +948 -0
  7. data/assets/adapters/elasticlunr.js +59 -0
  8. data/assets/adapters/minisearch.js +57 -0
  9. data/assets/adapters/semantic.js +154 -0
  10. data/assets/client-search-base.js +294 -0
  11. data/assets/client-search-related.js +176 -0
  12. data/assets/includes/related-articles.html +36 -0
  13. data/assets/layouts/post-with-related.html +54 -0
  14. data/assets/query-embedders/ollama-api.js +63 -0
  15. data/assets/query-embedders/transformers-worker.js +130 -0
  16. data/assets/query-embedders/transformers.js +223 -0
  17. data/docs/assets/icon-256.png +0 -0
  18. data/docs/assets/icon.svg +133 -0
  19. data/lib/jekyll/client_search/configuration.rb +152 -0
  20. data/lib/jekyll/client_search/configuration_accessors.rb +48 -0
  21. data/lib/jekyll/client_search/document_builder.rb +66 -0
  22. data/lib/jekyll/client_search/embedder_config_page.rb +14 -0
  23. data/lib/jekyll/client_search/embedding_configuration.rb +95 -0
  24. data/lib/jekyll/client_search/generator.rb +134 -0
  25. data/lib/jekyll/client_search/index_cache.rb +82 -0
  26. data/lib/jekyll/client_search/live_search_configuration.rb +70 -0
  27. data/lib/jekyll/client_search/ollama_embedding_adapter.rb +59 -0
  28. data/lib/jekyll/client_search/query_embedder_configuration.rb +127 -0
  29. data/lib/jekyll/client_search/related_analyzer.rb +152 -0
  30. data/lib/jekyll/client_search/related_configuration.rb +103 -0
  31. data/lib/jekyll/client_search/related_page.rb +14 -0
  32. data/lib/jekyll/client_search/related_tag.rb +76 -0
  33. data/lib/jekyll/client_search/runtime_config_page.rb +30 -0
  34. data/lib/jekyll/client_search/search_index_page.rb +15 -0
  35. data/lib/jekyll/client_search/search_tag.rb +100 -0
  36. data/lib/jekyll/client_search/tasks.rb +137 -0
  37. data/lib/jekyll/client_search/version.rb +7 -0
  38. data/lib/jekyll/client_search.rb +25 -0
  39. data/lib/jekyll-client-search.rb +3 -0
  40. metadata +104 -0
data/README.md ADDED
@@ -0,0 +1,948 @@
1
+ # jekyll-client-search
2
+
3
+ ![jekyll-client-search logo](docs/assets/icon-256.png)
4
+
5
+ [![CI](https://github.com/gundestrup/jekyll-client-search/actions/workflows/ci.yml/badge.svg)](https://github.com/gundestrup/jekyll-client-search/actions/workflows/ci.yml)
6
+ [![Ruby](https://img.shields.io/badge/ruby-%E2%89%A5%203.2-red.svg)](https://www.ruby-lang.org/)
7
+ [![Jekyll](https://img.shields.io/badge/jekyll-4.x-blue.svg)](https://jekyllrb.com/)
8
+ [![License: AGPL v3](https://img.shields.io/badge/license-AGPL--3.0--or--later-blue.svg)](LICENSE)
9
+ [![DeepWiki](https://img.shields.io/badge/DeepWiki-docs-7B68EE.svg)](https://deepwiki.com/gundestrup/jekyll-client-search)
10
+ [![Coverage](https://img.shields.io/badge/coverage-100%25%20branches-brightgreen.svg)](#testing-strategy)
11
+
12
+ A Jekyll plugin that generates a JSON document index for client-side search.
13
+ Supports lexical search ([MiniSearch](https://lucaong.github.io/minisearch/),
14
+ [ElasticLunr](https://github.com/weixsong/elasticlunr.js)) and semantic search
15
+ via pre-computed embeddings from a local [Ollama](https://ollama.ai) server.
16
+ Related articles can be generated at build time from shared tags, categories,
17
+ and vector similarity.
18
+
19
+ ## Table of contents
20
+
21
+ - [Lightning start](#lightning-start)
22
+ - [Quick start](#quick-start)
23
+ - [Choose an engine](#choose-an-engine)
24
+ - [Live search](#live-search)
25
+ - [Related articles](#related-articles)
26
+ - [Semantic search (embeddings)](#semantic-search-embeddings)
27
+ - [The search form](#the-search-form)
28
+ - [Rake tasks](#rake-tasks)
29
+ - [Configuration reference](#configuration-reference)
30
+ - [Architecture](#architecture)
31
+ - [Embeddings and incremental indexing](#embeddings-and-incremental-indexing)
32
+ - [Related articles reference](#related-articles-reference)
33
+ - [Browser usage reference](#browser-usage-reference)
34
+ - [Keeping the search engine current](#keeping-the-search-engine-current)
35
+ - [License](#license)
36
+ - [Development](#development)
37
+ - [Build and version tasks](#build-and-version-tasks)
38
+ - [CI and release process](#ci-and-release-process)
39
+ - [Documentation](#documentation)
40
+
41
+ ## Lightning start
42
+
43
+ Three steps. No configuration needed — the defaults work out of the box.
44
+
45
+ **1. Add the gem:**
46
+
47
+ ```ruby
48
+ # Gemfile
49
+ group :jekyll_plugins do
50
+ gem "jekyll-client-search", "~> 0.1"
51
+ end
52
+ ```
53
+
54
+ ```yaml
55
+ # _config.yml
56
+ plugins:
57
+ - jekyll-client-search
58
+ ```
59
+
60
+ **2. Add the search form to any page or layout:**
61
+
62
+ ```liquid
63
+ {% search_form %}
64
+ ```
65
+
66
+ That's it. The tag outputs the form, results container, and all scripts —
67
+ config-driven, so you never need to touch template HTML when changing engines.
68
+
69
+ **3. (Optional) Add related articles to your post layout:**
70
+
71
+ ```liquid
72
+ {% related_articles %}
73
+ ```
74
+
75
+ Run `bundle install && bundle exec jekyll build` and search works at
76
+ `/search-index.json` with MiniSearch + live search enabled by default.
77
+
78
+ > **What you get by default:** MiniSearch engine, live search on (results
79
+ > update as you type), related articles off, embeddings off. No Ollama or
80
+ > external services required. See [Quick start](#quick-start) to customize.
81
+
82
+ ## Quick start
83
+
84
+ The defaults are designed for zero-config lexical search. Customize by
85
+ adding a `client_search:` section to `_config.yml`.
86
+
87
+ ### Choose an engine
88
+
89
+ | Engine | Type | Default? | Needs Ollama? | Good for |
90
+ | --- | --- | --- | --- | --- |
91
+ | `minisearch` | Lexical | Yes | No | Most sites — fast, prefix search, fuzzy fallback |
92
+ | `elasticlunr` | Lexical | No | No | Sites already using ElasticLunr |
93
+ | `semantic` | Vector | No | Yes (build time) | Concept search across dissimilar vocabulary |
94
+
95
+ ```yaml
96
+ client_search:
97
+ engine: minisearch # default — change to elasticlunr or semantic
98
+ ```
99
+
100
+ The `{% search_form %}` tag automatically loads the right CDN URL and
101
+ adapter for the selected engine. See [Configuration reference](#configuration-reference)
102
+ for `engine_url`, `engine_sri`, and self-hosting options.
103
+
104
+ ### Live search
105
+
106
+ Live search (results update as the visitor types) is **on by default** for
107
+ lexical engines and **off by default** for semantic (to avoid embedding
108
+ every keystroke). Form submission always works regardless.
109
+
110
+ ```yaml
111
+ client_search:
112
+ live_search:
113
+ enabled: true # default: true for lexical, false for semantic
114
+ min_chars: 2 # minimum query length before live search fires
115
+ debounce_ms: 150 # lexical debounce
116
+ semantic_debounce_ms: 500 # semantic debounce
117
+ update_url: true # sync ?q= in the URL
118
+ ```
119
+
120
+ See [Configuration reference](#configuration-reference) for all options.
121
+
122
+ ### Related articles
123
+
124
+ Related articles are **off by default** (they require a separate build-time
125
+ analysis pass). Enable them to generate a `search-relations.json` file and
126
+ use the `{% related_articles %}` tag:
127
+
128
+ ```yaml
129
+ client_search:
130
+ related:
131
+ enabled: true
132
+ minimum_similarity: 0.55 # cosine cutoff for semantic relations
133
+ ```
134
+
135
+ Then add one line to any post layout:
136
+
137
+ ```liquid
138
+ {% related_articles %}
139
+ ```
140
+
141
+ Without embeddings, relations are based on shared tags, categories, and
142
+ hierarchical parent domains. With embeddings enabled, vector similarity
143
+ above the cutoff is also included. See
144
+ [Related articles reference](#related-articles-reference) for rendering
145
+ options, custom callbacks, and filtering.
146
+
147
+ ### Semantic search (embeddings)
148
+
149
+ Semantic search requires a local [Ollama](https://ollama.ai/) server during
150
+ Jekyll build to generate document embeddings. The browser query model runs
151
+ via transformers.js (default) or an Ollama-compatible API endpoint.
152
+
153
+ ```yaml
154
+ client_search:
155
+ engine: semantic
156
+ embedding:
157
+ enabled: true
158
+ model: embeddinggemma:300m
159
+ base_url: http://localhost:11434
160
+ ```
161
+
162
+ See [Embeddings and incremental indexing](#embeddings-and-incremental-indexing)
163
+ for setup, model choices, caching, and browser embedder configuration.
164
+
165
+ ## The search form
166
+
167
+ The `{% search_form %}` Liquid tag is the simplest way to add search. It
168
+ outputs the form HTML, status/results containers, and all runtime scripts
169
+ in the correct order — config-driven, so changing engines in `_config.yml`
170
+ requires zero template changes.
171
+
172
+ ```liquid
173
+ ---
174
+ title: Search
175
+ permalink: /search/
176
+ ---
177
+
178
+ {% search_form %}
179
+ ```
180
+
181
+ Tag modes:
182
+
183
+ | Syntax | Effect |
184
+ | --- | --- |
185
+ | `{% search_form %}` | Form HTML + all scripts (default) |
186
+ | `{% search_form scripts_only %}` | Just the scripts — use with custom form HTML |
187
+ | `{% search_form no_scripts %}` | Just the form HTML — load scripts yourself |
188
+
189
+ When `client_search.enabled` is false the tag renders nothing, so it is safe
190
+ to leave in a layout even when the plugin is off.
191
+
192
+ **Custom form with config-driven scripts:**
193
+
194
+ ```liquid
195
+ <form id="search-form" class="my-search" role="search">
196
+ <input id="search-query" type="search" name="q" placeholder="Search articles">
197
+ <button type="submit">Search</button>
198
+ </form>
199
+ <div id="search-status" aria-live="polite"></div>
200
+ <div id="search-results"></div>
201
+
202
+ {% search_form scripts_only %}
203
+ ```
204
+
205
+ **Self-hosting the engine library:**
206
+
207
+ ```yaml
208
+ client_search:
209
+ engine: minisearch
210
+ engine_url: /assets/vendor/minisearch.min.js
211
+ engine_sri: sha384-... # optional integrity hash
212
+ engine_crossorigin: anonymous # optional
213
+ ```
214
+
215
+ For manual `<script>` setup (engine-specific HTML), see
216
+ [Browser usage reference](#browser-usage-reference).
217
+
218
+ ## Rake tasks
219
+
220
+ The gem ships reference layouts and includes that you can copy into your
221
+ site as starting points. Two rake tasks make this easy:
222
+
223
+ ```bash
224
+ # List reference files, show status, and diff against installed copies:
225
+ bundle exec rake jekyll_client_search:reference_files
226
+
227
+ # Install or update reference files (skips modified copies):
228
+ bundle exec rake jekyll_client_search:install
229
+
230
+ # Overwrite modified copies with the latest gem versions:
231
+ bundle exec rake 'jekyll_client_search:install[true]'
232
+ ```
233
+
234
+ The `reference_files` task shows whether each file is `up to date`,
235
+ `modified or outdated`, or `not installed`, and prints a diff when copies
236
+ differ. The `install` task copies the latest versions from the gem, but
237
+ **skips files you have modified** unless you pass `overwrite=true`.
238
+
239
+ After `bundle update jekyll-client-search`, run `reference_files` to check
240
+ if your copies are outdated, then `install` to update unmodified copies.
241
+
242
+ > **Upgrade-safe alternative:** the `{% search_form %}` and
243
+ > `{% related_articles %}` Liquid tags live in the gem and auto-update with
244
+ > zero user action. The reference files are for users who want full control
245
+ > over the HTML.
246
+
247
+ ## Configuration reference
248
+
249
+ ```yaml
250
+ client_search:
251
+ enabled: true
252
+ engine: minisearch
253
+ engine_url: null # null = per-engine CDN default
254
+ engine_sri: null # optional Subresource Integrity hash
255
+ engine_crossorigin: null # optional crossorigin attribute
256
+ output: search-index.json
257
+ collections:
258
+ - posts
259
+ include_pages: false
260
+ copy_runtime: true
261
+ live_search:
262
+ enabled: true # default: true for lexical, false for semantic
263
+ min_chars: 2
264
+ debounce_ms: 150
265
+ semantic_debounce_ms: 500
266
+ update_url: true
267
+ related:
268
+ enabled: false
269
+ output: search-relations.json
270
+ minimum_similarity: 0.55
271
+ max_items: null
272
+ embedding:
273
+ enabled: false
274
+ model: embeddinggemma:300m
275
+ base_url: http://localhost:11434
276
+ connect_timeout: 5
277
+ read_timeout: 120
278
+ fail_on_error: true
279
+ query_embedder:
280
+ type: transformers
281
+ ```
282
+
283
+ All options:
284
+
285
+ | Option | Default | Description |
286
+ | --- | --- | --- |
287
+ | `enabled` | `true` | Enable or disable index generation. |
288
+ | `engine` | `minisearch` | Search engine: `minisearch`, `elasticlunr`, or `semantic`. |
289
+ | `engine_url` | Per-engine CDN | Browser engine library URL. Override to self-host or pin a version. `null` for semantic. |
290
+ | `engine_sri` | `null` | Optional Subresource Integrity hash for the engine library. |
291
+ | `engine_crossorigin` | `null` | Optional `crossorigin` attribute for the engine library. |
292
+ | `output` | `search-index.json` | Output path for the generated JSON index. |
293
+ | `collections` | `[posts]` | Jekyll collections to index. |
294
+ | `include_pages` | `false` | Include titled Jekyll pages in the index. |
295
+ | `copy_runtime` | `true` | Copy the base runtime and engine adapter into the site. |
296
+ | `live_search.enabled` | `true` (lexical) / `false` (semantic) | Search while the user types. Form submission always remains available. |
297
+ | `live_search.min_chars` | `2` | Minimum trimmed query length before live search runs. |
298
+ | `live_search.debounce_ms` | `150` | MiniSearch/ElasticLunr input debounce. |
299
+ | `live_search.semantic_debounce_ms` | `500` | Semantic input debounce. |
300
+ | `live_search.update_url` | `true` | Keep the `q` URL parameter synchronized while typing. |
301
+ | `related.enabled` | `false` | Generate a separate related-article JSON file. |
302
+ | `related.output` | `search-relations.json` | Output path for related-article data. |
303
+ | `related.same_category` | `true` | Link articles sharing an exact category. |
304
+ | `related.shared_tags` | `true` | Link articles sharing one or more tags. |
305
+ | `related.include_parent_domains` | `true` | Link articles sharing parent paths in hierarchical categories. |
306
+ | `related.semantic` | `true` | Include vector relations when document embeddings are available. |
307
+ | `related.minimum_similarity` | `0.55` | Cosine cutoff for semantic relations; no fixed relation count. |
308
+ | `related.max_items` | `null` | Optional safety cap after cutoff; `null` keeps every matching relation. |
309
+ | `embedding.enabled` | `false` | Generate embedding vectors at build time via Ollama. |
310
+ | `embedding.model` | `embeddinggemma:300m` | Ollama model name for embedding generation. |
311
+ | `embedding.base_url` | `http://localhost:11434` | Ollama server URL. |
312
+ | `embedding.connect_timeout` | `5` | Ollama connection timeout in seconds. |
313
+ | `embedding.read_timeout` | `120` | Ollama response timeout in seconds. |
314
+ | `embedding.fail_on_error` | `true` | Fail the build when an embedding cannot be generated. |
315
+ | `embedding.include_in_index` | Semantic engine only | Keep document vectors in `search-index.json`; lexical related-analysis builds can omit them. |
316
+ | `embedding.document_prefix` | Model-specific | Prefix applied to document text before build-time embedding. |
317
+ | `embedding.query_prefix` | Model-specific | Prefix applied to browser queries before embedding. |
318
+ | `embedding.query_embedder.type` | `transformers` | Query embedder: `transformers`, `ollama_api`, or `none`. |
319
+ | `embedding.query_embedder.model` | Model-specific | Browser model ID or Ollama API model. Required when no safe mapping exists. |
320
+ | `embedding.query_embedder.api_url` | `<base_url>/api/embed` | Ollama-compatible query API endpoint. |
321
+ | `embedding.query_embedder.library_url` | jsDelivr `3.8.1` | Exact transformers.js ESM URL; set to self-host. |
322
+ | `embedding.query_embedder.model_base_url` | Hugging Face Hub | Self-hosted transformers.js model base URL. |
323
+ | `embedding.query_embedder.wasm_base_url` | ONNX default CDN | Self-hosted ONNX Runtime WASM directory. |
324
+ | `embedding.query_embedder.worker_url` | Packaged worker | Override the Transformers Web Worker URL. |
325
+ | `embedding.query_embedder.device` | WASM default | Optional device such as `webgpu` or `wasm`. |
326
+ | `embedding.query_embedder.dtype` | `q8` | Browser model data type. |
327
+ | `embedding.query_embedder.worker` | `true` | Run tokenization and model inference off the main UI thread. |
328
+ | `embedding.query_embedder.timeout_ms` | `300000` / `30000` | Transformers / Ollama API timeout. |
329
+ | `embedding.query_embedder.retry_attempts` | `1` | Model/runtime load retries after the initial attempt. |
330
+ | `embedding.query_embedder.max_tokens` | `512` | Maximum browser query token count. |
331
+
332
+ Each document in the index contains:
333
+
334
+ ```json
335
+ {
336
+ "id": "/article/",
337
+ "title": "Article title",
338
+ "url": "/article/",
339
+ "excerpt": "Short excerpt",
340
+ "content": "Searchable article content",
341
+ "categories": ["family", "travel"],
342
+ "tags": ["greenland"]
343
+ }
344
+ ```
345
+
346
+ ## Architecture
347
+
348
+ The plugin uses a base runtime + adapter architecture where the base owns
349
+ the search strategy and each adapter is a pure translator:
350
+
351
+ - **`assets/client-search-base.js`** — engine-agnostic shell that handles form
352
+ wiring, index fetching, document normalization, DOM rendering, same-origin
353
+ URL safety, optional debounced live search, status updates, and the two-stage
354
+ search strategy (AND first, fuzzy OR fallback).
355
+ - **`assets/adapters/minisearch.js`** — translates the uniform query into
356
+ MiniSearch's native API.
357
+ - **`assets/adapters/elasticlunr.js`** — translates the uniform query into
358
+ ElasticLunr's native API.
359
+ - **`assets/adapters/semantic.js`** — validates vectors and ranks document
360
+ embeddings by cosine similarity.
361
+ - **`assets/query-embedders/transformers.js`** and
362
+ **`transformers-worker.js`** — load and run the browser query model outside
363
+ the main UI thread.
364
+ - **`assets/query-embedders/ollama-api.js`** — calls a remote
365
+ Ollama-compatible endpoint with timeout and stale-request cancellation.
366
+
367
+ Each adapter implements a small translator interface:
368
+
369
+ | Method | Description |
370
+ | --- | --- |
371
+ | `name` | Engine identifier string. |
372
+ | `available()` | Returns `true` when the engine library is loaded. |
373
+ | `buildIndex(documents)` | Builds and returns an engine-specific index. |
374
+ | `search(index, query, options)` | Translates the uniform query `{ combineWith, fuzzy, prefix }` into the engine's native call and returns `[{ ref, score }]`. |
375
+
376
+ The base runtime owns the strategy. The adapter only translates. The
377
+ `semantic` adapter is a working example — it uses cosine similarity against
378
+ pre-computed embeddings rather than the AND/OR text strategy.
379
+
380
+ ## Embeddings and incremental indexing
381
+
382
+ When `embedding.enabled: true`, the plugin generates embedding vectors at
383
+ Jekyll build time using a local [Ollama](https://ollama.ai/) server and the
384
+ [`ollama-ruby`](https://github.com/flori/ollama-ruby) gem.
385
+
386
+ ### Setup
387
+
388
+ 1. Install [Ollama](https://ollama.ai/) and pull an embedding model:
389
+ ```bash
390
+ ollama pull embeddinggemma:300m
391
+ ```
392
+ 2. Add `ollama-ruby` to your site's Gemfile (optional dependency):
393
+ ```ruby
394
+ gem "ollama-ruby", "~> 1.23"
395
+ ```
396
+ 3. Enable embeddings in `_config.yml`:
397
+ ```yaml
398
+ client_search:
399
+ engine: semantic
400
+ embedding:
401
+ enabled: true
402
+ model: embeddinggemma:300m
403
+ base_url: http://localhost:11434
404
+ ```
405
+
406
+ ### How it works
407
+
408
+ - At build time, the `OllamaEmbeddingAdapter` sends each document's text
409
+ (title + excerpt + content) to the Ollama server and receives a float
410
+ vector. Model-specific document prefixes are applied automatically.
411
+ - The vector is stored in the `embedding` field of the JSON document.
412
+ - At search time, either transformers.js runs the compatible model in the
413
+ browser or the browser calls an Ollama-compatible `/api/embed` endpoint.
414
+ The matching model-specific query prefix is applied automatically.
415
+ - A content-hash cache (`.jekyll-client-search-cache.json` in the site
416
+ source) tracks which documents have already been embedded. Unchanged
417
+ documents reuse the cached embedding — only new or modified documents
418
+ are sent to the model.
419
+ - Cache entries include the provider, model, endpoint, and cache schema. A
420
+ model or embedding configuration change automatically invalidates old
421
+ vectors and re-embeds the documents.
422
+ - Cache writes are atomic. The cache file is git-ignored and safe to delete
423
+ (it will be rebuilt on the next `jekyll build`).
424
+ - By default, an embedding failure fails the build rather than silently
425
+ producing an unusable semantic index. Set `embedding.fail_on_error: false`
426
+ only when warning-only behavior is intentional.
427
+
428
+ ### Choosing a model
429
+
430
+ Any Ollama-compatible embedding model works. Common choices:
431
+
432
+ | Model | Dimensions | Good for |
433
+ | --- | --- | --- |
434
+ | `embeddinggemma:300m` | 768 | Default, multilingual, good quality |
435
+ | `nomic-embed-text` | 768 | Multilingual, good quality |
436
+ | `bge-m3` | 1024 | Multilingual, high quality |
437
+ | `all-minilm` | 384 | English, fast, lightweight |
438
+
439
+ The model used at build time must match the query model's weights, vector
440
+ space, dimensions, and preprocessing. Cross-runtime compatibility is verified
441
+ for the default `embeddinggemma:300m` mapping. An `all-minilm` mapping is also
442
+ provided, but sites should independently verify ranking when changing models.
443
+ For any other Ollama model, set `embedding.query_embedder.model` to a
444
+ compatible Transformers.js model explicitly or use `ollama_api` so both paths
445
+ call the same Ollama model.
446
+
447
+ ## Related articles reference
448
+
449
+ Set `related.enabled: true` to generate a separate `search-relations.json`
450
+ file. The file contains all matching relations for each article, excluding
451
+ the article itself. Exact shared tags, categories, and hierarchical parent
452
+ domains are included without an arbitrary count limit. When document
453
+ embeddings are available, vector relations are added when their cosine score
454
+ meets `related.minimum_similarity`. If `embedding.enabled` is also true
455
+ for a MiniSearch build, embeddings are used during analysis and omitted from
456
+ the public search index by default. Set `embedding.include_in_index: true` if
457
+ the vectors are needed by another consumer.
458
+
459
+ Relations include their score, shared tags, shared categories, shared parent
460
+ domains, reasons, title, URL, and publication timestamp. The relation score
461
+ indicates relatedness; it does not assert a causal or factual relationship.
462
+
463
+ A working demo page exercising every adoption path lives in the fixture site
464
+ at `spec/fixtures/site/related-test.html` (built at `/related-test/`). The
465
+ fixture site also has `_layouts/post.html` showing the `{% related_articles %}`
466
+ tag in a real post layout. See [README.developer.md](README.developer.md) for
467
+ fixture site setup instructions.
468
+
469
+ ### Liquid tag
470
+
471
+ The `{% related_articles %}` tag renders the container, sort control, and
472
+ runtime scripts in one line:
473
+
474
+ ```liquid
475
+ {{ content }}
476
+
477
+ {% related_articles %}
478
+ ```
479
+
480
+ When `related.enabled` is false the tag renders nothing, so it is safe to
481
+ leave in a layout even when the feature is off.
482
+
483
+ | Syntax | Effect |
484
+ | --- | --- |
485
+ | `{% related_articles %}` | Default sort (relevance), includes scripts |
486
+ | `{% related_articles sort:date %}` | Default sort is newest-first |
487
+ | `{% related_articles no_scripts %}` | Render only the container; load scripts yourself |
488
+
489
+ ### Include file and drop-in layout
490
+
491
+ The gem ships reference files you can copy via
492
+ [rake tasks](#rake-tasks):
493
+
494
+ ```bash
495
+ bundle exec rake jekyll_client_search:install
496
+ ```
497
+
498
+ This copies `_includes/related-articles.html` and
499
+ `_layouts/post-with-related.html` into your site. Then use either:
500
+
501
+ ```liquid
502
+ {% include related-articles.html %}
503
+ ```
504
+
505
+ or set `layout: post-with-related` in a post's front matter. Most sites
506
+ already have a post layout they like — in that case, just add the
507
+ `{% related_articles %}` tag to your existing layout instead.
508
+
509
+ ### Default rendering
510
+
511
+ The packaged `client-search-related.js` helper renders each relation as a
512
+ list item with a link, publication date, shared tags, shared categories, and
513
+ excerpt (when those fields are present in the JSON):
514
+
515
+ ```html
516
+ <h2>Related articles</h2>
517
+ <ul class="related-articles-list">
518
+ <li class="related-article-item">
519
+ <a class="related-article-link" href="/article/">Article title</a>
520
+ <div class="related-article-meta">
521
+ <span class="related-article-date">Jan 1, 2026</span>
522
+ <span class="related-article-tags">greenland, travel</span>
523
+ </div>
524
+ <p class="related-article-excerpt">Short excerpt...</p>
525
+ </li>
526
+ </ul>
527
+ ```
528
+
529
+ ### Custom render callback
530
+
531
+ Pass a `renderItem` function to override the default list item rendering.
532
+ The callback receives the relation object and the global `document`, and
533
+ must return a DOM node (or `null` to skip the item):
534
+
535
+ ```html
536
+ <div id="related-articles"></div>
537
+ <script src="/assets/search-runtime-config.js"></script>
538
+ <script src="/assets/client-search-related.js"></script>
539
+ <script>
540
+ 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>";
545
+ return li;
546
+ }
547
+ });
548
+ </script>
549
+ ```
550
+
551
+ ### Filtering relations
552
+
553
+ Pass a `filter` function to narrow which relations appear. The callback
554
+ follows `Array.prototype.filter` semantics — return truthy to keep, falsy
555
+ to drop:
556
+
557
+ ```html
558
+ <script>
559
+ // Only show relations with a semantic similarity above 0.7
560
+ ClientSearchRelated.run({
561
+ filter: function (item) {
562
+ return item.semantic_similarity && item.semantic_similarity > 0.7;
563
+ }
564
+ });
565
+ </script>
566
+ ```
567
+
568
+ ### Reading the raw JSON from Liquid
569
+
570
+ The `search-relations.json` file is a static Jekyll page. You can read it
571
+ at build time with a small generator if you need server-side rendering. The
572
+ file is written to the site destination, so it is available as a static file
573
+ after the build:
574
+
575
+ ```ruby
576
+ # _plugins/related_renderer.rb
577
+ module RelatedRenderer
578
+ class Generator < Jekyll::Generator
579
+ priority :low
580
+ def generate(site)
581
+ related_page = site.pages.find { |p| p.url == "/search-relations.json" }
582
+ return unless related_page
583
+
584
+ data = JSON.parse(related_page.content)
585
+ site.posts.docs.each do |doc|
586
+ relations = data["relations"] && data["relations"][doc.url]
587
+ next unless relations
588
+
589
+ doc.data["related_articles"] = relations.first(5)
590
+ end
591
+ end
592
+ end
593
+ end
594
+ ```
595
+
596
+ Then in a layout:
597
+
598
+ ```liquid
599
+ {% if page.related_articles and page.related_articles.size > 0 %}
600
+ <aside class="related">
601
+ <h2>Related articles</h2>
602
+ <ul>
603
+ {% for item in page.related_articles %}
604
+ <li>
605
+ <a href="{{ item.url }}">{{ item.title }}</a>
606
+ {% if item.shared_tags %}<span>{{ item.shared_tags | join: ", " }}</span>{% endif %}
607
+ </li>
608
+ {% endfor %}
609
+ </ul>
610
+ </aside>
611
+ {% endif %}
612
+ ```
613
+
614
+ Note: this approach requires a custom generator plugin in the consuming
615
+ site, and the related data is baked into the HTML at build time (no
616
+ client-side sort switching). For client-side sorting, use the JS helper
617
+ instead.
618
+
619
+ ### Manual HTML setup
620
+
621
+ If you prefer not to use the Liquid tag or include file, add the container
622
+ and scripts directly:
623
+
624
+ ```html
625
+ <label for="related-sort">Sort related articles</label>
626
+ <select id="related-sort">
627
+ <option value="relevance">Most related</option>
628
+ <option value="date">Newest</option>
629
+ </select>
630
+ <div id="related-articles"></div>
631
+ <script src="/assets/search-runtime-config.js"></script>
632
+ <script src="/assets/client-search-related.js"></script>
633
+ ```
634
+
635
+ The helper sorts by relevance by default. A site can request newest-first
636
+ before loading the helper:
637
+
638
+ ```html
639
+ <script>
640
+ window.clientSearchConfig = Object.assign(window.clientSearchConfig || {}, {
641
+ relatedSort: "date"
642
+ });
643
+ </script>
644
+ <script src="/assets/client-search-related.js"></script>
645
+ ```
646
+
647
+ ## Browser usage reference
648
+
649
+ The generated JSON is engine-neutral. A consuming site can either use the
650
+ packaged runtime or build its own index directly from the JSON. Add a
651
+ `#search-sort` select with `relevance` and `date` values if visitors should
652
+ choose between highest search relation and newest publication date; the base
653
+ runtime sorts either lexical or semantic results consistently.
654
+
655
+ For the simplest setup, use the [`{% search_form %}` tag](#the-search-form).
656
+ The sections below show the manual `<script>` setup for each engine.
657
+
658
+ ### MiniSearch
659
+
660
+ ```html
661
+ <form id="search-form">
662
+ <input id="search-query" type="search" name="q">
663
+ <button type="submit">Search</button>
664
+ </form>
665
+ <div id="search-status" aria-live="polite"></div>
666
+ <div id="search-results"></div>
667
+ <script src="https://cdn.jsdelivr.net/npm/minisearch@7.2.0/dist/umd/index.min.js"
668
+ crossorigin="anonymous"></script>
669
+ <script src="/assets/search-runtime-config.js"></script>
670
+ <script src="/assets/client-search-base.js"></script>
671
+ <script src="/assets/adapters/minisearch.js"></script>
672
+ ```
673
+
674
+ ### ElasticLunr
675
+
676
+ ```html
677
+ <form id="search-form">
678
+ <input id="search-query" type="search" name="q">
679
+ <button type="submit">Search</button>
680
+ </form>
681
+ <div id="search-status" aria-live="polite"></div>
682
+ <div id="search-results"></div>
683
+ <script src="https://cdn.jsdelivr.net/npm/elasticlunr@0.9.5/elasticlunr.min.js"
684
+ crossorigin="anonymous"></script>
685
+ <script src="/assets/search-runtime-config.js"></script>
686
+ <script src="/assets/client-search-base.js"></script>
687
+ <script src="/assets/adapters/elasticlunr.js"></script>
688
+ ```
689
+
690
+ ### Semantic
691
+
692
+ With the default `query_embedder.type: transformers`, the query model runs
693
+ entirely in the visitor's browser through transformers.js. Ollama is needed
694
+ only while Jekyll builds the document vectors. The first query downloads the
695
+ q8 browser model (about 325 MB for EmbeddingGemma); the browser caches it.
696
+
697
+ ```html
698
+ <script src="/assets/search-runtime-config.js"></script>
699
+ <script src="/assets/search-embedder-config.js"></script>
700
+ <script src="/assets/query-embedders/transformers.js"></script>
701
+ <script src="/assets/client-search-base.js"></script>
702
+ <script src="/assets/adapters/semantic.js"></script>
703
+ ```
704
+
705
+ The default library is loaded from jsDelivr and the model from Hugging Face.
706
+ To keep all runtime files on the static site, self-host both:
707
+
708
+ ```yaml
709
+ client_search:
710
+ engine: semantic
711
+ embedding:
712
+ enabled: true
713
+ model: embeddinggemma:300m
714
+ query_embedder:
715
+ type: transformers
716
+ library_url: /assets/vendor/transformers.min.js
717
+ model_base_url: /assets/models/
718
+ wasm_base_url: /assets/vendor/onnx-wasm/
719
+ model: onnx-community/embeddinggemma-300m-ONNX
720
+ dtype: q8
721
+ worker: true
722
+ ```
723
+
724
+ Self-hosting still requires no application server or background LLM. The
725
+ static web server delivers JavaScript, WASM, and ONNX files; inference runs
726
+ inside the packaged Web Worker. WASM is the compatibility-first default; set
727
+ `device: webgpu` explicitly to request WebGPU. Preserve the Hugging Face model
728
+ directory layout below `model_base_url`. Model weights retain their own
729
+ license. The default EmbeddingGemma model is subject to the Gemma Terms of
730
+ Use.
731
+
732
+ The CDN default is pinned to transformers.js `3.8.1`. Strict production CSP
733
+ or offline deployments should self-host all three dependencies: the ESM
734
+ library, ONNX model, and ONNX Runtime WASM files. Configure `script-src`,
735
+ `worker-src`, and `connect-src` for their actual origins; WASM execution may
736
+ also require `'wasm-unsafe-eval'`. Multi-threaded WASM requires compatible
737
+ COOP/COEP and CORP headers. Without cross-origin isolation, ONNX Runtime uses
738
+ a compatible single-threaded path.
739
+
740
+ To call a running Ollama-compatible API instead:
741
+
742
+ ```yaml
743
+ client_search:
744
+ engine: semantic
745
+ embedding:
746
+ enabled: true
747
+ model: embeddinggemma:300m
748
+ query_embedder:
749
+ type: ollama_api
750
+ api_url: https://embeddings.example.com/api/embed
751
+ ```
752
+
753
+ Then load `/assets/query-embedders/ollama-api.js` in place of the
754
+ transformers script. The endpoint must support CORS. Obsolete live-search
755
+ requests are aborted, and requests time out after 30 seconds by default. Do
756
+ not expose an unauthenticated Ollama server directly to the public internet.
757
+ A localhost URL refers to each visitor's computer, so it only works when that
758
+ visitor is running Ollama locally.
759
+
760
+ Set `query_embedder.type: none` to continue providing a custom
761
+ `window.ClientSearchQueryEmbedder`. The function may be synchronous or
762
+ asynchronous. Up to 100 recent query embeddings are cached for the lifetime
763
+ of the page. Rejected or invalid vectors make search unavailable rather than
764
+ presenting a false zero-result response.
765
+
766
+ ### Two-stage search strategy
767
+
768
+ The base runtime owns the two-stage strategy, applied uniformly across all
769
+ text adapters:
770
+
771
+ 1. **Exact AND search** with prefix matching and field boosting — returns only
772
+ documents matching all query terms.
773
+ 2. **Fuzzy OR fallback** — if the AND search returns no results, retries with
774
+ relaxed matching for typo tolerance.
775
+
776
+ ## Keeping the search engine current
777
+
778
+ The gem deliberately does not bundle or build the search engine library. The
779
+ consuming site owns the browser dependency and loads a pinned version. This
780
+ keeps the Ruby gem independent from the JavaScript release cycle.
781
+
782
+ Recommended policy:
783
+
784
+ 1. Pin an exact engine version in the consuming site.
785
+ 2. Add a Subresource Integrity hash when loading from a CDN.
786
+ 3. Review the engine's release notes and documentation before upgrading.
787
+ 4. Run the consuming site's search and integration checks after an upgrade.
788
+ 5. Prefer a local copy when a site needs offline builds or a strict CSP.
789
+
790
+ ## License
791
+
792
+ This project is licensed under the GNU Affero General Public License v3.0 or
793
+ later (`AGPL-3.0-or-later`). See [LICENSE](LICENSE).
794
+
795
+ ## Development
796
+
797
+ Development uses Ruby 3.4.10 through rbenv, while the gem supports Ruby 3.2
798
+ and newer. The development version is stored in `.ruby-version`, and CI tests
799
+ Ruby 3.2, 3.3, and 3.4.
800
+
801
+ ```bash
802
+ rbenv install 3.4.10 # if not already installed
803
+ rbenv local 3.4.10
804
+ bundle install
805
+ npm ci
806
+ bundle exec rake ci
807
+ ```
808
+
809
+ ### Testing strategy
810
+
811
+ The test suite uses **80 real-world source posts** as fixture content to ensure
812
+ realistic search behavior testing:
813
+
814
+ - **40 Wikipedia articles** (CC BY-SA 3.0) covering arctic/geography,
815
+ climbing/sports, photography/optics, food/cooking, and technology topics.
816
+ Downloaded via the Wikipedia API with source attribution, download date,
817
+ and license recorded in each post's frontmatter.
818
+ - **40 unique arXiv papers** (arXiv non-exclusive license) covering information
819
+ retrieval, NLP, computer vision, recommendation systems, and knowledge
820
+ graphs. The regeneration script deduplicates paper IDs across category
821
+ queries.
822
+
823
+ The articles have deliberate cross-topic vocabulary overlap (e.g., "ice"
824
+ appears in both glacier articles and climbing articles; "embeddings" appears
825
+ in both IR and NLP papers) to test search discrimination between related but
826
+ distinct topics.
827
+
828
+ **Test layers:**
829
+
830
+ | Layer | What it tests | Command |
831
+ | --- | --- | --- |
832
+ | Ruby unit specs | Configuration, cache, adapter, document builder | `bundle exec rspec --tag unit` |
833
+ | Ruby system specs | Jekyll build generates correct JSON from 80 posts | `bundle exec rspec --tag system` |
834
+ | JS unit tests | Adapter, query-embedder, and related-renderer behavior in jsdom | `node --test test/runtime.test.js test/query-embedders.test.js test/related.test.js` |
835
+ | JS system tests | Search results against the committed 80-post baseline in jsdom | `node --test test/system.test.js` |
836
+ | Ollama integration | Real embedding generation + cache reuse against local Ollama | `OLLAMA_INTEGRATION=1 bundle exec rspec --tag ollama_integration` |
837
+
838
+ **Test boundaries:**
839
+
840
+ - Normal CI executes Ruby unit/system tests and all lexical/semantic browser
841
+ tests using committed baselines.
842
+ - Real Ollama generation remains opt-in because it requires a local service and
843
+ model; committed vectors keep semantic ranking deterministic in normal CI.
844
+ - The packaged transformers.js and Ollama API query embedders are tested with
845
+ controlled runtimes. The default q8 Transformers.js EmbeddingGemma vectors
846
+ were also compared with Ollama vectors for the same prefixed inputs; both
847
+ query and document vectors had cross-runtime cosine similarity above 0.995.
848
+ - Custom `ClientSearchQueryEmbedder` implementations remain the consuming
849
+ site's integration responsibility.
850
+ - Fixture downloader scripts are syntax-checked but are not run in CI because
851
+ they perform network requests and replace local fixture posts.
852
+
853
+ **Regenerating fixture posts:**
854
+
855
+ ```bash
856
+ ruby spec/fixtures/download_wikipedia.rb # 40 Wikipedia articles
857
+ ruby spec/fixtures/download_arxiv.rb # 40 unique arXiv papers
858
+ bundle exec ruby spec/fixtures/generate_baseline.rb
859
+ OLLAMA_INTEGRATION=1 bundle exec rspec spec/ollama_integration_spec.rb
860
+ bundle exec ruby spec/fixtures/generate_semantic_gold.rb
861
+ ```
862
+
863
+ Each post includes `source`, `source_url`, `download_date`, and `license`
864
+ fields in its frontmatter for attribution. The committed semantic fixture stores
865
+ only document/query vectors and model metadata; tests inject those vectors into
866
+ the committed non-LLM baseline JSON so semantic quality runs on every CI job.
867
+
868
+ The Gundestrup.dk repository is the integration test platform. Its `Gemfile`
869
+ uses this gem through a local path dependency:
870
+
871
+ ```ruby
872
+ gem "jekyll-client-search", path: "../jekyll-client-search"
873
+ ```
874
+
875
+ Run the integration build with:
876
+
877
+ ```bash
878
+ cd ../gundestrup.dk
879
+ bundle install
880
+ bundle exec jekyll build --incremental
881
+ ```
882
+
883
+ The integration build may take a long time when jekyll-imgflow regenerates
884
+ image versions. That processing is independent of this plugin.
885
+
886
+ ## Build and version tasks
887
+
888
+ Show the current version:
889
+
890
+ ```bash
891
+ bundle exec rake version:show
892
+ ```
893
+
894
+ Bump a release component:
895
+
896
+ ```bash
897
+ bundle exec rake "version:bump[patch]"
898
+ bundle exec rake "version:bump[minor]"
899
+ bundle exec rake "version:bump[major]"
900
+ ```
901
+
902
+ The bump task changes only the version constant. Update `CHANGELOG.md`, run
903
+ the checks, and review the diff before committing.
904
+
905
+ Install or update reference files in a consuming site:
906
+
907
+ ```bash
908
+ bundle exec rake jekyll_client_search:reference_files
909
+ bundle exec rake jekyll_client_search:install
910
+ bundle exec rake 'jekyll_client_search:install[true]' # overwrite modified copies
911
+ ```
912
+
913
+ Run the complete local validation and build the gem:
914
+
915
+ ```bash
916
+ bundle exec rake ci
917
+ ```
918
+
919
+ ## CI and release process
920
+
921
+ GitHub Actions are configured in `.github/workflows/`:
922
+
923
+ - `ci.yml` runs the test and gem build checks on pushes and pull requests.
924
+ - `release.yml` runs on `v*` tags or manual dispatch, verifies the gem, and
925
+ publishes it using RubyGems trusted publishing.
926
+
927
+ Before enabling releases:
928
+
929
+ 1. Configure the GitHub repository as a RubyGems trusted publisher.
930
+ 2. Create the `rubygems` GitHub environment and protect it as appropriate.
931
+ 3. Confirm the gem name and repository metadata in the gemspec.
932
+ 4. Run `bundle exec rake ci` locally.
933
+ 5. Bump the version and update `CHANGELOG.md`.
934
+ 6. Create a matching tag such as `v0.1.0`.
935
+ 7. Review the GitHub Actions release run before announcing the release.
936
+
937
+ ## Documentation
938
+
939
+ - [DeepWiki](https://deepwiki.com/gundestrup/jekyll-client-search) —
940
+ auto-generated architecture documentation and code walkthroughs.
941
+ - [CHANGELOG.md](CHANGELOG.md) — release history and changes.
942
+ - [README.developer.md](README.developer.md) — fixture setup and LLM/vector
943
+ testing instructions for contributors.
944
+ - [README.performance.md](README.performance.md) — benchmark methodology
945
+ and results.
946
+ - [AGENTS.md](AGENTS.md) — AI assistant guide with architecture, commands,
947
+ and coding conventions.
948
+ - [NOTICE](NOTICE) — source attribution and licensing for test fixtures.