npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.

Readme

@nera-static/plugin-search

Test npm version

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.json from 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-search

The 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: true

Field 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:
    • content is the page's rendered Markdown body. Omit it from fields and the index holds titles only, so body-text queries match nothing.
    • href is 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: If true, HTML is removed from content before indexing.
  • Elements marked data-search-ignore are left out of content, along with everything inside them, whatever strip_html says. 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: If true, pages are indexed per meta.lang. See Multilingual sites.
  • show_title: If false, the shipped search.pug leaves out its h1.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-search

This 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.pug

layouts/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 to Search when the site has no translation for that key (or the template is rendered outside a build). To translate it, add a search_label key to the site's translations, e.g. for German search_label: Suche.

  • One h1 per page. By default the template renders its own h1.search__title, which suits a page whose Markdown has no heading, as in step 3. If the page brings its own h1 — usually a # Search heading in the Markdown — turn the template's off, or nera build --check warns a11y-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: true

A 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.json

Filenames

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: add data-search-index=(meta && meta.searchIndexPath) || (app && app.searchIndexPath) to the input.
  • search.js: fetch input.dataset.searchIndex || '/search-index.json', per input rather than once per page.

Data exposed

  • app.searchIndexPath keeps its existing string shape and points at the default language's index.
  • app.searchIndexPaths is { en: '/search-index.json', de: '/search-index.de.json', … }. Present in both modes.
  • meta.searchIndexPath is added to every page, only when group_by_lang is enabled.
  • app.searchShowTitle is false when config/search.yaml sets show_title: false, true otherwise. Present in both modes.
  • Each index entry carries a lang field, 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-search

This 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-run npx nera-search --force.
  • The request 404s. Your own template hardcodes an index URL that no longer exists — read it from meta.searchIndexPath instead.
  • A page's hits are in the wrong language. Check its frontmatter lang against the lang field 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-search

This will copy:

theme/views/vendor/plugin-search/search.pug

to 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 in theme/views/vendor/plugin-search/ and the script in theme/assets/js/, where the build actually renders and serves from. On a legacy site without a theme they fall back to the root views/ and assets/. Either way search.pug still 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 --force

A template change only reaches your site when you re-publish. Upgrading the package is safe but does nothing to theme/views/vendor/plugin-search/ or theme/assets/js/search.js — the published copies are yours, and are left alone. v2.0.0 changed the class names search.js emits, 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, empty class attributes and a bg-yellow-100 highlight.

📊 Generated Output

  • theme/assets/<output_filename>: contains all indexed page data. The filename follows output_filename in config/search.yaml (default search-index.json), so it is not fixed at search-index.json. With group_by_lang enabled 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 a theme/ folder, root assets/ on an older one, or whatever folders.assets in your config/app.yaml names — the last on Nera v4.5.0 and later. Below that, leave the key alone: the plugin honoured it but the render pipeline copied ./assets regardless, 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 to app data. The shipped search.pug passes it to the client as data-search-index, so a custom output_filename is honoured end to end. search.js falls back to /search-index.json when the attribute is absent.

  • app.searchIndexPaths and meta.searchIndexPath: see Multilingual sites.

  • app.searchShowTitle: false when show_title: false, else true; the shipped search.pug renders h1.search__title unless it is false.

⚠️ 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*.json

Do 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 layout in 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 lint

Tests use Vitest and cover:

  • Index creation from pagesData
  • Correct JSON structure and file writing
  • That getAppData stays synchronous and preserves existing app values
  • Fallback to ./assets when folders.assets is 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 layout are excluded, and their content never reaches the index
  • The shipped search.pug and search.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 run and npm run lint must pass (npm test is watch mode).
  • Bump the version and update CHANGELOG.md in 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 run npm publish.

🧑‍💻 Author

Michael Becker
https://github.com/seebaermichi

🔗 Links

🧩 Compatibility

  • Nera: v4.3.0+ — v4.2.0 for the config/plugin-order.yaml ordering this plugin needs to run last, and v4.3.0 for the basedir that makes the include /vendor/plugin-search/search.pug form in Usage resolve. On v4.2.x, use the relative include instead. The theme/ folder layout used in the examples — what nera new scaffolds — needs v4.6.0+.
  • Node.js: >= 20.0.0
  • Plugin Utils: ^1.5.0 — theme-aware publishing of both the template and search.js arrived in 1.5.0
  • Plugin API: Uses getAppData() for index creation and getMetaData() to expose each page's index path when group_by_lang is enabled

📦 License

MIT