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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +121 -0
- data/LICENSE +22 -0
- data/NOTICE +49 -0
- data/README.developer.md +204 -0
- data/README.md +948 -0
- data/assets/adapters/elasticlunr.js +59 -0
- data/assets/adapters/minisearch.js +57 -0
- data/assets/adapters/semantic.js +154 -0
- data/assets/client-search-base.js +294 -0
- data/assets/client-search-related.js +176 -0
- data/assets/includes/related-articles.html +36 -0
- data/assets/layouts/post-with-related.html +54 -0
- data/assets/query-embedders/ollama-api.js +63 -0
- data/assets/query-embedders/transformers-worker.js +130 -0
- data/assets/query-embedders/transformers.js +223 -0
- data/docs/assets/icon-256.png +0 -0
- data/docs/assets/icon.svg +133 -0
- data/lib/jekyll/client_search/configuration.rb +152 -0
- data/lib/jekyll/client_search/configuration_accessors.rb +48 -0
- data/lib/jekyll/client_search/document_builder.rb +66 -0
- data/lib/jekyll/client_search/embedder_config_page.rb +14 -0
- data/lib/jekyll/client_search/embedding_configuration.rb +95 -0
- data/lib/jekyll/client_search/generator.rb +134 -0
- data/lib/jekyll/client_search/index_cache.rb +82 -0
- data/lib/jekyll/client_search/live_search_configuration.rb +70 -0
- data/lib/jekyll/client_search/ollama_embedding_adapter.rb +59 -0
- data/lib/jekyll/client_search/query_embedder_configuration.rb +127 -0
- data/lib/jekyll/client_search/related_analyzer.rb +152 -0
- data/lib/jekyll/client_search/related_configuration.rb +103 -0
- data/lib/jekyll/client_search/related_page.rb +14 -0
- data/lib/jekyll/client_search/related_tag.rb +76 -0
- data/lib/jekyll/client_search/runtime_config_page.rb +30 -0
- data/lib/jekyll/client_search/search_index_page.rb +15 -0
- data/lib/jekyll/client_search/search_tag.rb +100 -0
- data/lib/jekyll/client_search/tasks.rb +137 -0
- data/lib/jekyll/client_search/version.rb +7 -0
- data/lib/jekyll/client_search.rb +25 -0
- data/lib/jekyll-client-search.rb +3 -0
- metadata +104 -0
data/README.md
ADDED
|
@@ -0,0 +1,948 @@
|
|
|
1
|
+
# jekyll-client-search
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+
|
|
5
|
+
[](https://github.com/gundestrup/jekyll-client-search/actions/workflows/ci.yml)
|
|
6
|
+
[](https://www.ruby-lang.org/)
|
|
7
|
+
[](https://jekyllrb.com/)
|
|
8
|
+
[](LICENSE)
|
|
9
|
+
[](https://deepwiki.com/gundestrup/jekyll-client-search)
|
|
10
|
+
[](#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.
|