jekyll-client-search 0.1.0 → 0.2.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 +26 -0
- data/README.developer.md +200 -0
- data/README.md +125 -0
- data/assets/client-search-base.js +53 -1
- data/lib/jekyll/client_search/configuration.rb +28 -5
- data/lib/jekyll/client_search/document_builder.rb +23 -5
- data/lib/jekyll/client_search/generator.rb +6 -2
- data/lib/jekyll/client_search/related_tag.rb +2 -2
- data/lib/jekyll/client_search/runtime_config_page.rb +1 -0
- data/lib/jekyll/client_search/search_tag.rb +2 -2
- data/lib/jekyll/client_search/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 770d678df814030f4576214a39a7faca03f14d889cc284c5976486bc7c22f331
|
|
4
|
+
data.tar.gz: 2eaf824353f2b981ec1c538c3a84b938198960d1092110b6efceb181311ce2ea
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 44e285f7238df3e68523839fb80bdc3f67c25883e3311753b8b67d460a160dcb7e1b9c3c23c9066e04dc57b7d2e951924ebfc55ba205b4913e882b764e0fbff3
|
|
7
|
+
data.tar.gz: e9b48c426a74311b235b33eda93d119ba3864be6d64175640de6971521461226baf5f8c5e88e89a47889beea28feae22833d3ec49a8cf461064e8e7101fab1fc
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
## 0.2.0 — 2026-08-27
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- Generic `passthrough_fields` config option: external plugins declare which document `data` fields to forward into the search index (e.g. `file_type`, `icon_url`, `icon_set` from jekyll-documents)
|
|
9
|
+
- Field renaming in `passthrough_fields`: entries can be strings (same name) or hashes (`{source => target}`) for integration with other search conventions
|
|
10
|
+
- `icon_field` config option (default: `"icon_url"`): tells the browser runtime which field to render as an `<img>` icon before the result title; set to `null` to disable
|
|
11
|
+
- Search index now includes `source` (collection label: `posts`, `pages`, etc.) — always present, set by the generator
|
|
12
|
+
- Result `<article>` elements carry `data-source`, `data-categories`, `data-tags`, plus `data-*` for all passthrough fields (snake_case → data-kebab-case) for CSS-based result customization
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
- Allow whitespace in script/style end tags in `DocumentBuilder#clean` (`</script\s*>` instead of `</script>`) — CodeQL: bad HTML filtering regexp
|
|
16
|
+
- Fix polynomial ReDoS in `SearchTag` and `RelatedTag` syntax regexes — strip markup before matching, remove leading/trailing `\s*` patterns that caused O(n²) backtracking (CodeQL: polynomial regex on uncontrolled data)
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
- Simplified release workflow to tag-push trigger (`push: tags: v*`) — no manual `gh release create` needed
|
|
20
|
+
- Switched to RubyGems trusted publishing (`rubygems/release-gem@v1` with OIDC)
|
|
21
|
+
- Centralized version in `version.rb` as single source of truth — removed `version` field from `package.json`
|
|
22
|
+
- Added `rake version:bump` and `rake version:check_changelog` tasks
|
|
23
|
+
- Added CHANGELOG gate to release workflow (fails if entry missing for the version)
|
|
24
|
+
- Added `npm audit` and `npm outdated` (non-blocking) to CI
|
|
25
|
+
- Replaced pre-commit hook with rubocop-only (fast); added pre-push hook (rubocop + rspec)
|
|
26
|
+
- Dropped reek from quality stack (KISS — marginal value on a small gem)
|
|
27
|
+
- Aligned AGENTS.md to the open convention; added CLAUDE.md and .windsurfrules pointer files
|
|
28
|
+
|
|
3
29
|
## 0.1.0 — 2026-08-25
|
|
4
30
|
|
|
5
31
|
- Add a pluggable search engine architecture with a base runtime and adapters.
|
data/README.developer.md
CHANGED
|
@@ -20,6 +20,57 @@ Both test suites pass with only the committed Wikipedia fixtures and the
|
|
|
20
20
|
committed baseline JSON artifacts. No network access or external services
|
|
21
21
|
are required for the default test run.
|
|
22
22
|
|
|
23
|
+
## Quality checks and git hooks
|
|
24
|
+
|
|
25
|
+
The project uses a focused quality stack: RuboCop (style), bundler-audit
|
|
26
|
+
(security), RSpec (Ruby tests), and `npm test` (JavaScript tests). This
|
|
27
|
+
covers the actionable ground without the maintenance burden of additional
|
|
28
|
+
code-smell tools on a small focused gem.
|
|
29
|
+
|
|
30
|
+
### Rake tasks
|
|
31
|
+
|
|
32
|
+
| Task | What it runs |
|
|
33
|
+
| --- | --- |
|
|
34
|
+
| `rake` (default) | `rake quality` — all checks below |
|
|
35
|
+
| `rake quality` | rubocop + bundler-audit + rspec + npm test |
|
|
36
|
+
| `rake quick` | rubocop + rspec (fast pre-push subset) |
|
|
37
|
+
| `rake rubocop` | RuboCop style check |
|
|
38
|
+
| `rake rubocop_fix` | RuboCop auto-fix |
|
|
39
|
+
| `rake bundler_audit` | `bundle-audit check --update` security scan |
|
|
40
|
+
| `rake spec` | RSpec test suite |
|
|
41
|
+
| `rake npm_test` | JavaScript test suite (`npm test`) |
|
|
42
|
+
| `rake ci` | rspec + rubocop + syntax checks + npm test + gem build |
|
|
43
|
+
|
|
44
|
+
### Git hooks
|
|
45
|
+
|
|
46
|
+
Git hooks are not committed to the repository. Install them locally after
|
|
47
|
+
cloning:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
bin/install-hooks.sh
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
This installs two hooks:
|
|
54
|
+
|
|
55
|
+
| Hook | What it runs | When |
|
|
56
|
+
| --- | --- | --- |
|
|
57
|
+
| `pre-commit` | `rubocop` only (~2s) | Before each commit |
|
|
58
|
+
| `pre-push` | `rubocop + rspec` (~15s) | Before each push |
|
|
59
|
+
|
|
60
|
+
The pre-commit hook is intentionally fast (style only) to avoid bypassing
|
|
61
|
+
with `--no-verify`. The full test suite runs on pre-push and in CI. Skip
|
|
62
|
+
either with `git commit --no-verify` or `git push --no-verify`.
|
|
63
|
+
|
|
64
|
+
### CI checks
|
|
65
|
+
|
|
66
|
+
The [CI workflow](.github/workflows/ci.yml) runs on every push and pull
|
|
67
|
+
request across Ruby 3.2/3.3/3.4 and Node 22/24:
|
|
68
|
+
|
|
69
|
+
- `bundle exec rake ci` (rspec, rubocop, syntax checks, npm test, gem build)
|
|
70
|
+
- `bundle exec bundle-audit check --update` (Ruby dependency security)
|
|
71
|
+
- `npm audit --audit-level=high` (JavaScript dependency security)
|
|
72
|
+
- `npm outdated` (non-blocking — warns about outdated npm packages)
|
|
73
|
+
|
|
23
74
|
## Build-time related articles
|
|
24
75
|
|
|
25
76
|
The optional related-analysis pass writes `search-relations.json` separately
|
|
@@ -202,3 +253,152 @@ bundle exec rspec --tag ollama_integration
|
|
|
202
253
|
The CI pipeline runs `bundle exec rspec` and `npm test` without arXiv
|
|
203
254
|
fixtures or Ollama. The committed baseline JSON and semantic embeddings
|
|
204
255
|
ensure all tests pass in CI without external dependencies.
|
|
256
|
+
|
|
257
|
+
## Releasing
|
|
258
|
+
|
|
259
|
+
Releases are published to RubyGems.org via the
|
|
260
|
+
[`release.yml`](.github/workflows/release.yml) GitHub Actions workflow,
|
|
261
|
+
which triggers automatically when a GitHub Release is published. The
|
|
262
|
+
workflow uses RubyGems trusted publishing (OIDC) — no API key is stored
|
|
263
|
+
in the repository.
|
|
264
|
+
|
|
265
|
+
### Prerequisites (one-time setup)
|
|
266
|
+
|
|
267
|
+
Trusted publishing must be configured on rubygems.org for the gem before
|
|
268
|
+
the first release:
|
|
269
|
+
|
|
270
|
+
1. Log into <https://rubygems.org> and open the gem's page.
|
|
271
|
+
2. Go to **Settings → Trusted Publishers → Add trusted publisher**.
|
|
272
|
+
3. Enter:
|
|
273
|
+
- **Repository**: `gundestrup/jekyll-client-search`
|
|
274
|
+
- **Workflow filename**: `release.yml`
|
|
275
|
+
- **Environment**: `release`
|
|
276
|
+
|
|
277
|
+
If the gem has never been published before, rubygems.org may require a
|
|
278
|
+
one-time manual `gem push` with an API key to create the gem name before
|
|
279
|
+
a trusted publisher can be attached. After that, all subsequent releases
|
|
280
|
+
use trusted publishing automatically.
|
|
281
|
+
|
|
282
|
+
### Version bumping
|
|
283
|
+
|
|
284
|
+
The version lives in [`lib/jekyll/client_search/version.rb`](lib/jekyll/client_search/version.rb)
|
|
285
|
+
and follows [Semantic Versioning](https://semver.org/). The gemspec reads
|
|
286
|
+
from this file — it is the **single source of truth**. No other file
|
|
287
|
+
stores the version (`package.json` is `private: true` and has no version
|
|
288
|
+
field). Use the rake task to bump it:
|
|
289
|
+
|
|
290
|
+
```bash
|
|
291
|
+
bundle exec rake "version:bump[patch]" # 0.1.0 -> 0.1.1 (bug fixes)
|
|
292
|
+
bundle exec rake "version:bump[minor]" # 0.1.0 -> 0.2.0 (new features, backwards compatible)
|
|
293
|
+
bundle exec rake "version:bump[major]" # 0.1.0 -> 1.0.0 (incompatible API changes)
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
The rake task only edits `version.rb`. It does not commit, tag, or update
|
|
297
|
+
the changelog — those are manual steps in the release checklist below.
|
|
298
|
+
|
|
299
|
+
You can verify that the CHANGELOG has an entry for the current version:
|
|
300
|
+
|
|
301
|
+
```bash
|
|
302
|
+
bundle exec rake version:check_changelog
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
This check also runs in the release workflow — the publish will fail if
|
|
306
|
+
`CHANGELOG.md` has no `## X.Y.Z` entry matching the version being released.
|
|
307
|
+
|
|
308
|
+
### Release checklist
|
|
309
|
+
|
|
310
|
+
1. **Ensure the working tree is clean** and on `main`:
|
|
311
|
+
|
|
312
|
+
```bash
|
|
313
|
+
git status
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
2. **Ensure all 80 fixture posts are present** (needed for Ollama
|
|
317
|
+
integration tests and baseline regeneration):
|
|
318
|
+
|
|
319
|
+
```bash
|
|
320
|
+
ruby spec/fixtures/download_arxiv.rb
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
Requires `pdftotext` (`brew install poppler` on macOS). Skip if the
|
|
324
|
+
arXiv posts are already present and unchanged.
|
|
325
|
+
|
|
326
|
+
3. **Run the full test suite locally**, including Ollama integration tests:
|
|
327
|
+
|
|
328
|
+
```bash
|
|
329
|
+
bundle exec rspec
|
|
330
|
+
OLLAMA_INTEGRATION=1 bundle exec rspec spec/ollama_integration_spec.rb
|
|
331
|
+
OLLAMA_INTEGRATION=1 bundle exec rspec spec/llm_injection_spec.rb
|
|
332
|
+
bundle exec rubocop
|
|
333
|
+
npm test
|
|
334
|
+
npm run lint
|
|
335
|
+
gem build jekyll-client-search.gemspec
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
The Ollama integration tests require a running [Ollama](https://ollama.ai/)
|
|
339
|
+
server with the `embeddinggemma:300m` model pulled (`ollama pull
|
|
340
|
+
embeddinggemma:300m`). Do not publish a release until the Ollama
|
|
341
|
+
integration tests pass locally — they are skipped by default and will
|
|
342
|
+
not block CI, so they must be run manually before each release.
|
|
343
|
+
|
|
344
|
+
4. **Bump the version** (see above) and update the `## Unreleased` or new
|
|
345
|
+
`## X.Y.Z — YYYY-MM-DD` section at the top of
|
|
346
|
+
[`CHANGELOG.md`](CHANGELOG.md) with a user-facing summary of changes.
|
|
347
|
+
|
|
348
|
+
5. **Commit the version bump and changelog**:
|
|
349
|
+
|
|
350
|
+
```bash
|
|
351
|
+
git add lib/jekyll/client_search/version.rb CHANGELOG.md
|
|
352
|
+
git commit -m "Release X.Y.Z: <short summary>"
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
6. **Tag the release** with an annotated tag:
|
|
356
|
+
|
|
357
|
+
```bash
|
|
358
|
+
git tag -a vX.Y.Z -m "Release X.Y.Z
|
|
359
|
+
|
|
360
|
+
<one-line summary of notable changes>"
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
7. **Push `main` and the tag** to GitHub — pushing the tag triggers the
|
|
364
|
+
release workflow automatically:
|
|
365
|
+
|
|
366
|
+
```bash
|
|
367
|
+
git push origin main
|
|
368
|
+
git push origin vX.Y.Z
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
No manual `gh release create` is needed. The workflow builds the gem,
|
|
372
|
+
attaches it to a GitHub release, and publishes to RubyGems.
|
|
373
|
+
|
|
374
|
+
8. **Watch the workflow** and verify the gem appears on RubyGems:
|
|
375
|
+
|
|
376
|
+
```bash
|
|
377
|
+
gh run watch --workflow=release.yml
|
|
378
|
+
gem list jekyll-client-search --remote --exact
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
9. **Verify the integration site** builds cleanly against the published
|
|
382
|
+
gem (update the path dependency to the released version in
|
|
383
|
+
`../gundestrup.dk` and run `bundle exec jekyll build`).
|
|
384
|
+
|
|
385
|
+
### What the release workflow does
|
|
386
|
+
|
|
387
|
+
The [`release.yml`](.github/workflows/release.yml) workflow, triggered by
|
|
388
|
+
pushing a `v*` tag:
|
|
389
|
+
|
|
390
|
+
1. Checks out the repository at the release tag (`persist-credentials: false`).
|
|
391
|
+
2. Sets up Ruby 3.4.10.
|
|
392
|
+
3. Verifies the tag name matches `v<gem version>` (fails on mismatch).
|
|
393
|
+
4. Verifies `CHANGELOG.md` has a `## X.Y.Z` entry for the version
|
|
394
|
+
(`rake version:check_changelog` — fails if missing).
|
|
395
|
+
5. Builds the gem (`gem build`).
|
|
396
|
+
6. Creates a GitHub Release and attaches the `.gem` file as a downloadable
|
|
397
|
+
asset (`softprops/action-gh-release@v2`).
|
|
398
|
+
7. Publishes the gem to RubyGems.org using trusted publishing
|
|
399
|
+
(`rubygems/release-gem@v1` with OIDC).
|
|
400
|
+
|
|
401
|
+
CI already validates tests on every push — the release workflow does not
|
|
402
|
+
re-run the test suite. It only builds and publishes. The workflow runs in
|
|
403
|
+
the `release` environment, which must match the environment name
|
|
404
|
+
configured on the trusted publisher.
|
data/README.md
CHANGED
|
@@ -27,6 +27,7 @@ and vector similarity.
|
|
|
27
27
|
- [The search form](#the-search-form)
|
|
28
28
|
- [Rake tasks](#rake-tasks)
|
|
29
29
|
- [Configuration reference](#configuration-reference)
|
|
30
|
+
- [Indexing custom collections](#indexing-custom-collections)
|
|
30
31
|
- [Architecture](#architecture)
|
|
31
32
|
- [Embeddings and incremental indexing](#embeddings-and-incremental-indexing)
|
|
32
33
|
- [Related articles reference](#related-articles-reference)
|
|
@@ -215,6 +216,89 @@ client_search:
|
|
|
215
216
|
For manual `<script>` setup (engine-specific HTML), see
|
|
216
217
|
[Browser usage reference](#browser-usage-reference).
|
|
217
218
|
|
|
219
|
+
### Customizing search results with CSS
|
|
220
|
+
|
|
221
|
+
Each search result is rendered as an `<article>` with data attributes that
|
|
222
|
+
expose the document's metadata for CSS-based styling:
|
|
223
|
+
|
|
224
|
+
| Attribute | Value | Source |
|
|
225
|
+
| --- | --- | --- |
|
|
226
|
+
| `data-source` | Collection label (`posts`, `pages`, `documents`, …) | Generator (always present) |
|
|
227
|
+
| `data-categories` | Space-separated categories | Front matter |
|
|
228
|
+
| `data-tags` | Space-separated tags | Front matter |
|
|
229
|
+
| `data-*` | Any passthrough field (e.g. `data-file-type`, `data-icon-set`) | Config-driven |
|
|
230
|
+
|
|
231
|
+
**Passthrough fields:** External plugins can expose their data in search
|
|
232
|
+
results by listing field names in `passthrough_fields`. Each listed field is
|
|
233
|
+
forwarded from the document's front matter / data into the search index and
|
|
234
|
+
rendered as a `data-*` attribute on the result `<article>` (field names are
|
|
235
|
+
converted from `snake_case` to `data-kebab-case`).
|
|
236
|
+
|
|
237
|
+
**Field renaming:** Entries can be strings (same name) or hashes (rename
|
|
238
|
+
`{source => target}`) for integration with other search conventions:
|
|
239
|
+
|
|
240
|
+
```yaml
|
|
241
|
+
client_search:
|
|
242
|
+
passthrough_fields:
|
|
243
|
+
- file_type: doctype # index as "doctype" instead of "file_type"
|
|
244
|
+
- icon_url: thumbnail # index as "thumbnail" instead of "icon_url"
|
|
245
|
+
- icon_set # stays as "icon_set"
|
|
246
|
+
icon_field: thumbnail # must match the target name
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
**Auto-injection:** When jekyll-documents is installed and `documents` is
|
|
250
|
+
in the search collections, `file_type`, `icon_url`, and `icon_set` are
|
|
251
|
+
auto-injected into `passthrough_fields` — no manual config needed.
|
|
252
|
+
|
|
253
|
+
**Icon rendering:** When the configured `icon_field` (default: `"icon_url"`)
|
|
254
|
+
is present in a result, an `<img class="client-search-result-icon">` is
|
|
255
|
+
rendered before the title. Set `icon_field: null` to disable.
|
|
256
|
+
|
|
257
|
+
**Default icon sizing:** Icons are sized to `1em` (matching the heading's
|
|
258
|
+
font size) with `vertical-align: middle` and a small right margin — they
|
|
259
|
+
auto-scale with the text. The inline styles are self-contained (no CSS
|
|
260
|
+
dependency), so they work with any framework (Bulma, Bootstrap, Tailwind).
|
|
261
|
+
|
|
262
|
+
Override with CSS:
|
|
263
|
+
|
|
264
|
+
```css
|
|
265
|
+
/* Make icons larger */
|
|
266
|
+
.client-search-result-icon { width: 1.5em; height: 1.5em; }
|
|
267
|
+
|
|
268
|
+
/* Fixed pixel sizes (if jekyll-documents' documents.css is included) */
|
|
269
|
+
.client-search-result-icon.icon-x2 { width: 32px; height: 32px; }
|
|
270
|
+
|
|
271
|
+
/* Theme-aware sizing using jekyll-documents' icon_set */
|
|
272
|
+
.client-search-result[data-icon-set="color"] .client-search-result-icon { width: 1.5em; }
|
|
273
|
+
.client-search-result[data-icon-set="ultra-minimal"] .client-search-result-icon { width: 1em; }
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
**CSS examples:**
|
|
277
|
+
|
|
278
|
+
```css
|
|
279
|
+
/* Show a different border color for posts vs pages */
|
|
280
|
+
.client-search-result[data-source="posts"] { border-left: 3px solid #3273dc; }
|
|
281
|
+
.client-search-result[data-source="pages"] { border-left: 3px solid #48c774; }
|
|
282
|
+
|
|
283
|
+
/* Add a badge showing the file type via CSS (no icon_url needed) */
|
|
284
|
+
.client-search-result[data-file-type="pdf"]::before {
|
|
285
|
+
content: "PDF";
|
|
286
|
+
background: #ff0000;
|
|
287
|
+
color: white;
|
|
288
|
+
padding: 0 0.3em;
|
|
289
|
+
font-size: 0.7em;
|
|
290
|
+
margin-right: 0.3em;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/* Show categories as tags */
|
|
294
|
+
.client-search-result[data-categories~="travel"] .title::after {
|
|
295
|
+
content: " ✈";
|
|
296
|
+
}
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
This enables per-source icons, file-type badges, category indicators, and
|
|
300
|
+
tag-based styling — all through CSS, with no JavaScript changes needed.
|
|
301
|
+
|
|
218
302
|
## Rake tasks
|
|
219
303
|
|
|
220
304
|
The gem ships reference layouts and includes that you can copy into your
|
|
@@ -343,6 +427,47 @@ Each document in the index contains:
|
|
|
343
427
|
}
|
|
344
428
|
```
|
|
345
429
|
|
|
430
|
+
## Indexing custom collections
|
|
431
|
+
|
|
432
|
+
By default only `posts` are indexed. Add any Jekyll collection to the
|
|
433
|
+
`collections` list to include it in the search index:
|
|
434
|
+
|
|
435
|
+
```yaml
|
|
436
|
+
client_search:
|
|
437
|
+
collections:
|
|
438
|
+
- posts
|
|
439
|
+
- documents
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
Each document in the index contains `id`, `title`, `url`, `excerpt`,
|
|
443
|
+
`content`, `categories`, `tags`, and normalized date fields. Collections
|
|
444
|
+
from other plugins work as long as their documents expose these fields.
|
|
445
|
+
|
|
446
|
+
### Integration with jekyll-documents
|
|
447
|
+
|
|
448
|
+
[jekyll-documents](https://github.com/gundestrup/jekyll-documents) turns
|
|
449
|
+
files in `assets/documents/` into a browsable `documents` collection with
|
|
450
|
+
titles, categories, and searchable content baked in at build time. To
|
|
451
|
+
include uploaded documents in search results alongside posts:
|
|
452
|
+
|
|
453
|
+
```yaml
|
|
454
|
+
# _config.yml
|
|
455
|
+
plugins:
|
|
456
|
+
- jekyll-documents
|
|
457
|
+
- jekyll-client-search
|
|
458
|
+
|
|
459
|
+
client_search:
|
|
460
|
+
collections:
|
|
461
|
+
- posts
|
|
462
|
+
- documents
|
|
463
|
+
related:
|
|
464
|
+
enabled: true
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
No additional configuration is needed — `jekyll-documents` bakes
|
|
468
|
+
`categories` (plural array) and searchable `content` into each document
|
|
469
|
+
so the `DocumentBuilder` can index them directly.
|
|
470
|
+
|
|
346
471
|
## Architecture
|
|
347
472
|
|
|
348
473
|
The plugin uses a base runtime + adapter architecture where the base owns
|
|
@@ -58,6 +58,12 @@
|
|
|
58
58
|
return;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
var coreFields = [
|
|
62
|
+
"id", "title", "url", "excerpt", "content",
|
|
63
|
+
"categories", "tags", "categoriesText", "tagsText",
|
|
64
|
+
"date", "date_timestamp", "embedding"
|
|
65
|
+
];
|
|
66
|
+
|
|
61
67
|
function normalize(entry) {
|
|
62
68
|
var id = entry.id || entry.url;
|
|
63
69
|
if (!id) {
|
|
@@ -78,9 +84,20 @@
|
|
|
78
84
|
categoriesText: categories.join(" "),
|
|
79
85
|
tagsText: tags.join(" ")
|
|
80
86
|
};
|
|
87
|
+
if (entry.source) {
|
|
88
|
+
normalized.source = entry.source;
|
|
89
|
+
}
|
|
81
90
|
if (Array.isArray(entry.embedding) && entry.embedding.length > 0) {
|
|
82
91
|
normalized.embedding = entry.embedding;
|
|
83
92
|
}
|
|
93
|
+
Object.keys(entry).forEach(function (key) {
|
|
94
|
+
if (coreFields.indexOf(key) === -1 && key !== "source" && !(key in normalized)) {
|
|
95
|
+
var value = entry[key];
|
|
96
|
+
if (value !== null && value !== undefined && value !== "") {
|
|
97
|
+
normalized[key] = value;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
});
|
|
84
101
|
return normalized;
|
|
85
102
|
}
|
|
86
103
|
|
|
@@ -143,17 +160,52 @@
|
|
|
143
160
|
var url = safeUrl(entry.url);
|
|
144
161
|
|
|
145
162
|
article.className = "box client-search-result";
|
|
163
|
+
if (entry.source) {
|
|
164
|
+
article.dataset.source = entry.source;
|
|
165
|
+
}
|
|
166
|
+
if (entry.categories.length) {
|
|
167
|
+
article.dataset.categories = entry.categories.join(" ");
|
|
168
|
+
}
|
|
169
|
+
if (entry.tags.length) {
|
|
170
|
+
article.dataset.tags = entry.tags.join(" ");
|
|
171
|
+
}
|
|
172
|
+
Object.keys(entry).forEach(function (key) {
|
|
173
|
+
if (coreFields.indexOf(key) !== -1 || key === "source") {
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
var value = entry[key];
|
|
177
|
+
if (typeof value === "string" || typeof value === "number") {
|
|
178
|
+
article.dataset[toCamelCase(key)] = String(value);
|
|
179
|
+
}
|
|
180
|
+
});
|
|
146
181
|
heading.className = "title is-4";
|
|
147
182
|
titleLink.href = url;
|
|
148
183
|
titleLink.textContent = entry.title;
|
|
184
|
+
heading.appendChild(titleLink);
|
|
185
|
+
var iconField = options.iconField;
|
|
186
|
+
if (iconField && entry[iconField]) {
|
|
187
|
+
var icon = document.createElement("img");
|
|
188
|
+
icon.className = "client-search-result-icon";
|
|
189
|
+
icon.src = safeUrl(entry[iconField]);
|
|
190
|
+
icon.alt = entry.file_type || entry.source || "";
|
|
191
|
+
icon.loading = "lazy";
|
|
192
|
+
icon.style.width = "1em";
|
|
193
|
+
icon.style.height = "1em";
|
|
194
|
+
icon.style.verticalAlign = "middle";
|
|
195
|
+
icon.style.marginRight = "0.3em";
|
|
196
|
+
heading.insertBefore(icon, titleLink);
|
|
197
|
+
}
|
|
149
198
|
excerpt.textContent = entry.excerpt;
|
|
150
199
|
readMore.href = url;
|
|
151
200
|
readMore.textContent = "Read more";
|
|
152
|
-
heading.appendChild(titleLink);
|
|
153
201
|
article.append(heading, excerpt, readMore);
|
|
154
202
|
return article;
|
|
155
203
|
}
|
|
156
204
|
|
|
205
|
+
function toCamelCase(key) {
|
|
206
|
+
return key.replace(/_([a-z])/g, function (_, char) { return char.toUpperCase(); });
|
|
207
|
+
}
|
|
208
|
+
|
|
157
209
|
function sortMatches(matches) {
|
|
158
210
|
var sortControl = document.querySelector(options.sortControl);
|
|
159
211
|
var sortOrder = sortControl ? sortControl.value : options.sort;
|
|
@@ -23,6 +23,8 @@ module Jekyll
|
|
|
23
23
|
"collections" => ["posts"],
|
|
24
24
|
"include_pages" => false,
|
|
25
25
|
"copy_runtime" => true,
|
|
26
|
+
"passthrough_fields" => [],
|
|
27
|
+
"icon_field" => "icon_url",
|
|
26
28
|
"embedding" => {
|
|
27
29
|
"enabled" => false,
|
|
28
30
|
"model" => "embeddinggemma:300m",
|
|
@@ -111,11 +113,7 @@ module Jekyll
|
|
|
111
113
|
end
|
|
112
114
|
|
|
113
115
|
def collections
|
|
114
|
-
Array(@values["collections"])
|
|
115
|
-
.compact
|
|
116
|
-
.map(&:to_s)
|
|
117
|
-
.reject(&:empty?)
|
|
118
|
-
.uniq
|
|
116
|
+
Array(@values["collections"]).compact.map(&:to_s).reject(&:empty?).uniq
|
|
119
117
|
end
|
|
120
118
|
|
|
121
119
|
def include_pages?
|
|
@@ -126,6 +124,31 @@ module Jekyll
|
|
|
126
124
|
@values["copy_runtime"] != false
|
|
127
125
|
end
|
|
128
126
|
|
|
127
|
+
def passthrough_fields
|
|
128
|
+
Array(@values["passthrough_fields"]).compact.each_with_object([]) do |e, f|
|
|
129
|
+
e.is_a?(Hash) ? add_hash_fields(e, f) : add_string_field(e, f)
|
|
130
|
+
end.uniq
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def add_hash_fields(hash, fields)
|
|
134
|
+
hash.each do |s, t|
|
|
135
|
+
fields << [s.to_s, t.to_s] unless s.to_s.empty? || t.to_s.empty?
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def add_string_field(entry, fields)
|
|
140
|
+
fields << [entry.to_s, entry.to_s] unless entry.to_s.empty?
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def icon_field
|
|
144
|
+
v = @values["icon_field"]
|
|
145
|
+
v.nil? || v == false ? nil : v.to_s
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def runtime_icon_field
|
|
149
|
+
icon_field if passthrough_fields.map(&:last).include?(icon_field)
|
|
150
|
+
end
|
|
151
|
+
|
|
129
152
|
private
|
|
130
153
|
|
|
131
154
|
def validate_query_embedder!
|
|
@@ -5,11 +5,21 @@ module Jekyll
|
|
|
5
5
|
# Normalizes Jekyll documents and pages into the flat hash shape that the
|
|
6
6
|
# search index JSON emits.
|
|
7
7
|
class DocumentBuilder
|
|
8
|
-
def from_document(document)
|
|
8
|
+
def from_document(document, source: nil, passthrough_fields: [])
|
|
9
9
|
url = document.url.to_s
|
|
10
10
|
return if url.empty?
|
|
11
11
|
|
|
12
12
|
data = document.data
|
|
13
|
+
core_fields(document, data, url)
|
|
14
|
+
.compact
|
|
15
|
+
.merge("source" => source)
|
|
16
|
+
.merge(passthrough(data, passthrough_fields))
|
|
17
|
+
.compact
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def core_fields(document, data, url)
|
|
13
23
|
{
|
|
14
24
|
"id" => url,
|
|
15
25
|
"title" => clean(data["title"] || "Untitled"),
|
|
@@ -20,10 +30,18 @@ module Jekyll
|
|
|
20
30
|
"tags" => normalize_list(data["tags"]),
|
|
21
31
|
"date" => normalized_date(document),
|
|
22
32
|
"date_timestamp" => normalized_timestamp(document)
|
|
23
|
-
}
|
|
33
|
+
}
|
|
24
34
|
end
|
|
25
35
|
|
|
26
|
-
|
|
36
|
+
def passthrough(data, fields)
|
|
37
|
+
fields.each_with_object({}) do |(source, target), result|
|
|
38
|
+
value = data[source]
|
|
39
|
+
next if value.nil?
|
|
40
|
+
next if value.is_a?(String) && value.empty?
|
|
41
|
+
|
|
42
|
+
result[target] = value
|
|
43
|
+
end
|
|
44
|
+
end
|
|
27
45
|
|
|
28
46
|
def excerpt_for(document)
|
|
29
47
|
document.excerpt if document.respond_to?(:excerpt)
|
|
@@ -49,8 +67,8 @@ module Jekyll
|
|
|
49
67
|
|
|
50
68
|
def clean(value)
|
|
51
69
|
cleaned = value.to_s
|
|
52
|
-
.gsub(%r{<script\b[^>]*>.*?</script
|
|
53
|
-
.gsub(%r{<style\b[^>]*>.*?</style
|
|
70
|
+
.gsub(%r{<script\b[^>]*>.*?</script\s*>}mi, " ")
|
|
71
|
+
.gsub(%r{<style\b[^>]*>.*?</style\s*>}mi, " ")
|
|
54
72
|
.gsub(/\{%.*?%\}/m, " ")
|
|
55
73
|
.gsub(/\{\{.*?\}\}/m, " ")
|
|
56
74
|
.gsub(/!\[[^\]]*\]\([^)]*\)/, " ")
|
|
@@ -90,19 +90,23 @@ module Jekyll
|
|
|
90
90
|
end
|
|
91
91
|
|
|
92
92
|
def collection_documents(site, configuration, builder)
|
|
93
|
+
fields = configuration.passthrough_fields
|
|
93
94
|
configuration.collections.flat_map do |label|
|
|
94
95
|
collection = label == "posts" ? site.posts : site.collections[label]
|
|
95
96
|
next [] unless collection
|
|
96
97
|
|
|
97
|
-
collection.docs.filter_map
|
|
98
|
+
collection.docs.filter_map do |document|
|
|
99
|
+
builder.from_document(document, source: label, passthrough_fields: fields)
|
|
100
|
+
end
|
|
98
101
|
end
|
|
99
102
|
end
|
|
100
103
|
|
|
101
104
|
def page_documents(site, builder, configuration)
|
|
105
|
+
fields = configuration.passthrough_fields
|
|
102
106
|
site.pages
|
|
103
107
|
.reject { |page| page.url == "/#{configuration.output}" }
|
|
104
108
|
.select { |page| page.data["title"] }
|
|
105
|
-
.filter_map { |page| builder.from_document(page) }
|
|
109
|
+
.filter_map { |page| builder.from_document(page, source: "pages", passthrough_fields: fields) }
|
|
106
110
|
end
|
|
107
111
|
|
|
108
112
|
def add_runtime_asset(site, configuration)
|
|
@@ -12,11 +12,11 @@ module Jekyll
|
|
|
12
12
|
# When +related.enabled+ is false the tag renders nothing, so it is safe
|
|
13
13
|
# to leave in a layout even when the feature is off.
|
|
14
14
|
class RelatedTag < Liquid::Tag
|
|
15
|
-
SYNTAX = /\A
|
|
15
|
+
SYNTAX = /\A(sort:(\w+))?\s*(no_scripts)?\z/
|
|
16
16
|
|
|
17
17
|
def initialize(tag_name, markup, tokens)
|
|
18
18
|
super
|
|
19
|
-
@markup = markup.to_s
|
|
19
|
+
@markup = markup.to_s.strip
|
|
20
20
|
unless (match = @markup.match(SYNTAX))
|
|
21
21
|
raise Liquid::SyntaxError,
|
|
22
22
|
"related_articles: invalid syntax. Use {% related_articles %}, " \
|
|
@@ -12,6 +12,7 @@ module Jekyll
|
|
|
12
12
|
"liveSearch" => configuration.live_search_config
|
|
13
13
|
}
|
|
14
14
|
defaults["relatedUrl"] = index_url(site, configuration.related_output) if configuration.related_enabled?
|
|
15
|
+
defaults["iconField"] = configuration.runtime_icon_field if configuration.runtime_icon_field
|
|
15
16
|
json = JSON.generate(defaults)
|
|
16
17
|
self.content = "window.clientSearchConfig = (function (generated, existing) {" \
|
|
17
18
|
"var liveSearch = Object.assign({}, generated.liveSearch, existing.liveSearch || {});" \
|
|
@@ -12,11 +12,11 @@ module Jekyll
|
|
|
12
12
|
#
|
|
13
13
|
# When client_search is disabled the tag renders nothing.
|
|
14
14
|
class SearchTag < Liquid::Tag
|
|
15
|
-
SYNTAX = /\A
|
|
15
|
+
SYNTAX = /\A(scripts_only|no_scripts)?\z/
|
|
16
16
|
|
|
17
17
|
def initialize(tag_name, markup, tokens)
|
|
18
18
|
super
|
|
19
|
-
@markup = markup.to_s
|
|
19
|
+
@markup = markup.to_s.strip
|
|
20
20
|
unless (match = @markup.match(SYNTAX))
|
|
21
21
|
raise Liquid::SyntaxError,
|
|
22
22
|
"search_form: invalid syntax. Use {% search_form %}, " \
|