Skip to content

Commit caaa1fb

Browse files
markdumayclaude
andcommitted
feat: add lazyLoad option to fetch the search index on demand
When enabled via params.modules.flexsearch.lazyLoad, the FlexSearch index data is emitted as a separate per-language JSON file through a new searchindex Hugo output format and fetched on the visitor's first search interaction, instead of being bundled into the core script loaded on every page. Cuts the core bundle by several megabytes on content-heavy sites at the cost of a single fetch on first search. The searchindex output format must be attached to home outputs at the theme level — Hinode includes this wiring from the next release. Sites with a strict Content Security Policy must allow connect-src 'self'; the module declares this in its csp block so mod-csp picks it up automatically. The eager and lazy paths share a single source of truth for index documents (layouts/_partials/utilities/GetSearchDocs.html), so both behave identically with respect to the existing summaryOnly, frontmatter, filter, and canonifyURLs options. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 776e541 commit caaa1fb

10 files changed

Lines changed: 169 additions & 53 deletions

File tree

CLAUDE.md

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,18 +51,26 @@ The module imports `github.com/nextapps-de/flexsearch` and mounts its bundle to
5151
- `layouts/_partials/assets/search-input.html`: Embedded search form HTML structure
5252
- `layouts/_shortcodes/ModalSearch.html`: Modal search dialog structure
5353
- `layouts/_partials/assets/search-meta.html`: Recursive helper to extract frontmatter content for indexing
54+
- `layouts/_partials/utilities/GetSearchDocs.html`: Single source of truth — builds the slice of index documents (consumed by both the eager JS and the lazy JSON output)
55+
- `layouts/index.searchindex.json`: Layout for the `searchindex` output format — emits the index documents as JSON on the home page (lazy mode)
56+
- `layouts/_partials/utilities/GetSearchIndex.html`: Returns the URL of the search-index output (lazy mode)
5457
- `assets/js/modules/flexsearch/flexsearch.index.js`: FlexSearch initialization and search logic
5558

5659
**Search index configuration:**
57-
- The JavaScript file generates a FlexSearch document index at build time from Hugo's page content
60+
- The index documents are built by `GetSearchDocs.html` from Hugo's page content
5861
- Indexes three fields: `title` (forward tokenization), `description`, and `content` (full tokenization)
5962
- Supports optional frontmatter indexing via `flexsearch.frontmatter` parameter
6063
- Can index page summaries instead of full content via `flexsearch.summaryOnly` parameter
6164
- Can use absolute URLs via `flexsearch.canonifyURLs` parameter
6265
- Pages can be excluded with `searchExclude: true` in frontmatter
6366
- Supports optional `indexTitle` parameter to override title in search results
64-
- Each indexed page is emitted as a separate `index.add(...)` statement (a chained
65-
`.add()` expression overflows the minifier's expression-nesting limit on large sites)
67+
- **Eager mode (default):** `flexsearch.index.js` emits one flat `index.add(...)`
68+
statement per page into the core bundle (a chained `.add()` expression
69+
overflows the minifier's expression-nesting limit on large sites)
70+
- **Lazy mode (`flexsearch.lazyLoad`):** the index is emitted as a separate
71+
per-language JSON file via the `searchindex` output format and fetched on
72+
the first search interaction (a normal page render, so page content can be
73+
aggregated safely — unlike a detached resource template)
6674

6775
**Search behavior:**
6876
- Shows up to 5 results across title, description, and content fields
@@ -85,6 +93,7 @@ Module configuration in `config.toml` under `params.modules.flexsearch`:
8593
- `frontmatter` (default: false): Include frontmatter content in search index
8694
- `filter` (default: "params"): Restrict frontmatter scanning to specific key
8795
- `summaryOnly` (default: false): Index page summaries instead of full content
96+
- `lazyLoad` (default: false): Emit the index as a separate JSON file fetched on first search interaction, instead of bundling it into every page
8897
- `localize` (default: true): Enable language-specific search
8998

9099
Navigation configuration under `params.navigation.search`:

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,13 @@ This module supports the following parameters (see the section `params.modules`
4848
| `flexsearch.frontmatter` | false | If set, includes front matter in the page content. The search index function adds all parameters with the name `content`, `heading`, `title`, `preheading` recursively. |
4949
| `flexsearch.filter` | "params" | Restricts the scanned frontmatter variables to the named filter. By default, all front matter variables are scanned. Only applicable when `flexsearch.frontmatter` is set. |
5050
| `flexsearch.summaryOnly` | false | If set, indexes each page's summary instead of its full content. Reduces the size of the generated search index considerably on large sites, at the cost of matching only summary text. |
51+
| `flexsearch.lazyLoad` | false | If set, the search index is emitted as a separate per-language JSON file and fetched on the visitor's first search interaction, instead of being bundled into the core script loaded on every page. See the note below on Content Security Policy. |
52+
53+
> [!NOTE]
54+
> With `flexsearch.lazyLoad` enabled the search index is fetched at runtime. A
55+
> site that sets a strict Content Security Policy must allow `connect-src 'self'`.
56+
> The module declares this directive in its `csp` block, so sites using the
57+
> Hinode CSP module pick it up automatically.
5158
5259
In addition, the module recognizes the following site parameters (see the section `params.navigation` in `config.toml`):.
5360

assets/js/modules/flexsearch/flexsearch.index.js

Lines changed: 55 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
{{- $lazy := site.Params.modules.flexsearch.lazyLoad | default false -}}
12
const search = document.querySelector('.search-input')
23
const suggestions = document.querySelector('.search-suggestions')
34
const background = document.querySelector('.search-background')
@@ -33,57 +34,57 @@ var index = new FlexSearch.Document({
3334
}
3435
});
3536

37+
{{ if $lazy -}}
38+
/*
39+
Lazy mode: the index data is fetched from a standalone JSON resource the
40+
first time the user interacts with search, instead of being bundled into
41+
every page. The data URL is read from the search input's data-search-index
42+
attribute (set by the search-input / ModalSearch layouts).
43+
*/
44+
let indexStatus = 'idle'; // idle | loading | ready | error
45+
46+
function loadIndex() {
47+
if (indexStatus !== 'idle') return;
48+
indexStatus = 'loading';
49+
50+
const url = search.dataset.searchIndex;
51+
if (!url) {
52+
indexStatus = 'error';
53+
console.error('flexsearch: missing data-search-index URL on the search input');
54+
return;
55+
}
56+
57+
fetch(url)
58+
.then((response) => {
59+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
60+
return response.json();
61+
})
62+
.then((docs) => {
63+
for (const doc of docs) index.add(doc);
64+
indexStatus = 'ready';
65+
search.addEventListener('input', showResults, true);
66+
// Honor a query typed while the index was still loading.
67+
if (search.value) showResults.call(search);
68+
})
69+
.catch((err) => {
70+
indexStatus = 'error';
71+
console.error('flexsearch: failed to load search index', err);
72+
});
73+
}
74+
{{- else -}}
3675
/*
3776
Source:
3877
- https://github.com/nextapps-de/flexsearch#index-documents-field-search
3978
- https://raw.githack.com/nextapps-de/flexsearch/master/demo/autocomplete.html
4079
*/
4180
function initIndex() {
42-
// https://discourse.gohugo.io/t/range-length-or-last-element/3803/2
43-
// Note: pages without a title (such as browserconfig.xml) are excluded
44-
{{ $sections := where (where site.Pages "Kind" "section") "Title" "!=" "" }}
45-
{{ $list := where site.RegularPages "Title" "!=" "" | append $sections }}
46-
{{ $list = where $list ".Params.searchExclude" "!=" true }}
47-
{{ $len := (len $list) -}}
48-
49-
{{ if gt $len 0 }}
50-
{{ range $index, $element := sort $list "Title" "asc" -}}
51-
{{ $url := .RelPermalink }}
52-
{{ if site.Params.modules.flexsearch.canonifyURLs }}{{ $url = .Permalink }}{{ end }}
53-
{{ $title := or .Params.indexTitle .LinkTitle .Title }}
54-
{{ if gt (strings.RuneCount $title) 33 }}
55-
{{ $title = print (substr $title 0 30) "..." }}
56-
{{ end }}
57-
{{ if and site.Params.main.titleCase (not $element.Params.exact) }}{{ $title = title $title }}{{ end }}
58-
{{ $content := "" }}
59-
{{ if site.Params.modules.flexsearch.summaryOnly -}}
60-
{{ $content = replaceRE "[{}]" "" (.Summary | plainify) }}
61-
{{- else -}}
62-
{{ $content = replaceRE "[{}]" "" .Plain }}
63-
{{- end }}
64-
{{ if site.Params.modules.flexsearch.frontmatter }}
65-
{{ $key := site.Params.modules.flexsearch.filter | default "params" }}
66-
{{ $val := slice }}
67-
{{ if ne $key "params" }}{{ $val = index .Params $key }}{{ else }}{{ $val = .Params }}{{ end }}
68-
{{ $content = printf "%s %s" (partial "assets/search-meta.html" (dict "key" $key "val" $val)) $content }}
69-
{{ end }}
70-
index.add({
71-
id: {{ $index }},
72-
href: "{{ $url }}",
73-
title: {{ $title | jsonify }},
74-
{{ with .Description -}}
75-
description: {{ . | jsonify }},
76-
{{- else -}}
77-
description: {{ .Summary | plainify | jsonify }},
78-
{{- end }}
79-
content: {{ trim $content " \r\n" | jsonify }}
80-
});
81-
{{ end -}}
82-
{{ end }}
83-
81+
{{- range $doc := partial "utilities/GetSearchDocs.html" . }}
82+
index.add({{ $doc | jsonify }});
83+
{{- end }}
8484
search.addEventListener('input', showResults, true);
8585
}
86-
86+
{{- end }}
87+
8788
function hideSuggestions(e) {
8889
var isClickInsideElement = suggestions.contains(e.target);
8990

@@ -134,7 +135,7 @@ function suggestionFocus(e) {
134135
focusableSuggestions[nextIndex].focus();
135136
}
136137
}
137-
138+
138139
/*
139140
Source:
140141
- https://github.com/nextapps-de/flexsearch#index-documents-field-search
@@ -168,7 +169,7 @@ function showResults() {
168169

169170
suggestions.innerHTML = "";
170171
suggestions.classList.remove('d-none');
171-
172+
172173
// inform user that no results were found
173174
if (flatResults.size === 0 && searchQuery) {
174175
const msg = suggestions.dataset.noResults;
@@ -204,17 +205,24 @@ function showResults() {
204205
if (suggestions.childElementCount == maxResult) break;
205206
}
206207
}
207-
208+
208209
if (search !== null && suggestions !== null) {
209210
document.addEventListener('keydown', inputFocus);
210-
document.addEventListener('keydown', suggestionFocus);
211+
document.addEventListener('keydown', suggestionFocus);
211212
document.addEventListener('click', hideSuggestions);
213+
{{ if $lazy -}}
214+
search.addEventListener('focus', loadIndex, { once: true });
215+
search.addEventListener('click', loadIndex, { once: true });
216+
{{- else -}}
212217
initIndex();
218+
{{- end }}
213219
}
214220

215221
const searchModal = document.getElementById('search-modal')
216222
if (searchModal !== null) {
217223
searchModal.addEventListener('shown.bs.modal', function () {
224+
{{ if $lazy }}loadIndex();
225+
{{ end -}}
218226
const searchInput = document.getElementById('search-input-modal')
219227
if (searchInput !== null) {
220228
searchInput.focus({ focusVisible: true })

config.toml

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,14 @@
1+
[outputFormats]
2+
[outputFormats.searchindex]
3+
mediaType = "application/json"
4+
baseName = "flexsearch-index"
5+
isPlainText = true
6+
notAlternative = true
7+
permalinkable = true
8+
9+
[outputs]
10+
home = ["searchindex"]
11+
112
[module]
213
[module.hugoVersion]
314
extended = true
@@ -39,4 +50,7 @@
3950
frontmatter = false
4051
filter = ""
4152
summaryOnly = false
42-
localize = true
53+
lazyLoad = false
54+
localize = true
55+
[params.modules.flexsearch.csp]
56+
connect-src = ["'self'"]

exampleSite/hugo.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@ baseURL = 'http://example.org/'
22
languageCode = 'en-us'
33
title = 'Test site for mod-flexsearch'
44

5+
[params.modules.flexsearch]
6+
lazyLoad = true
7+
8+
[outputs]
9+
home = ["HTML", "RSS", "searchindex"]
10+
511
[module]
612
replacements = 'github.com/gethinode/mod-flexsearch/v4 -> ../..'
713
[[module.mounts]]

layouts/_partials/assets/search-input.html

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,11 @@
66
-->
77

88
{{- $class := .class -}}
9+
{{- $lazy := site.Params.modules.flexsearch.lazyLoad | default false -}}
910

1011
<div class="d-flex flex-fill ms-md-3{{ with $class }} {{ . }}{{ end }}">
1112
<form class="search flex-fill position-relative me-auto">
12-
<input class="search-input form-control is-search" type="search" placeholder="{{ T "ui_search" }}" aria-label="{{ T "ui_search" }}" autocomplete="off" name="search-input">
13+
<input class="search-input form-control is-search" type="search" placeholder="{{ T "ui_search" }}" aria-label="{{ T "ui_search" }}" autocomplete="off" name="search-input"{{ if $lazy }} data-search-index="{{ partialCached "utilities/GetSearchIndex.html" page page.Language.Lang }}"{{ end }}>
1314
<div class="search-suggestions shadow bg-body rounded d-none" data-no-results="{{ T "ui_no_results" }}"></div>
1415
</form>
1516
</div>
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
{{- /*
2+
Builds the list of documents for the FlexSearch index. Returns a slice of
3+
dicts with the keys: id, href, title, description, content.
4+
5+
This is the single source of truth for index documents — it is consumed
6+
both by the eager path (flexsearch.index.js, inline index.add() calls) and
7+
the lazy path (flexsearch-index-data.json, fetched on demand).
8+
9+
Honors the params.modules.flexsearch settings: canonifyURLs, summaryOnly,
10+
frontmatter, filter. Pages without a title and pages with searchExclude
11+
are omitted.
12+
*/ -}}
13+
{{- $sections := where (where site.Pages "Kind" "section") "Title" "!=" "" -}}
14+
{{- $list := where site.RegularPages "Title" "!=" "" | append $sections -}}
15+
{{- $list = where $list ".Params.searchExclude" "!=" true -}}
16+
{{- $docs := slice -}}
17+
{{- range $index, $element := sort $list "Title" "asc" -}}
18+
{{- $url := .RelPermalink -}}
19+
{{- if site.Params.modules.flexsearch.canonifyURLs }}{{ $url = .Permalink }}{{ end -}}
20+
{{- $title := or .Params.indexTitle .LinkTitle .Title -}}
21+
{{- if gt (strings.RuneCount $title) 33 }}{{ $title = print (substr $title 0 30) "..." }}{{ end -}}
22+
{{- if and site.Params.main.titleCase (not $element.Params.exact) }}{{ $title = title $title }}{{ end -}}
23+
{{- $description := "" -}}
24+
{{- with .Description }}{{ $description = . }}{{ else }}{{ $description = $element.Summary | plainify }}{{ end -}}
25+
{{- $content := "" -}}
26+
{{- if site.Params.modules.flexsearch.summaryOnly -}}
27+
{{- $content = replaceRE "[{}]" "" (.Summary | plainify) -}}
28+
{{- else -}}
29+
{{- $content = replaceRE "[{}]" "" .Plain -}}
30+
{{- end -}}
31+
{{- if site.Params.modules.flexsearch.frontmatter -}}
32+
{{- $key := site.Params.modules.flexsearch.filter | default "params" -}}
33+
{{- $val := slice -}}
34+
{{- if ne $key "params" }}{{ $val = index .Params $key }}{{ else }}{{ $val = .Params }}{{ end -}}
35+
{{- $content = printf "%s %s" (partial "assets/search-meta.html" (dict "key" $key "val" $val)) $content -}}
36+
{{- end -}}
37+
{{- $docs = $docs | append (dict
38+
"id" $index
39+
"href" $url
40+
"title" $title
41+
"description" $description
42+
"content" (trim $content " \r\n")
43+
) -}}
44+
{{- end -}}
45+
{{- return $docs -}}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{{- /*
2+
Returns the URL of the per-language search index — the `searchindex` output
3+
format rendered on the home page. Consumed by the search input layouts to
4+
populate the data-search-index attribute the lazy runtime fetches.
5+
*/ -}}
6+
{{- $url := "" -}}
7+
{{- with site.Home.OutputFormats.Get "searchindex" -}}
8+
{{- $url = .RelPermalink -}}
9+
{{- end -}}
10+
{{- return $url -}}

layouts/_shortcodes/ModalSearch.html

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
{{- $search := partial "utilities/GetSearchConfig.html" (dict "search" true) -}}
2+
{{- $lazy := site.Params.modules.flexsearch.lazyLoad | default false -}}
23

34
{{- if $search.modal }}
45
<div id="search-modal" class="modal fade search-modal" tabindex="-1" aria-labelledby="searchModalLabel" aria-hidden="true" aria-modal="true">
@@ -7,7 +8,7 @@
78
<div class="modal-header">
89
<div class="w-100">
910
<form class="search position-relative me-auto">
10-
<input id="search-input-modal" class="search-input form-control is-search" tabindex="1" type="search" placeholder="{{ T "ui_search" }}..." aria-label="{{ T "ui_search" }}" autocomplete="off">
11+
<input id="search-input-modal" class="search-input form-control is-search" tabindex="1" type="search" placeholder="{{ T "ui_search" }}..." aria-label="{{ T "ui_search" }}" autocomplete="off"{{ if $lazy }} data-search-index="{{ partialCached "utilities/GetSearchIndex.html" page page.Language.Lang }}"{{ end }}>
1112
</form>
1213
</div>
1314
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ T "close" }}"></button>

layouts/index.searchindex.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{{- /*
2+
Renders the FlexSearch index documents as a JSON array. Emitted as the
3+
`searchindex` output format on the home page so the data is built during a
4+
normal page render (where the page context is available) rather than from a
5+
detached resource template.
6+
7+
The lazy runtime fetches this file on the first search interaction. When
8+
lazyLoad is disabled the file is still emitted, but empty — the index is
9+
bundled into the core script instead.
10+
*/ -}}
11+
{{- if site.Params.modules.flexsearch.lazyLoad -}}
12+
{{- (partial "utilities/GetSearchDocs.html" .) | jsonify -}}
13+
{{- else -}}
14+
[]
15+
{{- end -}}

0 commit comments

Comments
 (0)