jekyll-client-search 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +32 -0
- data/README.developer.md +72 -10
- data/README.md +99 -4
- data/assets/client-search-dropdown.js +434 -0
- data/assets/client-search-related.js +10 -0
- data/lib/jekyll/client_search/configuration.rb +12 -8
- data/lib/jekyll/client_search/configuration_accessors.rb +12 -4
- data/lib/jekyll/client_search/dropdown_configuration.rb +92 -0
- data/lib/jekyll/client_search/dropdown_tag.rb +115 -0
- data/lib/jekyll/client_search/related_configuration.rb +1 -1
- data/lib/jekyll/client_search/related_tag.rb +29 -7
- data/lib/jekyll/client_search/runtime_config_page.rb +4 -1
- data/lib/jekyll/client_search/search_tag.rb +21 -4
- data/lib/jekyll/client_search/version.rb +1 -1
- data/lib/jekyll/client_search.rb +2 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ed1c7a635cc419d9fb130411ced8e64a473c016e8188e36ddd98ea6922a098f7
|
|
4
|
+
data.tar.gz: f320844ded59274df991b1aa3d56fb22560e818105c5a0e682ea6e6a0c67df88
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a12fb0052f9a727baf957735cd23fc9759f46fe7b54977acd16ed63edb14b0d63e2594d3c2bee936c9317541f8810a654acf7a5369746c9d11c39699457cf351
|
|
7
|
+
data.tar.gz: 46986b4c1a0107821f6932d8538c1aff0fcc386ddb9e9f787a4890da5f8af3ccd30a8c0b6b9257d928d3b52aa6fe625806136812cfbf781763a0f3a391cf9aa6
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,38 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.3.0 — 2026-09-02
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- `{% search_dropdown %}` Liquid tag — compact live-search dropdown for navbars and headers with keyboard navigation (Arrow/Enter/Escape), lazy index loading, icon support, and framework-agnostic semantic HTML (no Bulma/Bootstrap/Tailwind classes)
|
|
9
|
+
- `DropdownConfiguration` class with `enabled`, `max_items`, `min_chars`, `debounce_ms`, `redirect_url` options
|
|
10
|
+
- `assets/client-search-dropdown.js` runtime (434 lines) — two-stage search (AND first, fuzzy OR fallback), shared index cache with the base runtime, multi-instance support via `[data-client-search-dropdown]` attributes, icon rendering from `icon_field`
|
|
11
|
+
- `max:N` parameter on `{% related_articles %}` tag (e.g. `{% related_articles max:3 %}`, `{% related_articles sort:date max:10 %}`)
|
|
12
|
+
- `max_items` config option for `related` section (default: 5)
|
|
13
|
+
- Dropdown config included in generated `search-runtime-config.js`
|
|
14
|
+
- `dropdown_enabled?` accessor and dropdown asset in `runtime_assets`
|
|
15
|
+
- HTML-escaping (via `CGI.escapeHTML`) for `engine_url`, `engine_sri`, and `engine_crossorigin` attributes in `SearchTag` script output
|
|
16
|
+
- Defensive guard with `simplecov:disable` annotation for unreachable `embedder_asset` nil branch in `SearchTag`
|
|
17
|
+
- Ollama embedding adapter tests for empty/invalid responses and standard error handling
|
|
18
|
+
- Related analyzer tests for semantic-only similarity matching
|
|
19
|
+
- System test assertions for embeddings/retrieval content matching and non-ASCII query robustness
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
- `related.max_items` default changed from `nil` (unlimited) to `5`
|
|
23
|
+
- `Configuration#initialize` refactored — extracted `normalize_configured` helper for cleaner config validation
|
|
24
|
+
- Runtime config page now merges `dropdown` config alongside `liveSearch`
|
|
25
|
+
- `SearchTag` and `RelatedTag` now return empty string when `site` is nil or `client_search` is `false` (previously crashed on nil site)
|
|
26
|
+
- Meta test (`test/meta.test.js`) converted from `forEach` to `for` loops to properly `await` settle in cross-engine comparison
|
|
27
|
+
- System tests strengthened with content assertions and non-ASCII robustness checks
|
|
28
|
+
- AGENTS.md restructured — split developer info to README.developer.md, added dropdown architecture entries
|
|
29
|
+
- README.developer.md expanded with coverage documentation, spec file table, and SimpleCov branch coverage notes
|
|
30
|
+
- README.md updated with dropdown section, related `max:N` docs, and CSS styling examples for dropdown
|
|
31
|
+
|
|
32
|
+
### Fixed
|
|
33
|
+
- `SearchTag#render` no longer raises on nil site (now returns empty string)
|
|
34
|
+
- `RelatedTag#render` no longer raises on nil site or `client_search: false` config
|
|
35
|
+
- Meta test race condition — `forEach` callback couldn't `await settle()`, causing flaky cross-engine comparisons; fixed by switching to `for` loops
|
|
36
|
+
|
|
5
37
|
## 0.2.0 — 2026-08-27
|
|
6
38
|
|
|
7
39
|
### Added
|
data/README.developer.md
CHANGED
|
@@ -12,8 +12,9 @@ rbenv install 3.4.10 # if not already installed
|
|
|
12
12
|
rbenv local 3.4.10
|
|
13
13
|
bundle install
|
|
14
14
|
npm ci
|
|
15
|
-
bundle exec rspec
|
|
16
|
-
|
|
15
|
+
bundle exec rspec # Ruby tests (248 examples; 7 Ollama pending)
|
|
16
|
+
OLLAMA_INTEGRATION=1 bundle exec rspec # all tests including Ollama integration
|
|
17
|
+
npm test # JavaScript tests (134 tests)
|
|
17
18
|
```
|
|
18
19
|
|
|
19
20
|
Both test suites pass with only the committed Wikipedia fixtures and the
|
|
@@ -113,14 +114,9 @@ content, self-exclusion, and score sorting.
|
|
|
113
114
|
|
|
114
115
|
### Related test files
|
|
115
116
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
| `spec/related_tag_spec.rb` | Unit tests for the `{% related_articles %}` Liquid tag |
|
|
120
|
-
| `spec/search_tag_spec.rb` | Unit tests for the `{% search_form %}` Liquid tag |
|
|
121
|
-
| `spec/tasks_spec.rb` | Unit tests for the rake tasks (reference_files, install) |
|
|
122
|
-
| `spec/system_spec.rb` | System tests building the fixture site with related and search_form |
|
|
123
|
-
| `test/related.test.js` | JS tests for the related renderer (default, renderItem, filter, sort) |
|
|
117
|
+
See the [Spec files](#spec-files) table for the full list. The key related
|
|
118
|
+
test files are `spec/related_analyzer_spec.rb`, `spec/related_tag_spec.rb`,
|
|
119
|
+
`spec/system_spec.rb`, and `test/related.test.js`.
|
|
124
120
|
|
|
125
121
|
## What is committed
|
|
126
122
|
|
|
@@ -248,6 +244,72 @@ bundle exec rspec --tag system
|
|
|
248
244
|
bundle exec rspec --tag ollama_integration
|
|
249
245
|
```
|
|
250
246
|
|
|
247
|
+
## Coverage
|
|
248
|
+
|
|
249
|
+
The project uses SimpleCov 1.1+ with branch coverage. The target is 100%
|
|
250
|
+
branch coverage and ~99.6% line coverage. Three lines are intentionally
|
|
251
|
+
excluded via `# simplecov:disable branch` for an unreachable defensive
|
|
252
|
+
guard in `search_tag.rb` (the `if embedder_asset` check that can never be
|
|
253
|
+
false with current embedder types — `semantic_with_embedder?` already
|
|
254
|
+
filters out the `none` type, and both `transformers` and `ollama_api`
|
|
255
|
+
always return an asset).
|
|
256
|
+
|
|
257
|
+
The coverage report is generated at `coverage/index.html` after each RSpec
|
|
258
|
+
run. Raw data is in `coverage/.resultset.json` (SimpleCov 1.0+ format with
|
|
259
|
+
`lines` and `branches` keys per file).
|
|
260
|
+
|
|
261
|
+
To check remaining uncovered lines and branches after a run:
|
|
262
|
+
|
|
263
|
+
```bash
|
|
264
|
+
python3 -c "
|
|
265
|
+
import json
|
|
266
|
+
with open('coverage/.resultset.json') as f:
|
|
267
|
+
data = json.load(f)
|
|
268
|
+
for suite, info in data.items():
|
|
269
|
+
cov = info.get('coverage', {})
|
|
270
|
+
for file, lines in sorted(cov.items()):
|
|
271
|
+
if 'lib/jekyll' not in file: continue
|
|
272
|
+
short = file.split('/lib/')[-1]
|
|
273
|
+
line_data = lines.get('lines', []) if isinstance(lines, dict) else lines
|
|
274
|
+
uncovered = [i+1 for i, v in enumerate(line_data) if v == 0]
|
|
275
|
+
if uncovered:
|
|
276
|
+
print(f'{short}: lines {uncovered}')
|
|
277
|
+
branches = lines.get('branches', {}) if isinstance(lines, dict) else {}
|
|
278
|
+
for bk, bd in branches.items():
|
|
279
|
+
for sk, count in bd.items():
|
|
280
|
+
if count == 0:
|
|
281
|
+
print(f'{short}: {sk} (count=0)')
|
|
282
|
+
"
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
## Spec files
|
|
286
|
+
|
|
287
|
+
| File | Description |
|
|
288
|
+
| --- | --- |
|
|
289
|
+
| `spec/configuration_spec.rb` | Site configuration, defaults, validation |
|
|
290
|
+
| `spec/dropdown_configuration_spec.rb` | Dropdown config validation |
|
|
291
|
+
| `spec/dropdown_tag_spec.rb` | `{% search_dropdown %}` Liquid tag |
|
|
292
|
+
| `spec/search_tag_spec.rb` | `{% search_form %}` Liquid tag |
|
|
293
|
+
| `spec/related_tag_spec.rb` | `{% related_articles %}` Liquid tag |
|
|
294
|
+
| `spec/related_analyzer_spec.rb` | Build-time relation analysis |
|
|
295
|
+
| `spec/related_configuration_spec.rb` | Related config validation |
|
|
296
|
+
| `spec/generator_spec.rb` | Jekyll generator integration |
|
|
297
|
+
| `spec/document_builder_spec.rb` | Normalized search documents |
|
|
298
|
+
| `spec/search_index_page_spec.rb` | Generated JSON page |
|
|
299
|
+
| `spec/index_cache_spec.rb` | Index cache |
|
|
300
|
+
| `spec/embedding_cache_spec.rb` | Embedding cache |
|
|
301
|
+
| `spec/runtime_assets_spec.rb` | Runtime asset copying |
|
|
302
|
+
| `spec/runtime_config_page_spec.rb` | Runtime config page generation |
|
|
303
|
+
| `spec/ollama_embedding_adapter_spec.rb` | Ollama adapter unit tests |
|
|
304
|
+
| `spec/ollama_integration_spec.rb` | Ollama integration tests (pending) |
|
|
305
|
+
| `spec/llm_injection_spec.rb` | LLM injection baseline tests (pending) |
|
|
306
|
+
| `spec/tasks_spec.rb` | Rake tasks (reference_files, install) |
|
|
307
|
+
| `spec/gemspec_spec.rb` | Gemspec metadata |
|
|
308
|
+
| `spec/system_spec.rb` | End-to-end system tests |
|
|
309
|
+
| `test/runtime.test.js` | JS unit tests parameterized over adapters |
|
|
310
|
+
| `test/related.test.js` | JS tests for related renderer |
|
|
311
|
+
| `test/system.test.js` | JS system tests using committed baseline index |
|
|
312
|
+
|
|
251
313
|
## CI
|
|
252
314
|
|
|
253
315
|
The CI pipeline runs `bundle exec rspec` and `npm test` without arXiv
|
data/README.md
CHANGED
|
@@ -25,6 +25,7 @@ and vector similarity.
|
|
|
25
25
|
- [Related articles](#related-articles)
|
|
26
26
|
- [Semantic search (embeddings)](#semantic-search-embeddings)
|
|
27
27
|
- [The search form](#the-search-form)
|
|
28
|
+
- [The search dropdown](#the-search-dropdown)
|
|
28
29
|
- [Rake tasks](#rake-tasks)
|
|
29
30
|
- [Configuration reference](#configuration-reference)
|
|
30
31
|
- [Indexing custom collections](#indexing-custom-collections)
|
|
@@ -139,6 +140,23 @@ Then add one line to any post layout:
|
|
|
139
140
|
{% related_articles %}
|
|
140
141
|
```
|
|
141
142
|
|
|
143
|
+
By default, 5 related articles are shown. Override per-tag with `max:N`:
|
|
144
|
+
|
|
145
|
+
```liquid
|
|
146
|
+
{% related_articles max:3 %}
|
|
147
|
+
{% related_articles sort:date max:10 %}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Or set the default in `_config.yml`:
|
|
151
|
+
|
|
152
|
+
```yaml
|
|
153
|
+
client_search:
|
|
154
|
+
related:
|
|
155
|
+
enabled: true
|
|
156
|
+
max_items: 5 # default: 5; null = no limit
|
|
157
|
+
minimum_similarity: 0.55
|
|
158
|
+
```
|
|
159
|
+
|
|
142
160
|
Without embeddings, relations are based on shared tags, categories, and
|
|
143
161
|
hierarchical parent domains. With embeddings enabled, vector similarity
|
|
144
162
|
above the cutoff is also included. See
|
|
@@ -216,6 +234,77 @@ client_search:
|
|
|
216
234
|
For manual `<script>` setup (engine-specific HTML), see
|
|
217
235
|
[Browser usage reference](#browser-usage-reference).
|
|
218
236
|
|
|
237
|
+
## The search dropdown
|
|
238
|
+
|
|
239
|
+
The `{% search_dropdown %}` Liquid tag renders a compact live-search
|
|
240
|
+
dropdown suitable for navbars and headers. It is framework-agnostic —
|
|
241
|
+
emits semantic HTML with data attributes, no CSS classes from Bulma,
|
|
242
|
+
Bootstrap, Tailwind, or any other framework. Style it with your own CSS.
|
|
243
|
+
|
|
244
|
+
```liquid
|
|
245
|
+
{% search_dropdown %}
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
Tag modes:
|
|
249
|
+
|
|
250
|
+
| Syntax | Effect |
|
|
251
|
+
| --- | --- |
|
|
252
|
+
| `{% search_dropdown %}` | Dropdown HTML + all scripts (default) |
|
|
253
|
+
| `{% search_dropdown max:10 %}` | Show up to 10 items (default: 5) |
|
|
254
|
+
| `{% search_dropdown scripts_only %}` | Just the scripts — use with custom HTML |
|
|
255
|
+
| `{% search_dropdown no_scripts %}` | Just the dropdown HTML — load scripts yourself |
|
|
256
|
+
|
|
257
|
+
**Behavior:**
|
|
258
|
+
|
|
259
|
+
- User types → results appear in a dropdown list (live, debounced)
|
|
260
|
+
- Enter with no item selected → redirect to `redirect_url` (default
|
|
261
|
+
`/search/`) with `?q=...` for full results on the search page
|
|
262
|
+
- Arrow Up/Down → highlight items, Enter on highlighted → navigate
|
|
263
|
+
- Click an item → navigate to that document's URL
|
|
264
|
+
- Escape or click outside → close dropdown
|
|
265
|
+
- Index is lazy-loaded on first keystroke (no cost on pages where the
|
|
266
|
+
visitor doesn't search)
|
|
267
|
+
|
|
268
|
+
**Configuration:**
|
|
269
|
+
|
|
270
|
+
```yaml
|
|
271
|
+
client_search:
|
|
272
|
+
dropdown:
|
|
273
|
+
enabled: true # default
|
|
274
|
+
max_items: 5 # default, tag param max:N overrides
|
|
275
|
+
min_chars: 2 # minimum query length before search fires
|
|
276
|
+
debounce_ms: 150 # debounce delay
|
|
277
|
+
redirect_url: /search/ # where Enter redirects for full results
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
**Icon support:** When `icon_field` is configured (default `icon_url`)
|
|
281
|
+
and the search index includes that field (e.g. from jekyll-documents),
|
|
282
|
+
an `<img>` icon is rendered before each result title. Set `icon_field:
|
|
283
|
+
null` to disable.
|
|
284
|
+
|
|
285
|
+
**CSS styling:** The dropdown emits no framework classes. Style with:
|
|
286
|
+
|
|
287
|
+
```css
|
|
288
|
+
.client-search-dropdown { position: relative; }
|
|
289
|
+
.client-search-dropdown ul[role="listbox"] {
|
|
290
|
+
position: absolute; top: 100%; right: 0;
|
|
291
|
+
min-width: 300px; z-index: 30;
|
|
292
|
+
background: white; box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
|
293
|
+
list-style: none; margin: 0; padding: 0;
|
|
294
|
+
}
|
|
295
|
+
.client-search-dropdown li { padding: 0.5rem 1rem; }
|
|
296
|
+
.client-search-dropdown li:hover,
|
|
297
|
+
.client-search-dropdown li[aria-selected="true"] { background: #f5f5f5; }
|
|
298
|
+
.client-search-dropdown li a { text-decoration: none; color: inherit; }
|
|
299
|
+
|
|
300
|
+
/* Type-based styling via data attributes */
|
|
301
|
+
.client-search-dropdown li[data-source="documents"] { border-left: 3px solid #48c774; }
|
|
302
|
+
.client-search-dropdown li[data-file-type="pdf"]::before {
|
|
303
|
+
content: "PDF"; background: #ff0000; color: white;
|
|
304
|
+
padding: 0 0.3em; font-size: 0.7em; margin-right: 0.3em;
|
|
305
|
+
}
|
|
306
|
+
```
|
|
307
|
+
|
|
219
308
|
### Customizing search results with CSS
|
|
220
309
|
|
|
221
310
|
Each search result is rendered as an `<article>` with data attributes that
|
|
@@ -663,10 +752,16 @@ must return a DOM node (or `null` to skip the item):
|
|
|
663
752
|
<script src="/assets/client-search-related.js"></script>
|
|
664
753
|
<script>
|
|
665
754
|
ClientSearchRelated.run({
|
|
666
|
-
renderItem: function (item,
|
|
667
|
-
var li =
|
|
668
|
-
|
|
669
|
-
|
|
755
|
+
renderItem: function (item, dom) {
|
|
756
|
+
var li = dom.createElement("li");
|
|
757
|
+
var link = dom.createElement("a");
|
|
758
|
+
link.href = item.url;
|
|
759
|
+
link.textContent = item.title;
|
|
760
|
+
var score = dom.createElement("span");
|
|
761
|
+
score.className = "score";
|
|
762
|
+
score.textContent = (item.score * 100).toFixed(0) + "%";
|
|
763
|
+
li.appendChild(link);
|
|
764
|
+
li.appendChild(score);
|
|
670
765
|
return li;
|
|
671
766
|
}
|
|
672
767
|
});
|
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
(function () {
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* ClientSearch dropdown runtime — compact live-search dropdown for
|
|
6
|
+
* navbars and headers. Framework-agnostic: emits semantic HTML with
|
|
7
|
+
* data attributes, no CSS classes from any framework.
|
|
8
|
+
*
|
|
9
|
+
* Features:
|
|
10
|
+
* - Lazy index loading (fetches search-index.json on first keystroke)
|
|
11
|
+
* - Two-stage search (AND first, fuzzy OR fallback) via engine adapter
|
|
12
|
+
* - Compact <li><a> items with optional icon rendering
|
|
13
|
+
* - Keyboard navigation (Arrow Up/Down, Enter, Escape)
|
|
14
|
+
* - Enter with no selection → redirect to redirect_url (?q=...)
|
|
15
|
+
* - Click outside or Escape → close dropdown
|
|
16
|
+
* - Multi-instance via [data-client-search-dropdown] attributes
|
|
17
|
+
* - Shared index cache with the base runtime when present
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
var sharedIndex = null;
|
|
21
|
+
var sharedDocuments = null;
|
|
22
|
+
var indexLoadPromise = null;
|
|
23
|
+
|
|
24
|
+
function safeUrl(value) {
|
|
25
|
+
if (value === "#") {
|
|
26
|
+
return "#";
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
var url = new URL(value, window.location.origin);
|
|
30
|
+
if (url.origin === window.location.origin &&
|
|
31
|
+
["http:", "https:"].includes(url.protocol)) {
|
|
32
|
+
return url.pathname + url.search + url.hash;
|
|
33
|
+
}
|
|
34
|
+
} catch (_error) {
|
|
35
|
+
return "#";
|
|
36
|
+
}
|
|
37
|
+
return "#";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function toCamelCase(key) {
|
|
41
|
+
return key.replace(/_([a-z])/g, function (_, char) { return char.toUpperCase(); });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function normalize(entry) {
|
|
45
|
+
var id = entry.id || entry.url;
|
|
46
|
+
if (!id) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
var categories = Array.isArray(entry.categories) ? entry.categories : [];
|
|
50
|
+
var tags = Array.isArray(entry.tags) ? entry.tags : [];
|
|
51
|
+
var normalized = {
|
|
52
|
+
id: id,
|
|
53
|
+
title: entry.title || "Untitled",
|
|
54
|
+
url: entry.url || "#",
|
|
55
|
+
excerpt: entry.excerpt || "",
|
|
56
|
+
content: entry.content || "",
|
|
57
|
+
date: entry.date || "",
|
|
58
|
+
date_timestamp: Number(entry.date_timestamp) || 0,
|
|
59
|
+
categories: categories,
|
|
60
|
+
tags: tags,
|
|
61
|
+
categoriesText: categories.join(" "),
|
|
62
|
+
tagsText: tags.join(" ")
|
|
63
|
+
};
|
|
64
|
+
if (entry.source) {
|
|
65
|
+
normalized.source = entry.source;
|
|
66
|
+
}
|
|
67
|
+
Object.keys(entry).forEach(function (key) {
|
|
68
|
+
if (!(key in normalized) && key !== "embedding") {
|
|
69
|
+
var value = entry[key];
|
|
70
|
+
if (value !== null && value !== undefined && value !== "") {
|
|
71
|
+
normalized[key] = value;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
return normalized;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function loadIndex(config) {
|
|
79
|
+
if (sharedIndex && sharedDocuments) {
|
|
80
|
+
return Promise.resolve({ index: sharedIndex, documents: sharedDocuments });
|
|
81
|
+
}
|
|
82
|
+
if (indexLoadPromise) {
|
|
83
|
+
return indexLoadPromise;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
var indexUrl = config.indexUrl;
|
|
87
|
+
if (!indexUrl) {
|
|
88
|
+
return Promise.reject(new Error("No index URL configured"));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
indexLoadPromise = fetch(indexUrl, { headers: { Accept: "application/json" } })
|
|
92
|
+
.then(function (response) {
|
|
93
|
+
if (!response.ok) {
|
|
94
|
+
throw new Error("Unable to load search index");
|
|
95
|
+
}
|
|
96
|
+
return response.json();
|
|
97
|
+
})
|
|
98
|
+
.then(function (data) {
|
|
99
|
+
if (!Array.isArray(data)) {
|
|
100
|
+
throw new TypeError("Search index must be an array");
|
|
101
|
+
}
|
|
102
|
+
var documents = data.map(normalize).filter(Boolean);
|
|
103
|
+
var adapter = window.ClientSearchAdapters &&
|
|
104
|
+
(window.ClientSearchAdapters[config.engine] ||
|
|
105
|
+
window.ClientSearchAdapters.minisearch);
|
|
106
|
+
if (!adapter) {
|
|
107
|
+
throw new Error("Search adapter not loaded");
|
|
108
|
+
}
|
|
109
|
+
sharedDocuments = new Map(documents.map(function (entry) {
|
|
110
|
+
return [entry.id, entry];
|
|
111
|
+
}));
|
|
112
|
+
sharedIndex = adapter.buildIndex(documents);
|
|
113
|
+
return { index: sharedIndex, documents: sharedDocuments };
|
|
114
|
+
})
|
|
115
|
+
.catch(function (error) {
|
|
116
|
+
indexLoadPromise = null;
|
|
117
|
+
throw error;
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
return indexLoadPromise;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function search(index, query, adapter) {
|
|
124
|
+
var exact = adapter.search(index, query, {
|
|
125
|
+
combineWith: "AND",
|
|
126
|
+
fuzzy: false,
|
|
127
|
+
prefix: true
|
|
128
|
+
});
|
|
129
|
+
if (exact && typeof exact.then === "function") {
|
|
130
|
+
return exact.then(function (results) {
|
|
131
|
+
if (results && results.length > 0) {
|
|
132
|
+
return results;
|
|
133
|
+
}
|
|
134
|
+
return adapter.search(index, query, {
|
|
135
|
+
combineWith: "OR",
|
|
136
|
+
fuzzy: true,
|
|
137
|
+
prefix: true
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
if (exact && exact.length > 0) {
|
|
142
|
+
return exact;
|
|
143
|
+
}
|
|
144
|
+
return adapter.search(index, query, {
|
|
145
|
+
combineWith: "OR",
|
|
146
|
+
fuzzy: true,
|
|
147
|
+
prefix: true
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function createResultItem(match, documents, iconField) {
|
|
152
|
+
var entry = documents.get(match.ref);
|
|
153
|
+
if (!entry) {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
var li = document.createElement("li");
|
|
158
|
+
li.setAttribute("role", "option");
|
|
159
|
+
li.className = "client-search-dropdown-item";
|
|
160
|
+
li.dataset.url = safeUrl(entry.url);
|
|
161
|
+
|
|
162
|
+
if (entry.source) {
|
|
163
|
+
li.dataset.source = entry.source;
|
|
164
|
+
}
|
|
165
|
+
if (entry.categories && entry.categories.length) {
|
|
166
|
+
li.dataset.categories = entry.categories.join(" ");
|
|
167
|
+
}
|
|
168
|
+
if (entry.tags && entry.tags.length) {
|
|
169
|
+
li.dataset.tags = entry.tags.join(" ");
|
|
170
|
+
}
|
|
171
|
+
Object.keys(entry).forEach(function (key) {
|
|
172
|
+
var skip = ["id", "title", "url", "excerpt", "content",
|
|
173
|
+
"categories", "tags", "source", "embedding",
|
|
174
|
+
"date_timestamp"];
|
|
175
|
+
if (skip.indexOf(key) !== -1) {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
var value = entry[key];
|
|
179
|
+
if (typeof value === "string" || typeof value === "number") {
|
|
180
|
+
li.dataset[toCamelCase(key)] = String(value);
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
if (iconField && entry[iconField]) {
|
|
185
|
+
var icon = document.createElement("img");
|
|
186
|
+
icon.className = "client-search-dropdown-icon";
|
|
187
|
+
icon.src = safeUrl(entry[iconField]);
|
|
188
|
+
icon.alt = entry.file_type || entry.source || "";
|
|
189
|
+
icon.loading = "lazy";
|
|
190
|
+
icon.style.width = "1em";
|
|
191
|
+
icon.style.height = "1em";
|
|
192
|
+
icon.style.verticalAlign = "middle";
|
|
193
|
+
icon.style.marginRight = "0.3em";
|
|
194
|
+
li.appendChild(icon);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
var link = document.createElement("a");
|
|
198
|
+
link.href = safeUrl(entry.url);
|
|
199
|
+
link.textContent = entry.title;
|
|
200
|
+
link.className = "client-search-dropdown-link";
|
|
201
|
+
li.appendChild(link);
|
|
202
|
+
|
|
203
|
+
return li;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function DropdownInstance(root, config) {
|
|
207
|
+
this.root = root;
|
|
208
|
+
this.form = root.querySelector("[data-cs-dropdown-form]");
|
|
209
|
+
this.input = root.querySelector("[data-cs-dropdown-input]");
|
|
210
|
+
this.results = root.querySelector("[data-cs-dropdown-results]");
|
|
211
|
+
this.config = config;
|
|
212
|
+
this.maxItems = parseInt(this.results.dataset.maxItems, 10) || config.maxItems || 5;
|
|
213
|
+
this.selectedIndex = -1;
|
|
214
|
+
this.currentItems = [];
|
|
215
|
+
this.debounceTimer = null;
|
|
216
|
+
this.renderVersion = 0;
|
|
217
|
+
this.adapter = null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
DropdownInstance.prototype.init = function () {
|
|
221
|
+
if (!this.form || !this.input || !this.results) {
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
var self = this;
|
|
226
|
+
|
|
227
|
+
this.input.addEventListener("input", function () {
|
|
228
|
+
self.onInput();
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
this.input.addEventListener("keydown", function (event) {
|
|
232
|
+
self.onKeydown(event);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
this.form.addEventListener("submit", function (event) {
|
|
236
|
+
event.preventDefault();
|
|
237
|
+
self.onSubmit();
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
document.addEventListener("click", function (event) {
|
|
241
|
+
if (!self.root.contains(event.target)) {
|
|
242
|
+
self.hide();
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
this.results.addEventListener("click", function (event) {
|
|
247
|
+
var li = event.target.closest("li");
|
|
248
|
+
if (li && li.dataset.url && li.dataset.url !== "#") {
|
|
249
|
+
window.location.href = li.dataset.url;
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
DropdownInstance.prototype.onInput = function () {
|
|
255
|
+
var self = this;
|
|
256
|
+
this.renderVersion += 1;
|
|
257
|
+
clearTimeout(this.debounceTimer);
|
|
258
|
+
|
|
259
|
+
var query = this.input.value.trim();
|
|
260
|
+
if (!query || query.length < this.config.minChars) {
|
|
261
|
+
this.hide();
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
this.debounceTimer = setTimeout(function () {
|
|
266
|
+
self.performSearch(query);
|
|
267
|
+
}, this.config.debounceMs);
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
DropdownInstance.prototype.onKeydown = function (event) {
|
|
271
|
+
if (this.results.getAttribute("aria-hidden") === "true") {
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
var visible = this.results.children.length > 0;
|
|
275
|
+
if (!visible) {
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
switch (event.key) {
|
|
280
|
+
case "ArrowDown":
|
|
281
|
+
event.preventDefault();
|
|
282
|
+
this.selectItem(Math.min(this.selectedIndex + 1, this.currentItems.length - 1));
|
|
283
|
+
break;
|
|
284
|
+
case "ArrowUp":
|
|
285
|
+
event.preventDefault();
|
|
286
|
+
this.selectItem(Math.max(this.selectedIndex - 1, 0));
|
|
287
|
+
break;
|
|
288
|
+
case "Enter":
|
|
289
|
+
if (this.selectedIndex >= 0 && this.currentItems[this.selectedIndex]) {
|
|
290
|
+
event.preventDefault();
|
|
291
|
+
var url = this.currentItems[this.selectedIndex].dataset.url;
|
|
292
|
+
if (url && url !== "#") {
|
|
293
|
+
window.location.href = url;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
break;
|
|
297
|
+
case "Escape":
|
|
298
|
+
this.hide();
|
|
299
|
+
this.input.blur();
|
|
300
|
+
break;
|
|
301
|
+
case "Tab":
|
|
302
|
+
this.hide();
|
|
303
|
+
break;
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
DropdownInstance.prototype.onSubmit = function () {
|
|
308
|
+
var query = this.input.value.trim();
|
|
309
|
+
if (!query) {
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
var redirectUrl = this.config.redirectUrl || "/search/";
|
|
313
|
+
var separator = redirectUrl.indexOf("?") !== -1 ? "&" : "?";
|
|
314
|
+
window.location.href = redirectUrl + separator + "q=" + encodeURIComponent(query);
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
DropdownInstance.prototype.performSearch = function (query) {
|
|
318
|
+
var self = this;
|
|
319
|
+
var version = this.renderVersion;
|
|
320
|
+
|
|
321
|
+
var config = this.config;
|
|
322
|
+
var engineName = config.engine || "minisearch";
|
|
323
|
+
|
|
324
|
+
loadIndex(config).then(function (loaded) {
|
|
325
|
+
if (version !== self.renderVersion) {
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
var adapter = window.ClientSearchAdapters &&
|
|
330
|
+
(window.ClientSearchAdapters[engineName] ||
|
|
331
|
+
window.ClientSearchAdapters.minisearch);
|
|
332
|
+
if (!adapter || !adapter.available()) {
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
var results = search(loaded.index, query, adapter);
|
|
337
|
+
return Promise.resolve(results).then(function (matches) {
|
|
338
|
+
if (version === self.renderVersion) {
|
|
339
|
+
self.renderResults(matches, loaded.documents);
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
}).catch(function () {
|
|
343
|
+
if (version === self.renderVersion) {
|
|
344
|
+
self.hide();
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
DropdownInstance.prototype.renderResults = function (matches, documents) {
|
|
350
|
+
this.results.replaceChildren();
|
|
351
|
+
this.currentItems = [];
|
|
352
|
+
this.selectedIndex = -1;
|
|
353
|
+
|
|
354
|
+
if (!matches || matches.length === 0) {
|
|
355
|
+
this.hide();
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
var iconField = this.config.iconField || null;
|
|
360
|
+
var count = Math.min(matches.length, this.maxItems);
|
|
361
|
+
|
|
362
|
+
for (var i = 0; i < count; i++) {
|
|
363
|
+
var item = createResultItem(matches[i], documents, iconField);
|
|
364
|
+
if (item) {
|
|
365
|
+
this.results.appendChild(item);
|
|
366
|
+
this.currentItems.push(item);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (this.currentItems.length === 0) {
|
|
371
|
+
this.hide();
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
this.show();
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
DropdownInstance.prototype.selectItem = function (index) {
|
|
379
|
+
this.selectedIndex = index;
|
|
380
|
+
for (var i = 0; i < this.currentItems.length; i++) {
|
|
381
|
+
if (i === index) {
|
|
382
|
+
this.currentItems[i].setAttribute("aria-selected", "true");
|
|
383
|
+
} else {
|
|
384
|
+
this.currentItems[i].removeAttribute("aria-selected");
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
if (this.currentItems[index]) {
|
|
388
|
+
this.currentItems[index].scrollIntoView({ block: "nearest" });
|
|
389
|
+
}
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
DropdownInstance.prototype.show = function () {
|
|
393
|
+
this.results.setAttribute("aria-hidden", "false");
|
|
394
|
+
this.input.setAttribute("aria-expanded", "true");
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
DropdownInstance.prototype.hide = function () {
|
|
398
|
+
this.results.setAttribute("aria-hidden", "true");
|
|
399
|
+
this.input.setAttribute("aria-expanded", "false");
|
|
400
|
+
this.results.replaceChildren();
|
|
401
|
+
this.currentItems = [];
|
|
402
|
+
this.selectedIndex = -1;
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
function initAll() {
|
|
406
|
+
var config = window.clientSearchConfig || {};
|
|
407
|
+
var dropdownConfig = config.dropdown || {};
|
|
408
|
+
if (dropdownConfig.enabled === false) {
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
var mergedConfig = {
|
|
413
|
+
indexUrl: config.indexUrl,
|
|
414
|
+
engine: config.engine || "minisearch",
|
|
415
|
+
iconField: config.iconField || null,
|
|
416
|
+
minChars: typeof dropdownConfig.minChars === "number" ? dropdownConfig.minChars : 2,
|
|
417
|
+
debounceMs: typeof dropdownConfig.debounceMs === "number" ? dropdownConfig.debounceMs : 150,
|
|
418
|
+
maxItems: typeof dropdownConfig.maxItems === "number" ? dropdownConfig.maxItems : 5,
|
|
419
|
+
redirectUrl: dropdownConfig.redirectUrl || "/search/"
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
var roots = document.querySelectorAll("[data-client-search-dropdown]");
|
|
423
|
+
roots.forEach(function (root) {
|
|
424
|
+
var instance = new DropdownInstance(root, mergedConfig);
|
|
425
|
+
instance.init();
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
if (document.readyState === "loading") {
|
|
430
|
+
document.addEventListener("DOMContentLoaded", initAll);
|
|
431
|
+
} else {
|
|
432
|
+
initAll();
|
|
433
|
+
}
|
|
434
|
+
}());
|
|
@@ -100,6 +100,11 @@
|
|
|
100
100
|
}
|
|
101
101
|
sortItems(items, sortOrder);
|
|
102
102
|
|
|
103
|
+
var maxItems = options.maxItems || 0;
|
|
104
|
+
if (maxItems && maxItems > 0) {
|
|
105
|
+
items = items.slice(0, maxItems);
|
|
106
|
+
}
|
|
107
|
+
|
|
103
108
|
container.replaceChildren();
|
|
104
109
|
if (items.length === 0) {
|
|
105
110
|
return;
|
|
@@ -138,6 +143,7 @@
|
|
|
138
143
|
relationsUrl: window.clientSearchConfig && window.clientSearchConfig.relatedUrl,
|
|
139
144
|
currentUrl: window.location.pathname,
|
|
140
145
|
sort: (window.clientSearchConfig && window.clientSearchConfig.relatedSort) || "relevance",
|
|
146
|
+
maxItems: 0,
|
|
141
147
|
renderItem: null,
|
|
142
148
|
filter: null
|
|
143
149
|
}, options || {});
|
|
@@ -146,6 +152,10 @@
|
|
|
146
152
|
return Promise.resolve();
|
|
147
153
|
}
|
|
148
154
|
|
|
155
|
+
if (container.dataset.relatedMax) {
|
|
156
|
+
options.maxItems = parseInt(container.dataset.relatedMax, 10) || 0;
|
|
157
|
+
}
|
|
158
|
+
|
|
149
159
|
return fetch(options.relationsUrl, { headers: { Accept: "application/json" } })
|
|
150
160
|
.then(function (response) {
|
|
151
161
|
if (!response.ok) {
|
|
@@ -39,17 +39,11 @@ module Jekyll
|
|
|
39
39
|
}.freeze
|
|
40
40
|
|
|
41
41
|
def initialize(site)
|
|
42
|
-
configured = site.config["client_search"]
|
|
43
|
-
configured = { "enabled" => false } if configured == false
|
|
44
|
-
configured ||= {}
|
|
45
|
-
unless configured.is_a?(Hash)
|
|
46
|
-
raise Jekyll::Errors::FatalException,
|
|
47
|
-
"client_search configuration must be a mapping or false"
|
|
48
|
-
end
|
|
49
|
-
|
|
42
|
+
configured = normalize_configured(site.config["client_search"])
|
|
50
43
|
@values = DEFAULTS.merge(configured)
|
|
51
44
|
@live_search = LiveSearchConfiguration.new(configured["live_search"] || {}, engine: engine)
|
|
52
45
|
@related = RelatedConfiguration.new(configured["related"] || {})
|
|
46
|
+
@dropdown = DropdownConfiguration.new(configured["dropdown"] || {})
|
|
53
47
|
merge_embedding_config(configured["embedding"])
|
|
54
48
|
validate_engine!
|
|
55
49
|
return unless embedding_enabled?
|
|
@@ -60,6 +54,15 @@ module Jekyll
|
|
|
60
54
|
|
|
61
55
|
private
|
|
62
56
|
|
|
57
|
+
def normalize_configured(value)
|
|
58
|
+
return { "enabled" => false } if value == false
|
|
59
|
+
return value if value.is_a?(Hash)
|
|
60
|
+
return {} if value.nil?
|
|
61
|
+
|
|
62
|
+
raise Jekyll::Errors::FatalException,
|
|
63
|
+
"client_search configuration must be a mapping or false"
|
|
64
|
+
end
|
|
65
|
+
|
|
63
66
|
def merge_embedding_config(configured_embedding)
|
|
64
67
|
embedding = configured_embedding || {}
|
|
65
68
|
unless embedding.is_a?(Hash)
|
|
@@ -105,6 +108,7 @@ module Jekyll
|
|
|
105
108
|
assets = ["assets/client-search-base.js", "assets/adapters/#{engine}.js"]
|
|
106
109
|
assets.concat(query_embedder_assets) if engine == "semantic" && embedding_enabled?
|
|
107
110
|
assets << "assets/client-search-related.js" if related_enabled?
|
|
111
|
+
assets << "assets/client-search-dropdown.js" if dropdown_enabled?
|
|
108
112
|
assets
|
|
109
113
|
end
|
|
110
114
|
|
|
@@ -28,10 +28,6 @@ module Jekyll
|
|
|
28
28
|
@query_embedder.model
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
-
def query_embedder_api_url
|
|
32
|
-
@query_embedder.api_url
|
|
33
|
-
end
|
|
34
|
-
|
|
35
31
|
def query_embedder_asset
|
|
36
32
|
@query_embedder.asset
|
|
37
33
|
end
|
|
@@ -43,6 +39,18 @@ module Jekyll
|
|
|
43
39
|
def query_embedder_config_json
|
|
44
40
|
@query_embedder.to_json
|
|
45
41
|
end
|
|
42
|
+
|
|
43
|
+
def dropdown_enabled?
|
|
44
|
+
@dropdown.enabled?
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def dropdown_max_items
|
|
48
|
+
@dropdown.max_items
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def dropdown_config
|
|
52
|
+
@dropdown.to_h
|
|
53
|
+
end
|
|
46
54
|
end
|
|
47
55
|
end
|
|
48
56
|
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Jekyll
|
|
6
|
+
module ClientSearch
|
|
7
|
+
# Validates optional compact dropdown live-search settings.
|
|
8
|
+
class DropdownConfiguration
|
|
9
|
+
DEFAULTS = {
|
|
10
|
+
"enabled" => true,
|
|
11
|
+
"max_items" => 5,
|
|
12
|
+
"min_chars" => 2,
|
|
13
|
+
"debounce_ms" => 150,
|
|
14
|
+
"redirect_url" => "/search/"
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
def initialize(config)
|
|
18
|
+
unless config.is_a?(Hash)
|
|
19
|
+
raise Jekyll::Errors::FatalException,
|
|
20
|
+
"client_search dropdown configuration must be a mapping"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
@values = DEFAULTS.merge(config)
|
|
24
|
+
validate!
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def enabled?
|
|
28
|
+
@values.fetch("enabled") != false
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def max_items
|
|
32
|
+
@values.fetch("max_items")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def min_chars
|
|
36
|
+
@values.fetch("min_chars")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def debounce_ms
|
|
40
|
+
@values.fetch("debounce_ms")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def redirect_url
|
|
44
|
+
@values.fetch("redirect_url").to_s
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def to_h
|
|
48
|
+
{
|
|
49
|
+
"enabled" => enabled?,
|
|
50
|
+
"maxItems" => max_items,
|
|
51
|
+
"minChars" => min_chars,
|
|
52
|
+
"debounceMs" => debounce_ms,
|
|
53
|
+
"redirectUrl" => redirect_url
|
|
54
|
+
}
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def validate!
|
|
60
|
+
validate_boolean!("enabled")
|
|
61
|
+
validate_integer!("max_items", minimum: 1)
|
|
62
|
+
validate_integer!("min_chars", minimum: 0)
|
|
63
|
+
validate_integer!("debounce_ms", minimum: 0)
|
|
64
|
+
if redirect_url.empty?
|
|
65
|
+
raise Jekyll::Errors::FatalException,
|
|
66
|
+
"client_search dropdown redirect_url must not be empty"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
uri = URI.parse(redirect_url)
|
|
70
|
+
return if uri.scheme.nil? && uri.host.nil? && !redirect_url.start_with?("//")
|
|
71
|
+
|
|
72
|
+
raise Jekyll::Errors::FatalException,
|
|
73
|
+
"client_search dropdown redirect_url must be a relative path"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def validate_boolean!(key)
|
|
77
|
+
return if [true, false].include?(@values[key])
|
|
78
|
+
|
|
79
|
+
raise Jekyll::Errors::FatalException,
|
|
80
|
+
"client_search dropdown.#{key} must be true or false"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def validate_integer!(key, minimum:)
|
|
84
|
+
value = @values.fetch(key)
|
|
85
|
+
return if value.is_a?(Integer) && value >= minimum
|
|
86
|
+
|
|
87
|
+
raise Jekyll::Errors::FatalException,
|
|
88
|
+
"client_search dropdown.#{key} must be an integer greater than or equal to #{minimum}"
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cgi"
|
|
4
|
+
|
|
5
|
+
module Jekyll
|
|
6
|
+
module ClientSearch
|
|
7
|
+
# Liquid tag that renders a compact live-search dropdown suitable for
|
|
8
|
+
# navbars and headers. Framework-agnostic — emits semantic HTML with
|
|
9
|
+
# data attributes, no CSS classes from any framework.
|
|
10
|
+
#
|
|
11
|
+
# {% search_dropdown %}
|
|
12
|
+
# {% search_dropdown max:10 %}
|
|
13
|
+
# {% search_dropdown scripts_only %}
|
|
14
|
+
# {% search_dropdown no_scripts %}
|
|
15
|
+
#
|
|
16
|
+
# When client_search is disabled the tag renders nothing.
|
|
17
|
+
class DropdownTag < Liquid::Tag
|
|
18
|
+
SYNTAX = /\A(max:(\d+))?\s*(scripts_only|no_scripts)?\z/
|
|
19
|
+
|
|
20
|
+
def initialize(tag_name, markup, tokens)
|
|
21
|
+
super
|
|
22
|
+
@markup = markup.to_s.strip
|
|
23
|
+
unless (match = @markup.match(SYNTAX))
|
|
24
|
+
raise Liquid::SyntaxError,
|
|
25
|
+
"search_dropdown: invalid syntax. Use {% search_dropdown %}, " \
|
|
26
|
+
"{% search_dropdown max:10 %}, {% search_dropdown scripts_only %}, " \
|
|
27
|
+
"or {% search_dropdown no_scripts %}"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
@max_items = match[2].to_i if match[2]
|
|
31
|
+
raise Liquid::SyntaxError, "search_dropdown: max must be greater than zero" if @max_items && @max_items < 1
|
|
32
|
+
|
|
33
|
+
@mode = match[3] || "full"
|
|
34
|
+
@input_id = "cs-dropdown-input-#{object_id}"
|
|
35
|
+
@results_id = "cs-dropdown-results-#{object_id}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def render(context)
|
|
39
|
+
site = context.registers[:site]
|
|
40
|
+
return "" unless enabled?(site)
|
|
41
|
+
|
|
42
|
+
configuration = Configuration.new(site)
|
|
43
|
+
return "" unless configuration.dropdown_enabled?
|
|
44
|
+
|
|
45
|
+
render_mode(configuration, site)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def enabled?(site)
|
|
51
|
+
return false if site.nil?
|
|
52
|
+
|
|
53
|
+
config_hash = site.config.fetch("client_search", {})
|
|
54
|
+
config_hash != false && config_hash["enabled"] != false
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def render_mode(configuration, site)
|
|
58
|
+
baseurl = site.config["baseurl"].to_s.gsub(%r{\A/+|/+$}, "")
|
|
59
|
+
prefix = baseurl.empty? ? "" : "/#{baseurl}"
|
|
60
|
+
max = resolve_max(configuration)
|
|
61
|
+
|
|
62
|
+
form_html = build_form(max)
|
|
63
|
+
return form_html if @mode == "no_scripts"
|
|
64
|
+
|
|
65
|
+
scripts = build_scripts(configuration, prefix)
|
|
66
|
+
return scripts if @mode == "scripts_only"
|
|
67
|
+
|
|
68
|
+
"#{form_html}\n#{scripts}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def resolve_max(configuration)
|
|
72
|
+
@max_items || configuration.dropdown_max_items
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def build_form(max)
|
|
76
|
+
<<~HTML
|
|
77
|
+
<div class="client-search-dropdown" data-client-search-dropdown>
|
|
78
|
+
<form role="search" autocomplete="off" data-cs-dropdown-form>
|
|
79
|
+
<label class="sr-only" for="#{@input_id}">Search</label>
|
|
80
|
+
<input id="#{@input_id}" type="search" name="q"
|
|
81
|
+
placeholder="Search…" autocomplete="off"
|
|
82
|
+
aria-expanded="false" aria-controls="#{@results_id}"
|
|
83
|
+
data-cs-dropdown-input>
|
|
84
|
+
</form>
|
|
85
|
+
<ul id="#{@results_id}" role="listbox" aria-hidden="true"
|
|
86
|
+
data-cs-dropdown-results data-max-items="#{max}"></ul>
|
|
87
|
+
</div>
|
|
88
|
+
HTML
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def build_scripts(configuration, prefix)
|
|
92
|
+
scripts = []
|
|
93
|
+
engine = configuration.engine_url
|
|
94
|
+
if engine
|
|
95
|
+
attrs = ["src=\"#{CGI.escapeHTML(engine)}\""]
|
|
96
|
+
if configuration.engine_crossorigin
|
|
97
|
+
crossorigin = CGI.escapeHTML(configuration.engine_crossorigin)
|
|
98
|
+
attrs << "crossorigin=\"#{crossorigin}\""
|
|
99
|
+
end
|
|
100
|
+
if configuration.engine_sri
|
|
101
|
+
integrity = CGI.escapeHTML(configuration.engine_sri)
|
|
102
|
+
attrs << "integrity=\"#{integrity}\""
|
|
103
|
+
end
|
|
104
|
+
scripts << "<script #{attrs.join(' ')}></script>"
|
|
105
|
+
end
|
|
106
|
+
scripts << "<script src=\"#{prefix}/assets/search-runtime-config.js\"></script>"
|
|
107
|
+
scripts << "<script src=\"#{prefix}/assets/client-search-dropdown.js\"></script>"
|
|
108
|
+
scripts << "<script src=\"#{prefix}/assets/adapters/#{configuration.engine}.js\"></script>"
|
|
109
|
+
scripts.map { |script| " #{script}" }.join("\n")
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
Liquid::Template.register_tag("search_dropdown", Jekyll::ClientSearch::DropdownTag)
|
|
@@ -7,12 +7,14 @@ module Jekyll
|
|
|
7
7
|
#
|
|
8
8
|
# {% related_articles %}
|
|
9
9
|
# {% related_articles sort:date %}
|
|
10
|
+
# {% related_articles max:3 %}
|
|
11
|
+
# {% related_articles sort:date max:3 %}
|
|
10
12
|
# {% related_articles no_scripts %}
|
|
11
13
|
#
|
|
12
14
|
# When +related.enabled+ is false the tag renders nothing, so it is safe
|
|
13
15
|
# to leave in a layout even when the feature is off.
|
|
14
16
|
class RelatedTag < Liquid::Tag
|
|
15
|
-
SYNTAX = /\A(sort:(\w+))?\s*(no_scripts)?\z/
|
|
17
|
+
SYNTAX = /\A(sort:(\w+))?\s*(max:(\d+))?\s*(no_scripts)?\z/
|
|
16
18
|
|
|
17
19
|
def initialize(tag_name, markup, tokens)
|
|
18
20
|
super
|
|
@@ -20,22 +22,30 @@ module Jekyll
|
|
|
20
22
|
unless (match = @markup.match(SYNTAX))
|
|
21
23
|
raise Liquid::SyntaxError,
|
|
22
24
|
"related_articles: invalid syntax. Use {% related_articles %}, " \
|
|
23
|
-
"{% related_articles sort:date %},
|
|
25
|
+
"{% related_articles sort:date %}, {% related_articles max:3 %}, " \
|
|
26
|
+
"or {% related_articles no_scripts %}"
|
|
24
27
|
end
|
|
25
28
|
|
|
26
29
|
@sort = match[2] if match[2]
|
|
27
|
-
@
|
|
30
|
+
@max_items = match[4].to_i if match[4]
|
|
31
|
+
raise Liquid::SyntaxError, "related_articles: max must be greater than zero" if @max_items && @max_items < 1
|
|
32
|
+
|
|
33
|
+
@include_scripts = match[5].nil?
|
|
28
34
|
end
|
|
29
35
|
|
|
30
36
|
def render(context)
|
|
31
37
|
site = context.registers[:site]
|
|
32
|
-
|
|
38
|
+
return "" if site.nil?
|
|
39
|
+
|
|
40
|
+
config = site.config.fetch("client_search", {})
|
|
41
|
+
return "" if config == false
|
|
33
42
|
return "" unless related_enabled?(config)
|
|
34
43
|
|
|
35
44
|
asset_prefix = asset_prefix(site)
|
|
36
45
|
sort_attr = @sort ? " data-related-sort=\"#{@sort}\"" : ""
|
|
46
|
+
max_attr = resolve_max_attr(config)
|
|
37
47
|
scripts = build_scripts(asset_prefix)
|
|
38
|
-
build_html(sort_attr, scripts)
|
|
48
|
+
build_html(sort_attr, max_attr, scripts)
|
|
39
49
|
end
|
|
40
50
|
|
|
41
51
|
private
|
|
@@ -57,7 +67,19 @@ module Jekyll
|
|
|
57
67
|
"<script src=\"#{prefix}/assets/client-search-related.js\"></script>"
|
|
58
68
|
end
|
|
59
69
|
|
|
60
|
-
def
|
|
70
|
+
def resolve_max_attr(config)
|
|
71
|
+
related = config.fetch("related", {})
|
|
72
|
+
max = if @max_items
|
|
73
|
+
@max_items
|
|
74
|
+
elsif related.key?("max_items")
|
|
75
|
+
related["max_items"]
|
|
76
|
+
else
|
|
77
|
+
5
|
|
78
|
+
end
|
|
79
|
+
max.nil? ? "" : " data-related-max=\"#{max}\""
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def build_html(sort_attr, max_attr, scripts)
|
|
61
83
|
<<~HTML
|
|
62
84
|
<section class="related-articles-section">
|
|
63
85
|
<label for="related-sort">Sort related articles</label>
|
|
@@ -65,7 +87,7 @@ module Jekyll
|
|
|
65
87
|
<option value="relevance">Most related</option>
|
|
66
88
|
<option value="date">Newest</option>
|
|
67
89
|
</select>
|
|
68
|
-
<div id="related-articles"#{sort_attr}></div>
|
|
90
|
+
<div id="related-articles"#{sort_attr}#{max_attr}></div>
|
|
69
91
|
</section>#{scripts}
|
|
70
92
|
HTML
|
|
71
93
|
end
|
|
@@ -13,10 +13,13 @@ module Jekyll
|
|
|
13
13
|
}
|
|
14
14
|
defaults["relatedUrl"] = index_url(site, configuration.related_output) if configuration.related_enabled?
|
|
15
15
|
defaults["iconField"] = configuration.runtime_icon_field if configuration.runtime_icon_field
|
|
16
|
+
defaults["dropdown"] = configuration.dropdown_config if configuration.dropdown_enabled?
|
|
16
17
|
json = JSON.generate(defaults)
|
|
17
18
|
self.content = "window.clientSearchConfig = (function (generated, existing) {" \
|
|
18
19
|
"var liveSearch = Object.assign({}, generated.liveSearch, existing.liveSearch || {});" \
|
|
19
|
-
"
|
|
20
|
+
"var dropdown = Object.assign({}, generated.dropdown, existing.dropdown || {});" \
|
|
21
|
+
"return Object.assign(generated, existing, " \
|
|
22
|
+
"{ liveSearch: liveSearch, dropdown: dropdown });" \
|
|
20
23
|
"}(#{json}, window.clientSearchConfig || {}));\n"
|
|
21
24
|
end
|
|
22
25
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "cgi"
|
|
4
|
+
|
|
3
5
|
module Jekyll
|
|
4
6
|
module ClientSearch
|
|
5
7
|
# Liquid tag that renders the search form, status/results containers,
|
|
@@ -28,7 +30,10 @@ module Jekyll
|
|
|
28
30
|
|
|
29
31
|
def render(context)
|
|
30
32
|
site = context.registers[:site]
|
|
31
|
-
|
|
33
|
+
return "" if site.nil?
|
|
34
|
+
|
|
35
|
+
config_hash = site.config.fetch("client_search", {})
|
|
36
|
+
return "" if config_hash == false
|
|
32
37
|
return "" unless config_hash["enabled"] != false
|
|
33
38
|
|
|
34
39
|
configuration = Configuration.new(site)
|
|
@@ -73,9 +78,15 @@ module Jekyll
|
|
|
73
78
|
url = configuration.engine_url
|
|
74
79
|
return nil unless url
|
|
75
80
|
|
|
76
|
-
attrs = ["src=\"#{url}\""]
|
|
77
|
-
|
|
78
|
-
|
|
81
|
+
attrs = ["src=\"#{CGI.escapeHTML(url)}\""]
|
|
82
|
+
if configuration.engine_crossorigin
|
|
83
|
+
crossorigin = CGI.escapeHTML(configuration.engine_crossorigin)
|
|
84
|
+
attrs << "crossorigin=\"#{crossorigin}\""
|
|
85
|
+
end
|
|
86
|
+
if configuration.engine_sri
|
|
87
|
+
integrity = CGI.escapeHTML(configuration.engine_sri)
|
|
88
|
+
attrs << "integrity=\"#{integrity}\""
|
|
89
|
+
end
|
|
79
90
|
"<script #{attrs.join(' ')}></script>"
|
|
80
91
|
end
|
|
81
92
|
|
|
@@ -84,7 +95,13 @@ module Jekyll
|
|
|
84
95
|
|
|
85
96
|
scripts = ["<script src=\"#{prefix}/assets/search-embedder-config.js\"></script>"]
|
|
86
97
|
embedder_asset = configuration.query_embedder_asset
|
|
98
|
+
# simplecov:disable branch
|
|
99
|
+
# Defensive guard: semantic_with_embedder? already excludes the "none"
|
|
100
|
+
# type, and both "transformers" and "ollama_api" always return an asset.
|
|
101
|
+
# This branch is unreachable with current embedder types but protects
|
|
102
|
+
# against future types that may have no script asset.
|
|
87
103
|
scripts << "<script src=\"#{prefix}/#{embedder_asset}\"></script>" if embedder_asset
|
|
104
|
+
# simplecov:enable branch
|
|
88
105
|
scripts
|
|
89
106
|
end
|
|
90
107
|
|
data/lib/jekyll/client_search.rb
CHANGED
|
@@ -9,6 +9,7 @@ require_relative "client_search/version"
|
|
|
9
9
|
require_relative "client_search/embedding_configuration"
|
|
10
10
|
require_relative "client_search/live_search_configuration"
|
|
11
11
|
require_relative "client_search/related_configuration"
|
|
12
|
+
require_relative "client_search/dropdown_configuration"
|
|
12
13
|
require_relative "client_search/query_embedder_configuration"
|
|
13
14
|
require_relative "client_search/configuration_accessors"
|
|
14
15
|
require_relative "client_search/configuration"
|
|
@@ -22,4 +23,5 @@ require_relative "client_search/index_cache"
|
|
|
22
23
|
require_relative "client_search/ollama_embedding_adapter"
|
|
23
24
|
require_relative "client_search/generator"
|
|
24
25
|
require_relative "client_search/related_tag"
|
|
26
|
+
require_relative "client_search/dropdown_tag"
|
|
25
27
|
require_relative "client_search/search_tag"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: jekyll-client-search
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Svend Gundestrup
|
|
@@ -46,6 +46,7 @@ files:
|
|
|
46
46
|
- assets/adapters/minisearch.js
|
|
47
47
|
- assets/adapters/semantic.js
|
|
48
48
|
- assets/client-search-base.js
|
|
49
|
+
- assets/client-search-dropdown.js
|
|
49
50
|
- assets/client-search-related.js
|
|
50
51
|
- assets/includes/related-articles.html
|
|
51
52
|
- assets/layouts/post-with-related.html
|
|
@@ -59,6 +60,8 @@ files:
|
|
|
59
60
|
- lib/jekyll/client_search/configuration.rb
|
|
60
61
|
- lib/jekyll/client_search/configuration_accessors.rb
|
|
61
62
|
- lib/jekyll/client_search/document_builder.rb
|
|
63
|
+
- lib/jekyll/client_search/dropdown_configuration.rb
|
|
64
|
+
- lib/jekyll/client_search/dropdown_tag.rb
|
|
62
65
|
- lib/jekyll/client_search/embedder_config_page.rb
|
|
63
66
|
- lib/jekyll/client_search/embedding_configuration.rb
|
|
64
67
|
- lib/jekyll/client_search/generator.rb
|