@nera-static/plugin-search
v2.3.1
Published
A plugin for the Nera static site generator that builds a JSON search index and provides a client-side search script.
Maintainers
Readme
@nera-static/plugin-search
A plugin for the Nera static site generator that adds a lightweight client-side search functionality. It builds a searchable JSON index from your content and provides a ready-to-use search interface – all without requiring a backend.
📖 Documentation: nera.js.org
✨ Features
- Creates a
search-index.jsonfrom page content and metadata - Fully client-side – no backend or JavaScript framework needed
- Configurable search fields (e.g.,
title,excerpt,description) - Option to strip HTML from content before indexing
- Ships a client-side search script, published on request
- Includes ready-to-use Pug template with BEM-compatible markup
- Supports multiple search inputs per page
- Optional per-language indexes, so each language searches only its own content
- Indexes only pages the generator will actually render
- Full compatibility with Nera v4.3.0+
🚀 Installation
Install the plugin in your Nera project:
npm install @nera-static/plugin-searchThe plugin is automatically detected and run during the render process.
⚙️ Configuration
Configure the plugin via config/search.yaml. The file is optional — every key
below falls back to the default shown, so the plugin works with no config at
all. This block is the defaults:
output_filename: search-index.json
fields:
- title
- excerpt
- content
- href
strip_html: true
# Opt-in: one index per language instead of one for the whole site
group_by_lang: false
# Opt-out: false drops the template's own h1 when the page brings its own
show_title: trueField Notes
fields: the metadata fields to include in each index entry. Setting it replaces the default list rather than extending it — list every field you want, including the two that carry the search itself:contentis the page's rendered Markdown body. Omit it fromfieldsand the index holds titles only, so body-text queries match nothing.hrefis the result's link. Omit it and results render with no destination.
- Any other frontmatter key works —
description,keywords,tags— as long as the page defines it. A field whose value is empty or missing on a page is left off that entry rather than stored as"". strip_html: Iftrue, HTML is removed from content before indexing.- Elements marked
data-search-ignoreare left out ofcontent, along with everything inside them, whateverstrip_htmlsays. Use it for UI text that is part of the page body but not part of what it says, such as the file name and Copy button that@nera-static/plugin-code-blocks(1.1.0 and later) puts in each code block's bar. In Markdown you can write the attribute on raw HTML:<aside data-search-ignore>…</aside>. group_by_lang: Iftrue, pages are indexed permeta.lang. See Multilingual sites.show_title: Iffalse, the shippedsearch.pugleaves out itsh1.search__title. Any other value, or no key, keeps it. See Heading and label.
🧩 Usage
The search markup is Pug, and belongs in a view — a layout or a partial
in your views folder. Nera renders pages/**/*.md as Markdown, so Pug pasted
into a Markdown page is published as literal text, with no error and no warning.
The paths below are for a site scaffolded with nera new, whose presentation
lives in theme/views/ and theme/assets/. On an older site that renders from
root views/ and assets/, drop the theme/ prefix — npx nera-search picks
the right destination automatically, and so does the index (see
Generated Output).
1. Publish the template
npx nera-searchThis copies search.pug to theme/views/vendor/plugin-search/ and the client
script to theme/assets/js/search.js. See Template Publishing below for the
skip-if-exists rule and --force.
2. Include it from a view
//- theme/views/pages/search.pug
extends ../layouts/layout
block content
main
article!= content
include /vendor/plugin-search/search.puglayouts/layout is the base layout nera new scaffolds; extend your own base
layout if it is named differently. That layout leaves the page's main to the
view (as the scaffolded pages/default.pug does), so wrap the content in one, or
nera build --check warns a11y-main on the search page. The leading slash makes the include resolve
from your views folder (theme/views/) regardless of where the including file
sits — Nera compiles templates with basedir set, from v4.3.0 on, and there is
no theme/ or views/ segment in the path. A relative
include ../vendor/plugin-search/search.pug also works, but only from a view
exactly one level below the views folder.
3. Point a Markdown page at that view
---
title: Search
layout: pages/search.pug
---
Search across the documentation. Results are generated at build time.Only the frontmatter and prose live here. A page without a layout key is
skipped by the generator entirely — and, since v2.0.0, left out of the index too.
Heading and label
The shipped template is built to pass nera build --check without a11y-*
warnings:
The input has an accessible name. It carries
aria-label=t('search_label'), falling back toSearchwhen the site has no translation for that key (or the template is rendered outside a build). To translate it, add asearch_labelkey to the site's translations, e.g. for Germansearch_label: Suche.One
h1per page. By default the template renders its ownh1.search__title, which suits a page whose Markdown has no heading, as in step 3. If the page brings its ownh1— usually a# Searchheading in the Markdown — turn the template's off, ornera build --checkwarnsa11y-h1:# config/search.yaml show_title: false--- title: Search layout: pages/search.pug --- # Search Search across the documentation.
The template's other two texts translate the same way, each falling back to
English: search_title (the h1, default Search) and search_placeholder
(default Search...). A placeholder is not an accessible name, which is why
the aria-label is there as well.
Writing your own markup
You do not have to use the shipped template. Any input carrying
data-search-input and data-results is picked up by search.js, so a view of
your own works the same way:
input.search__input(
type="search",
placeholder="Search...",
data-search-input,
data-search-index=(meta && meta.searchIndexPath) || (app && app.searchIndexPath),
data-results="[data-search__results]"
)
ul.search__results(data-search__results)
script(src="/js/search.js")Keep the data-search-index attribute: it carries the configured
output_filename, and the page's own language when group_by_lang is on.
Multiple search inputs per page work as long as each points [data-results] at
a different list.
The plugin writes the index into your source assets folder, and Nera copies
that folder into public/ at the end of the render — so the built site serves
it from /<output_filename>, /search-index.json by default. See
Generated Output for what this means for version control.
Search result markup
Each match renders as one list item:
<li class="search__item">
<a class="search__link" href="/start">Getting started</a>
<p class="search__description">...how to <span class="search__highlight">install</span> Nera...</p>
</li>The description is the entry's excerpt, then its description, then a snippet
built around the match, whichever exists first.
🌍 Multilingual sites
By default every page lands in one index, so the search box on a German page
also returns English and Spanish hits. Enable group_by_lang to give each
language an index of its own:
group_by_lang: trueA page's language is meta.lang, set in its frontmatter:
---
title: Erste Schritte
layout: pages/default.pug
lang: de
---Pages without a lang belong to the site's default language — lang in
config/app.yaml, itself defaulting to en. Set that key deliberately: it
decides which language keeps the unsuffixed filename.
pages/index.md lang: en ─┐
pages/imprint.md (no lang) ─┴─→ theme/assets/search-index.json
pages/de/index.md lang: de ──→ theme/assets/search-index.de.json
pages/es/index.md lang: es ──→ theme/assets/search-index.es.jsonFilenames
The language code goes before the final extension, and the default language keeps the configured filename unchanged — so the URL your site already serves stays valid:
| output_filename | en (default) | de |
|---|---|---|
| search-index.json | /search-index.json | /search-index.de.json |
| data/pages.json | /data/pages.json | /data/pages.de.json |
| searchdata | /searchdata | /searchdata.de |
Template and client
The shipped search.pug emits data-search-index from meta.searchIndexPath,
which is the index for the page's own language, and search.js fetches that
URL. Nothing in your own markup needs to change.
input.search__input(
data-search-input,
data-search-index=(meta && meta.searchIndexPath) || (app && app.searchIndexPath),
data-results="[data-search__results]"
)If your site published templates before upgrading, its vendored search.pug and
theme/assets/js/search.js keep requesting /search-index.json, and search falls
back to the default language on every page. Refresh them with
npx nera-search --force — but only if you never edited them: --force
overwrites your copies wholesale. If they are customised, merge the two changes
by hand instead:
search.pug: adddata-search-index=(meta && meta.searchIndexPath) || (app && app.searchIndexPath)to the input.search.js: fetchinput.dataset.searchIndex || '/search-index.json', per input rather than once per page.
Data exposed
app.searchIndexPathkeeps its existing string shape and points at the default language's index.app.searchIndexPathsis{ en: '/search-index.json', de: '/search-index.de.json', … }. Present in both modes.meta.searchIndexPathis added to every page, only whengroup_by_langis enabled.app.searchShowTitleisfalsewhenconfig/search.yamlsetsshow_title: false,trueotherwise. Present in both modes.- Each index entry carries a
langfield, again only when grouping is on. The shipped client does not need it — the split already scopes results — but it lets a custom client load one index and filter, and it makes a misfiled page obvious when you open the JSON.
Pages generated by other plugins
Plugins run in sequence and hand pagesData on to the next one, so only pages
that exist before plugin-search runs get indexed. Plugins are ordered
alphabetically by default, which puts plugin-search ahead of
plugin-tags — its generated tag overview pages would be missing from the
index entirely. Run search last in config/plugin-order.yaml:
plugin-order:
- end:
- plugin-searchThis matters more on a multilingual site, where those generated pages carry
their own meta.lang and would otherwise be absent from every language's
index.
If a search box comes up empty
- Every page returns default-language results. The vendored template is
stale and emits no
data-search-index. Re-runnpx nera-search --force. - The request 404s. Your own template hardcodes an index URL that no
longer exists — read it from
meta.searchIndexPathinstead. - A page's hits are in the wrong language. Check its frontmatter
langagainst thelangfield on its entry in the JSON; a typo makes it its own language, and produces an index file named after the typo.
🛠️ Template Publishing
To customize the default view:
npx nera-searchThis will copy:
theme/views/vendor/plugin-search/search.pugto your local project. You can now edit or extend the search markup freely.
The command also copies the search.js file to theme/assets/js/. It handles DOM bindings and result generation.
Both destinations are theme-aware (requires @nera-static/plugin-utils
= 1.5.0). On a themed site — one with a local
theme/folder — the template lands intheme/views/vendor/plugin-search/and the script intheme/assets/js/, where the build actually renders and serves from. On a legacy site without a theme they fall back to the rootviews/andassets/. Either waysearch.pugstill references/js/search.js, since assets are served from the site root in both layouts.
Both steps skip if their destination already exists, so re-running the
command never discards your edits. For the template the check is on the
directory: if theme/views/vendor/plugin-search/ exists, nothing in it is copied
— a deleted or newly added template is not restored, and the command still
exits 0. For the script it is the file js/search.js itself. To deliberately
overwrite both with the packaged versions:
npx nera-search --forceA template change only reaches your site when you re-publish. Upgrading the package is safe but does nothing to
theme/views/vendor/plugin-search/ortheme/assets/js/search.js— the published copies are yours, and are left alone. v2.0.0 changed the class namessearch.jsemits, so a site that published before it keeps the old markup until it runs--force. That discards local edits wholesale, so diff first if you have customised either file.
If your site published templates before upgrading to v2.3.0, its vendored
search.pug has no aria-label, ignores show_title and does not translate
its texts. Refresh it with
npx nera-search --force — but only if you never edited it. Otherwise make
the two changes by hand:
- function searchText(key, fallback) { var text = typeof t === 'function' && t(key); return typeof text === 'string' && text.trim() && text !== key ? text : fallback }
- var searchLabel = searchText('search_label', 'Search')
.section.search
if !(app && app.searchShowTitle === false)
h1.search__title= searchText('search_title', 'Search')
input.search__input(
type="search",
placeholder=searchText('search_placeholder', 'Search...'),
aria-label=searchLabel,
…
)🎨 Styling
The template and the client script emit these class names:
| Class | Emitted by | On |
|---|---|---|
| .section | search.pug | the wrapper |
| .search | search.pug | the wrapper |
| .search__title | search.pug | the heading — only while show_title is not false |
| .search__input | search.pug | the search field |
| .search__results | search.pug | the results list |
| .search__item | search.js | one result |
| .search__link | search.js | the result's link |
| .search__description | search.js | the excerpt or match snippet |
| .search__highlight | search.js | the matched term inside a snippet |
The last four are written by search.js at query time, not by the
template — there is no markup in search.pug to read them from. The plugin
ships no CSS, so .search__highlight is invisible until you style it.
These names are a public contract. Sites style them from their own stylesheets, so renaming one is a breaking change and ships as a major. v2.0.0 is exactly that: the client previously emitted
list-none, emptyclassattributes and abg-yellow-100highlight.
📊 Generated Output
theme/assets/<output_filename>: contains all indexed page data. The filename followsoutput_filenameinconfig/search.yaml(defaultsearch-index.json), so it is not fixed atsearch-index.json. Withgroup_by_langenabled there is one such file per language.The folder is the site's assets folder as Nera resolves it:
theme/assets/on a site with atheme/folder, rootassets/on an older one, or whateverfolders.assetsin yourconfig/app.yamlnames — the last on Nera v4.5.0 and later. Below that, leave the key alone: the plugin honoured it but the render pipeline copied./assetsregardless, so the index landed somewhere the built site never served it from and every search request 404'd. Fixed in generator v4.5.0.theme/assets/js/search.js: minimal client-side logic for filtering and rendering, copied by the publish command rather than by the render.app.searchIndexPath: the index's public URL, added toappdata. The shippedsearch.pugpasses it to the client asdata-search-index, so a customoutput_filenameis honoured end to end.search.jsfalls back to/search-index.jsonwhen the attribute is absent.app.searchIndexPathsandmeta.searchIndexPath: see Multilingual sites.app.searchShowTitle:falsewhenshow_title: false, elsetrue; the shippedsearch.pugrendersh1.search__titleunless it isfalse.
⚠️ The index is written into your source tree
The index has to be written into the source assets folder, not into
public/. Nera deletes public/ and then copies assets into it after
plugins run, so anything a plugin writes directly to public/ is discarded.
That means each render creates or updates a generated file inside a directory
you probably track in git. To keep it out of version control while still
serving it, add it to .gitignore:
theme/assets/search-index*.jsonDo not add it to .neraignore — that would stop Nera copying it into
public/, and the search would find no index at runtime.
Each render writes the indexes it needs and leaves everything else alone, so an
index that stops being generated is not cleaned up: turn group_by_lang
back off, rename output_filename, or remove the last page of a language, and
the orphaned file stays in your assets folder and keeps being copied into
public/. Delete it by hand when that happens.
What gets indexed
Every page Nera will render, and nothing else:
- A page without a
layoutin its frontmatter is skipped by the generator, so it is left out of the index too. This is what makes a layout-less draft safe: before v2.0.0 its full body text was written into the index — a file that is published — while no HTML page for it ever existed, so results linked to a 404. - Pages generated by other plugins are indexed only if that plugin ran first. See Pages generated by other plugins.
🧪 Development
npm install
npx vitest run
npm run lintTests use Vitest and cover:
- Index creation from
pagesData - Correct JSON structure and file writing
- That
getAppDatastays synchronous and preserves existingappvalues - Fallback to
./assetswhenfolders.assetsis absent - Config-driven field control, custom output filename, and HTML stripping
- Per-language indexes: partitioning, filename derivation, and that opting out reproduces the single-index output
- That pages without a
layoutare excluded, and their content never reaches the index - The shipped
search.pugandsearch.js, rendered and run against a DOM, including the exact class names the client emits, the input's accessible name and the optional heading
🤝 Contributing
Issues and pull requests are welcome. See the Nera contributing guide for plugin development, the hook contract, and local setup.
For this repo specifically:
npx vitest runandnpm run lintmust pass (npm testis watch mode).- Bump the version and update
CHANGELOG.mdin the same commit as the change. - Template markup and BEM class names are a public contract — users style them from their own CSS, so changing one is a major bump.
- Releases publish from CI on a pushed
v*tag. Never runnpm publish.
🧑💻 Author
Michael Becker
https://github.com/seebaermichi
🔗 Links
🧩 Compatibility
- Nera: v4.3.0+ — v4.2.0 for the
config/plugin-order.yamlordering this plugin needs to run last, and v4.3.0 for thebasedirthat makes theinclude /vendor/plugin-search/search.pugform in Usage resolve. On v4.2.x, use the relative include instead. Thetheme/folder layout used in the examples — whatnera newscaffolds — needs v4.6.0+. - Node.js: >= 20.0.0
- Plugin Utils:
^1.5.0— theme-aware publishing of both the template andsearch.jsarrived in 1.5.0 - Plugin API: Uses
getAppData()for index creation andgetMetaData()to expose each page's index path whengroup_by_langis enabled
📦 License
MIT
