# Documentation Source: https://nera.js.org/docs/index.html Nera is a lightweight static site generator built on a simple idea: **Markdown in, HTML out, with plugins for everything else.** This guide takes you from an empty folder to a deployed site. ## The shape of a Nera project ``` my-site/ ├── config/ # app.yaml + one .yaml per plugin ├── pages/ # your Markdown content ├── theme/ │ ├── views/ # Pug layouts and page templates (vendor/ holds plugin templates) │ └── assets/ # CSS, JS, images, fonts — copied verbatim to public/ ├── src/plugins/ # optional local plugins, one folder each ├── public/ # generated output (never edit by hand) ├── AGENTS.md # instructions for AI assistants (CLAUDE.md points to it) └── package.json # one Nera dependency: @nera-static/nera ``` `nera new` creates everything here except `src/plugins/` and `public/`. Local plugins can live in another folder, set with `folders.plugins` in `config/app.yaml`. Sites from before the theme system keep `views/` and `assets/` at the project root; Nera still builds them, with a deprecation warning, until you move both folders under `theme/`. ## How a page becomes HTML 1. **Load** `config/app.yaml` into the `app` object and list `pages/`. 2. **Render** each Markdown file, extracting frontmatter into `meta`. 3. **Apply plugins**, which enrich `app` and `meta`. 4. **Write** `public/`, running each page through its `layout`. > A page is only rendered if its frontmatter sets a `layout`. Pages without one > are silently skipped — this is the single most common "why is my page missing?" > `npx nera validate` lists them (`layout-missing`). Continue to **Getting started** to scaffold your first site. Working with an AI assistant? Read [Using Nera with AI](https://nera.js.org/docs/using-ai.html). # Getting started Source: https://nera.js.org/docs/getting-started.html ## Requirements - **Node.js 20.19 or newer** (22.12 or newer on the 22 line) - npm (bundled with Node) ## Scaffold a site `nera new` scaffolds a new site whose one Nera dependency is `@nera-static/nera`, which brings the `@nera-static/core` engine — no clone, no vendored source: ```bash npx @nera-static/nera new my-site cd my-site npm run dev ``` `npm run dev` renders the site and starts a live-reloading preview on `http://localhost:3000`. Already in the folder the site should live in? `npx @nera-static/nera new .` scaffolds into the current, empty folder. ## Build for production ```bash npm run build ``` This deletes and regenerates `public/`. Deploy that folder to any static host. ## Update later ```bash npx nera update ``` `nera update` bumps the site's Nera packages with `npm update` and refreshes Nera's part of `AGENTS.md`, leaving your `pages/`, `config/` and `theme/` untouched. How an AI assistant uses `AGENTS.md` is explained in [Using Nera with AI](https://nera.js.org/docs/using-ai.html). # Core concepts Source: https://nera.js.org/docs/core-concepts.html ## The four-stage pipeline Every build runs the same fixed pipeline: **load app data → render pages → apply plugins → write output.** Understanding these four stages explains almost everything about how Nera behaves. ## `app` vs `meta` Two data objects reach your templates: - **`app`** — global values from `config/app.yaml`, available on every page. - **`meta`** — per-page values: your frontmatter, plus derived keys like `meta.href`, `meta.dirname`, `meta.filename` and `meta.createdAt`. Plugins add to both — for example, `plugin-navigation` sets `app.nav`, and `plugin-tags` sets `meta.tagLinks`. ## Layouts Layouts are Pug files under `theme/views/`. A page's `layout` frontmatter picks one: ```markdown --- layout: pages/docs.pug title: My page --- ``` The path is relative to `theme/views/`, so `pages/docs.pug` is `theme/views/pages/docs.pug`. If `config/app.yaml` sets `theme:` (a package such as `@nera-static/theme-example`, or a local folder), the layouts come from that theme, and a file in your own `theme/views/` or `theme/assets/` replaces the theme's file at the same path — override one template without copying the rest. `npx @nera-static/nera new my-site --theme example` starts a site that way. ## Translations `config/app.yaml` holds a `translations` map. In a layout, `t('key')` resolves `app.translations[meta.lang || app.lang][key]`, falling back to the key itself — so missing translations are visible, not silent. # Writing content Source: https://nera.js.org/docs/writing-content.html ## A content file Each page is a Markdown file under `pages/` with a YAML frontmatter block: ```markdown --- layout: pages/default.pug title: About description: What this page is about. --- # About Your content goes here. ``` ## Who writes the title `title` is data, not output. Whether it reaches the page is up to the layout, so check before repeating it in the body: - If the layout renders `meta.title` itself, **leave the heading out of the Markdown** — writing `# About` as well puts the title on the page twice. - If it doesn't, the body's own `#` heading is the page's only title, as in the example above. On this site `pages/default.pug` and `pages/docs.pug` render no title, so those pages open with a heading. The tutorial, tutorials-index, plugins and tag-overview layouts do render it, so their Markdown starts straight into the prose. ## Frontmatter is data Everything in frontmatter lands on `meta`. Use it for titles, descriptions, tags, ordering — anything a layout or plugin should read. > YAML types are preserved: numbers stay numbers, booleans stay booleans. Guard on > `meta.x != null`, not `if (meta.x)`, so a legitimate `0` or `false` survives. ## Derived meta values Nera adds these automatically: | Key | Meaning | | --- | --- | | `meta.href` | URL path of the generated HTML file | | `meta.dirname` | Directory the page lives in | | `meta.filename` | Source filename | | `meta.createdAt` | `createdAt` or `date` from the frontmatter, else the file's creation time | Set `createdAt` (or `date`) in the frontmatter of anything sorted by date, such as posts. A fresh checkout in CI gives every file the same creation time, so without it the order is lost on the build server. ## Folders become URLs `pages/docs/getting-started.md` → `/docs/getting-started.html`. Nesting folders nests URLs — no routing config required. # Plugins Source: https://nera.js.org/docs/plugins.html ## Installing ```bash npm install @nera-static/plugin-navigation ``` Any dependency whose name starts with `@nera-static/plugin-` is discovered and applied automatically — no registration step. (`@nera-static/plugin-utils` is a helper library, not a plugin, and is skipped.) A plugin for one site only needs no package: put it in `src/plugins//index.js`, or set another folder with `folders.plugins` in `config/app.yaml`. See [Write your own local plugin](https://nera.js.org/tutorials/write-a-local-plugin.html). ## The hook contract A plugin is an ESM module exporting either or both hooks: ```js export function getAppData({ app, pagesData }) { return { ...app, myKey: 'value' } // must return a plain object } export function getMetaData({ app, pagesData }) { return pagesData // must return an array } ``` `getAppData` runs first; `getMetaData` sees the `app` it returned. **Keep hooks synchronous** — an async hook can wipe `app` on older generator versions. A plugin that produces files (generated images, for example) also exports `getAssets` (core ≥ 4.13.0). It runs after every plugin's `getAppData` and `getMetaData`, sees the final `app` and `pagesData`, and returns what to copy into `public/`: ```js export function getAssets({ app, pagesData }) { return [{ from: '/abs/path/to/files', to: '_img' }] } ``` `from` is an absolute file or folder, `to` a path inside `public/`. Core copies these after the theme's assets and before your own, so your `theme/assets/` still wins on a name clash. Invalid entries are skipped with a warning. ## Config lives in your project Every plugin reads `config/.yaml` from **your** site, not from the package. The YAML shipped inside the package is documentation; copy it into your `config/` and edit. Missing keys fall back to sensible defaults. ## Ordering `config/plugin-order.yaml` controls execution order: names under `start:` run first, then everything else alphabetically, then names under `end:`. Search runs last so it indexes the final page data. ## Templates Plugins that ship views expose a publish command: ```bash npx nera-navigation # copies templates into theme/views/vendor/plugin-navigation/ ``` Then `include` them from your layouts. Publishing **skips if the folder already exists** — delete it (or pass `--force`) to pull in template updates. # Deployment Source: https://nera.js.org/docs/deployment.html Nera outputs a plain `public/` folder — deploy it anywhere that serves static files. ## Build ```bash npm run build ``` ## Host it - **Netlify / Vercel** — build command `npm run build`, publish directory `public`. - **GitHub Pages** — build in GitHub Actions and deploy `public/` with `actions/upload-pages-artifact` and `actions/deploy-pages`, as this site does. - **Any static host / CDN** — upload the contents of `public/`. ## Cache busting Browsers cache stylesheets, scripts and fonts, so after a deploy visitors may keep seeing the old CSS or JS until they force a reload. Turn on content hashing in `config/app.yaml` (requires `@nera-static/core` 4.11 or newer): ```yaml asset_hashing: true ``` On every build Nera appends `?v=` to each local asset URL — stylesheets, scripts, images, `srcset`, the search index and `url(…)` references inside CSS such as fonts. The hash comes from the file's content, so a URL changes exactly when its file does and stays cacheable otherwise. Your templates keep plain paths like `/css/main.css`; never add `?v=` by hand. Page links, external URLs and URLs that already have a query string are left alone, and it works together with `base_path`. Without the key the build output is unchanged. ## Before you launch - Set `app_origin` in `config/canonical-links.yaml` to your real domain so canonical URLs are correct. - Add an `.neraignore` at the project root to keep source-only assets out of the build. > `public/` is deleted and rebuilt on every render — never edit it by hand, and > never commit it as source. # CLI reference Source: https://nera.js.org/docs/cli.html ## The `nera` CLI (`@nera-static/nera`) One command scaffolds, builds, previews, updates and validates a site. Scaffold a new one with: ```bash npx @nera-static/nera new ``` `nera new .` scaffolds into the current, empty folder instead, and `--theme ` starts the site from a theme. A scaffolded site depends on `@nera-static/nera`, so run these from inside it with `npx`, e.g. `npx nera validate`. The scaffold also wires `build`, `dev` and `serve` to npm scripts, e.g. `npm run dev`: | Command | What it does | | --- | --- | | `nera build` | Build `pages/` → `public/`; with `--check`, check the result too | | `nera dev` | Build + live-reload preview on `:3000` | | `nera serve` | Serve `public/` without rebuilding | | `nera update` | Update the site's Nera packages (`npm update`), then create or refresh `AGENTS.md` and `CLAUDE.md` | | `nera validate` | Check layouts, includes and YAML before publishing | | `nera check` | Check the built `public/` for accessibility, privacy and legal-notice problems | ### Checking the built site `nera validate` reads your sources. `nera check` reads what the build produced — the page a visitor gets, with its layout, navigation, footer, scripts, stylesheets and fonts — and looks for problems a parser can find there: ```bash nera build --check # build, then check — the one command for CI nera check # check an existing public/ without rebuilding ``` Run `nera build` first; without `public/`, `nera check` stops with "run `nera build` first". A problem in a template is reported once, with the number of pages it appears on. Every finding is a **warning** by default, so the command exits `0`. It exits `1` only when a rule you promoted to `error` fires — that is how you make it a CI gate. **These are hints, not legal advice.** A clean run is not proof of compliance: automated checks find only part of the accessibility problems (about a third of WCAG failures). Colour contrast, visible focus and whether a law applies to your site are not checked. Every report ends with that reminder. | rule | default | basis | | --- | --- | --- | | `a11y-html-lang` | warning | WCAG 3.1.1 | | `a11y-title` | warning | WCAG 2.4.2 | | `a11y-h1` | warning | WCAG 1.3.1 | | `a11y-heading-skip` | warning | WCAG 1.3.1 | | `a11y-img-alt` | warning | WCAG 1.1.1 | | `a11y-form-label` | warning | WCAG 1.3.1, 4.1.2 | | `a11y-link-name` | warning | WCAG 2.4.4, 4.1.2 | | `a11y-main` | warning | WCAG 1.3.1 | | `a11y-skip-link` | warning | WCAG 2.4.1 | | `a11y-nav-name` | warning | WCAG 1.3.1 | | `a11y-duplicate-id` | warning | WCAG 4.1.2 | | `a11y-viewport-zoom` | warning | WCAG 1.4.4 | | `a11y-link-lang` | off (opt-in) | WCAG 3.1.2 | | `a11y-target-blank` | off (opt-in) | WCAG 3.2.5 (AAA) | | `a11y-reduced-motion` | off (opt-in) | WCAG 2.3.3 (AAA) | | `privacy-third-party` | warning | Art. 6 DSGVO | | `privacy-insecure` | warning | Art. 32 DSGVO | | `privacy-storage` | off (opt-in) | § 25 TDDDG | | `legal-imprint-link` | warning | § 5 DDG | | `legal-privacy-link` | warning | Art. 13 DSGVO | | `legal-outdated-law` | warning | DDG, TDDDG, MStV | Configure the checks in `config/validate.yaml` (optional — without it the defaults above apply): ```yaml rules: a11y-img-alt: error # promote: the check fails a11y-target-blank: warning # enable an opt-in rule a11y-skip-link: off # disable legal: imprint: # per language, the page every page links to de: /de/impressum.html en: /en/imprint.html privacy: de: /de/datenschutz.html en: /en/privacy.html privacy: allowed_hosts: # third-party hosts you have accounted for - cdn.example.org ``` To silence a rule on one page, list it in the page's frontmatter as `validate_ignore: [a11y-h1]`. To silence a rule on whole files or folders, list them under `ignore`. This works for `nera validate` too — for example for `layout-missing` on content fragments that another page pulls in, or on drafts, which have no `layout` on purpose: ```yaml ignore: layout-missing: - pages/*/references # a folder covers everything below it - pages/de/blog/drafts # * stands for one folder name ``` Paths are relative to the site root. Requires `@nera-static/validate` 1.3.0, which `npm update` brings into an existing site. **Your own host.** To tell your resources from third-party ones, `privacy-third-party` reads `origin` from `config/app.yaml`, else `app_origin` from `config/canonical-links.yaml` (with and without `www.`). With neither set, only relative URLs count as your own. **Imprint and privacy links in German and English only.** Without `legal` config, the links are found by their text — *Impressum*, *Imprint*, *Legal notice*, *Datenschutz*, *Privacy*, *Data protection* — and only on German and English pages. For any other language, name the pages under `legal.imprint.` and `legal.privacy.`. The full rule descriptions are in the [`@nera-static/validate` README](https://github.com/seebaermichi/nera-validate#checking-the-built-output). ### Instructions for AI assistants Every scaffolded site carries an `AGENTS.md` — the file Codex, Cursor, Copilot and other assistants read first — and a `CLAUDE.md` holding the single line `@AGENTS.md`, so Claude Code loads the same text. `AGENTS.md` ends with a **Notes for this site** section for your own instructions, below the marker line ``. Keep that line in place: the part above it is Nera's, the part below it is yours. From `@nera-static/nera` 1.7.0 on, `nera update` keeps both files current, with no extra flag. After updating the packages (and after `nera update --migrate`) it: - creates `AGENTS.md` and `CLAUDE.md` when they are missing, so an older site gets them too; - replaces only the part of `AGENTS.md` above the marker line with the text from the installed `@nera-static/nera`; the marker line and your notes below it stay exactly as they are; - leaves an `AGENTS.md` without the marker line alone and prints a hint — copy the marker line from the template directly above your own notes to opt in; - never touches an existing `CLAUDE.md`. A file is written only when its content changes, so running `nera update` again changes nothing. ### Migrating an older (cloned) site Sites created before the CLI were git clones that vendored the engine under `src/`. Inside such a site, run `nera update --migrate` — it adds the `@nera-static/nera` dependency, rewrites the scripts, removes the vendored `src/` engine, and installs, leaving your `pages/`, `config/` and `theme/` untouched. ## Plugin publish commands Each template-shipping plugin exposes a bin to copy its Pug into `theme/views/vendor/`, e.g.: ```bash npx nera-navigation npx nera-tags npx nera-search --force # overwrite existing published templates ``` # Using Nera with AI Source: https://nera.js.org/docs/using-ai.html AI assistants know Eleventy, Hugo and Astro well, and Nera hardly at all. Left alone, they guess — and guess wrong: pretty URLs, a missing `layout`, edits in `public/`. Nera gives them the facts in two ways: an instruction file in every site, and its docs as plain Markdown. ## What every site ships A site created with `npx @nera-static/nera new` (from `@nera-static/nera` 1.6.0 on) contains two files for AI assistants: - **`AGENTS.md`** — the instruction file read by Codex, Cursor, GitHub Copilot, Gemini CLI and other assistants. It says what Nera is (and that it is not Eleventy, Hugo, Astro or Jekyll), maps the folders, lists the commands, names the usual traps — a page needs `layout`, URLs end in `.html`, `public/` is wiped on every build — and says to look in the plugin catalog before writing a feature. From 1.8.0 on it also links Nera's docs for AI (see below). - **`CLAUDE.md`** — one line, `@AGENTS.md`, so Claude Code loads the same text. `AGENTS.md` ends with a **Notes for this site** section below the marker line ``. That part is yours: write down the audience, the tone, the languages, pages that must not change and where the site is hosted. The part above the marker is Nera's. **An existing site?** From `@nera-static/nera` 1.7.0 on, `npx nera update` creates `AGENTS.md` and `CLAUDE.md` when they are missing and refreshes only Nera's part of `AGENTS.md`, so your notes stay. The details are in the [CLI reference](https://nera.js.org/docs/cli.html), under "Instructions for AI assistants". ## Per assistant Open the site's folder in the assistant — the folder with `package.json` and `AGENTS.md` — so it finds the instructions. - **Claude Code** reads `CLAUDE.md`, which pulls in `AGENTS.md`. - **OpenAI Codex** reads `AGENTS.md`. - **Cursor**, **GitHub Copilot** and other coding assistants that support the `AGENTS.md` convention read it too. - **Chat assistants without access to your files** (claude.ai, ChatGPT and the like) never see `AGENTS.md`. Give them Nera's docs in the prompt instead — see the next section. ## Docs for AI nera.js.org publishes its docs for assistants, in English: - [`https://nera.js.org/llms.txt`](https://nera.js.org/llms.txt) — an index of the docs, following the llms.txt convention; - [`https://nera.js.org/llms-full.txt`](https://nera.js.org/llms-full.txt) — all English docs and tutorials in one Markdown file; - a raw Markdown file beside every English docs and tutorials page, e.g. [`/docs/cli.md`](https://nera.js.org/docs/cli.md) next to `/docs/cli.html`. Paste one of these URLs into a chat assistant's prompt, or attach the file. The docs are English only on purpose: models read them well and answer in your language. ## The workflow 1. Ask for one change at a time. 2. Let the assistant edit the files. 3. It runs `npx nera validate`, then `npx nera build --check`, and fixes what they report. `AGENTS.md` tells it to; say so in the prompt if it does not. 4. Preview with `npm run dev` on `http://localhost:3000`. 5. Review the diff (`git diff`) before you publish. ## Example prompts Create a new site — there is no `AGENTS.md` yet, so name the command and the docs: ```text Create a new Nera site in the folder "bakery" with npx @nera-static/nera new bakery, then read its AGENTS.md before changing anything. Nera's docs are at https://nera.js.org/llms.txt. ``` Add a page: ```text Add an "About us" page at pages/about.md with the same layout as pages/index.md, and link it from the menu (add @nera-static/plugin-navigation if the site has no menu yet). Then run npx nera validate and npx nera build --check and fix what they report. ``` Add a contact form with the plugin: ```text Add a contact form to the contact page with @nera-static/plugin-contact-form. Install it, copy its template with its command, include it in the page template and set my address in config/contact-form.yaml. Do not write a form by hand. ``` Add a second language: ```text Add German as a second language. Put the German pages under pages/de/, set lang: de in their frontmatter, move the interface text into translations in config/app.yaml and use t('key') in the templates. ``` Fix what `nera validate` reports: ```text Run npx nera validate and fix every error and warning it reports. Explain each fix in one line. Then run npx nera build --check. ``` In a chat assistant without file access: ```text Read https://nera.js.org/llms-full.txt — the docs of the Nera static site generator. Then explain how I add a blog section with tags to my Nera site, with the files I need to create and their content. ``` ## What's planned Planned, not available yet: - a **Nera skill** (an Agent Skill) copied into every site for assistants that load skills, plus a download for claude.ai and Claude Desktop; - **`--json` output** for `nera validate` and `nera check`, so assistants can read the findings directly; - **`nera publish`** — getting a site online from the CLI; - an **MCP server** for Nera: a local one for Claude Desktop, Claude Code and Cursor, and a hosted connector for claude.ai and ChatGPT that works on your GitHub repository. # Build a blog with Nera Source: https://nera.js.org/tutorials/build-a-blog.html In this tutorial you'll scaffold a Nera site, add a couple of posts, and list them newest-first on an index page. ## 1. Scaffold ```bash npx @nera-static/nera new my-blog cd my-blog ``` ## 2. Add posts Create `pages/posts/hello-world.md`: ```markdown --- layout: pages/default.pug title: Hello world description: My first post. --- # Hello world Welcome to my new Nera blog. ``` ## 3. List them A tiny local plugin under `src/plugins/` can collect every post into `app.posts`, sorted by `meta.createdAt`. Your index layout then loops over that list. (This very site does exactly that for its tutorials — see `plugins/tutorials-list`; it keeps its local plugins in `plugins/` via `folders.plugins`.) Give each post a `createdAt` date in its frontmatter so the order holds on a CI build server too. ## 4. Preview ```bash npm run dev ``` That's a working blog. From here, add [Tags](https://nera.js.org/tutorials/tag-your-content.html) or pagination to grow it. # Build a navigation with active states Source: https://nera.js.org/tutorials/custom-navigation.html `@nera-static/plugin-navigation` turns a YAML list into a navigation menu and marks the link for the current page — no JavaScript required. ## 1. Install and publish the template ```bash npm install @nera-static/plugin-navigation npx nera-navigation ``` The second command copies the nav templates into `theme/views/vendor/plugin-navigation/`, where you can edit them freely. ## 2. Define the menu Create `config/navigation.yaml`. A single flat menu looks like this: ```yaml active_class: nav__link--active active_path_class: nav__link--active-path nav_class: nav elements: - href: /index.html name: Home - href: /docs/index.html name: Docs ``` Need more than one menu? Nest them under names — each becomes `app.nav..elements`: ```yaml elements: main: - href: /index.html name: Home - href: /docs/index.html name: Docs footer: - href: /about.html name: About ``` The plugin exposes everything under `app.nav`. ## 3. Render it in a layout Include the published partial and call its mixin: ```pug include ../vendor/plugin-navigation/partials/link-list-navigation header.site-header if app.nav && app.nav.main +linkListNav(app.nav.main.elements, 'nav--main') ``` The plugin adds `nav__link--active` to the link matching the current page and `nav__link--active-path` to any ancestor link, so styling the current section is just CSS. > Prefer a different shape? The plugin also ships `simple-navigation` and > `pipe-separated-navigation` partials — same data, different markup. This site uses exactly this setup for its header and footer — see `config/navigation.yaml` in the source. # Tag your content and get topic pages for free Source: https://nera.js.org/tutorials/tag-your-content.html `@nera-static/plugin-tags` reads a `tags:` field from your frontmatter, then builds a browsable overview page for every tag automatically. ## 1. Install and publish templates ```bash npm install @nera-static/plugin-tags npx nera-tags ``` ## 2. Tag some pages Add a comma-separated `tags` value to any page's frontmatter: ```markdown --- layout: pages/tutorial.pug title: Add client-side search tags: plugins, search --- ``` Tags are slugified and case-folded, so `Web Dev`, `web dev` and `WEB DEV` all map to one page at `/tags/web-dev.html`. ## 3. Configure (optional) `config/tags.yaml` — every key is optional: ```yaml meta_property_name: tags tag_overview_path: '/tags' tag_separator: ',' tag_overview_layout: pages/tag-overview.pug ``` The `tag_overview_layout` points at a layout that renders the published overview template. Create `theme/views/pages/tag-overview.pug`: ```pug extends ../layouts/layout block content .container include ../vendor/plugin-tags/pages/tag-overview ``` ## 4. Show tags on a page The plugin gives each page a `meta.tagLinks` list. Include the published partial in your article layout: ```pug if meta.tagLinks include ../vendor/plugin-tags/partials/tag-links ``` And for a site-wide cloud, `app.tagCloud` drives the `tag-cloud` partial: ```pug if app.tagCloud include ../vendor/plugin-tags/partials/tag-cloud ``` ## 5. Per-language tags (optional) By default every tag lives in one namespace, so a multilingual site merges all languages into one overview page. Set `group_by_lang` to give each language its own: ```yaml group_by_lang: true ``` Tags are then collected per `meta.lang`, and the language code prefixes the whole `tag_overview_path`. With `tag_overview_path: '/tutorials/tags'` you get: ``` /tutorials/tags/.html # default language /de/tutorials/tags/.html # German pages only /es/tutorials/tags/.html # Spanish pages only ``` The default language stays unprefixed, which is what you want when it is served from the root. If every language sits in its own directory, add `prefix_default_lang: true` so it gets a segment too. Pages without a `meta.lang` fall into the default language rather than a bucket of their own, so a single-language site is unaffected by any of this. Chips and clouds follow along on their own: `meta.tagLinks` links within the page's own language, and each page gets a `meta.tagCloud` scoped to it. Watch one detail — `app.tagCloud` still exists, but with grouping on it holds the *default* language's cloud. Guarding on it is fine; reading it directly to render a cloud is not, because a German page would show English tags. The shipped `tag-cloud` partial already prefers `meta.tagCloud` and falls back to `app.tagCloud`, so use the partial rather than looping yourself. `app.tagCloudByLang` has every language, keyed by code, if you need them all at once. Generated overview pages also carry their own `meta.lang`, so `t()` inside the overview layout resolves in that page's language. Render, and every tag now has its own page — no manual index maintenance. The tutorial you're reading is tagged this way; the chips above link to the generated tag pages. This site runs with `group_by_lang: true`, so those chips lead to the tag pages for whichever language you are reading. # Add client-side search Source: https://nera.js.org/tutorials/add-search.html `@nera-static/plugin-search` builds a JSON index at render time and ships a small client script — no server required. ## 1. Install ```bash npm install @nera-static/plugin-search ``` ## 2. Publish the template ```bash npx nera-search ``` This copies the search box into `theme/views/vendor/plugin-search/` and the client into `theme/assets/js/search.js`. ## 3. Configure `config/search.yaml` controls which fields are indexed: ```yaml fields: - title - description - content - href strip_html: true output_filename: search-index.json ``` ## 4. Add a search page Create a page that includes the published search partial, then make sure search runs **last** in `config/plugin-order.yaml` so it indexes final page data: ```yaml plugin-order: - end: - plugin-search ``` ## 5. Per-language search (optional) By default every page lands in one index, so the search box on a German page also returns English and Spanish hits. Set `group_by_lang` to give each language an index of its own: ```yaml group_by_lang: true ``` Pages are grouped by `meta.lang`, and the language code goes before the extension of `output_filename`. The default language keeps the plain filename: ``` /search-index.json # default language /search-index.de.json # German pages only /search-index.es.json # Spanish pages only ``` Pages without a `meta.lang` fall into the default language — `lang` in `config/app.yaml` — so a single-language site is unaffected by any of this. Every page then carries `meta.searchIndexPath`, the index for its own language. The published `search.pug` passes it to the client as `data-search-index` and `search.js` fetches that URL, so your own markup needs no change. Watch one detail — `app.searchIndexPath` still exists, but with grouping on it points at the *default* language's index, so building the attribute from it would search English from a German page. `app.searchIndexPaths` has every language keyed by code, and each index entry carries its `lang`. Because search runs last (step 4), pages other plugins generate land in the right language too — the tag pages from `plugin-tags` among them. Upgrading an existing site has one catch worth knowing: `npx nera-search` skips files you already published, and `--force` overwrites them along with any edits you made. If your copies are customised, merge the two changes by hand instead — the `data-search-index` attribute in the template, and `search.js` reading `input.dataset.searchIndex`. Until you do, the old client keeps requesting `/search-index.json` and every page searches the default language. Render, and you have working search. This site's [Search page](https://nera.js.org/search.html) is built exactly this way, with `group_by_lang: true` — the box returns only pages in the language you are reading. # Add prev/next links between pages Source: https://nera.js.org/tutorials/prev-next-pagination.html `@nera-static/plugin-page-pagination` links each page to its previous and next **sibling** — pages in the same directory — in an order you control. Perfect for docs and multi-part tutorials. ## 1. Install and publish the template ```bash npm install @nera-static/plugin-page-pagination npx nera-page-pagination ``` ## 2. Order the pages By default the plugin reads a `pagination_order` number from each page: ```markdown --- title: Getting started pagination_order: 2 --- ``` Pages are ordered by `pagination_order` first, then by creation date, then by path — so a page without an explicit order still lands somewhere stable. > Ordering starts at `0` if you want it to: the plugin treats `0` as a real value, > not "missing". Change the key name in `config/page-pagination.yaml` with > `order_property` if you prefer. ## 3. Render the links The plugin adds `meta.pagePagination` with `.previous` and `.next`. Include the published template, guarded so it's hidden when there's nowhere to go: ```pug if meta.pagePagination && (meta.pagePagination.previous || meta.pagePagination.next) include ../vendor/plugin-page-pagination/page-pagination ``` ## Why "siblings only" matters Pagination is scoped per directory, so `pages/docs/*.md` paginate among themselves and `pages/tutorials/*.md` among themselves — they never bleed into each other. Put a sequence in its own folder and it just works. This site's docs use exactly this. # Open external links in a new tab Source: https://nera.js.org/tutorials/external-links-new-tab.html `@nera-static/plugin-link-attributes` post-processes your rendered HTML and adds attributes to outbound links — so every external link opens in a new tab, safely, without you touching the Markdown. ## 1. Install ```bash npm install @nera-static/plugin-link-attributes ``` No template to publish — this plugin only transforms content. ## 2. Configure `config/link-attributes.yaml` lists the attributes to add: ```yaml attributes: - target="_blank" - rel="noopener noreferrer" ``` The plugin targets only links whose `href` starts with `http` or `www`, so your internal links (`/docs/...`) are left alone. The `rel="noopener noreferrer"` is worth keeping — it closes the tab-nabbing hole that bare `target="_blank"` opens. ## 3. The one gotcha: layout links The plugin rewrites links inside your **Markdown content**, not links you write directly in Pug layouts (a header "GitHub" button, a footer link). Add the same attributes to those by hand: ```pug a.site-header__cta(href=app.repo_url, target="_blank", rel="noopener noreferrer") GitHub ``` Render, and every outbound link — in content and in your layout — opens in a new tab. This site does exactly this; try any GitHub link. > Node note: this plugin uses cheerio under the hood, so it needs Node ≥ 20.18.1 to > build. It doesn't affect your visitors — the output is plain HTML. # Add canonical links for SEO Source: https://nera.js.org/tutorials/canonical-links-seo.html Search engines want a single canonical URL per page. `@nera-static/plugin-canonical-links` emits the right `` tag on every page — and alternate-language tags if you run a multilingual site. ## 1. Install and publish the template ```bash npm install @nera-static/plugin-canonical-links npx nera-canonical-links ``` ## 2. Configure your origin `config/canonical-links.yaml`: ```yaml app_origin: https://your-domain.com page_identifier: slug available_languages: - en ``` `app_origin` is the only value you must set — it's the absolute origin canonical URLs are built from. Set it to your real domain before you launch. ## 3. Give pages a slug With `page_identifier: slug`, each page contributes its `slug` frontmatter to the canonical URL: ```markdown --- title: Getting started slug: getting-started --- ``` ## 4. Emit the tags The plugin sets `meta.canonicalLink`. Include the published partial in your layout's ``: ```pug head include ../vendor/plugin-canonical-links/index ``` Render and check the source of any page — you'll see: ```html ``` Every page on this site carries one, built exactly this way. # Write your own local plugin Source: https://nera.js.org/tutorials/write-a-local-plugin.html You don't need to publish to npm to extend Nera. Any module at `src/plugins//index.js` is discovered and applied automatically. To keep local plugins elsewhere, set the folder in `config/app.yaml` — this site uses `plugins/`: ```yaml folders: plugins: ./plugins ``` Let's build a real one: a plugin that collects every tutorial into a list for an index page — a simplified version of the plugin that powers this site. ## The contract A plugin exports either or both of two hooks: ```js export function getAppData({ app, pagesData }) { // must return a plain object — becomes the new `app` } export function getMetaData({ app, pagesData }) { // must return an array — becomes the new `pagesData` } ``` `getAppData` runs first; `getMetaData` sees the `app` it returned. **Keep them synchronous** — an async hook can silently wipe `app` on older generator versions. ## Build it Create `src/plugins/tutorials-list/index.js`: ```js export function getAppData({ app, pagesData }) { if (!app || typeof app !== 'object') return app if (!Array.isArray(pagesData)) return app const tutorials = pagesData .filter(({ meta }) => { const href = meta?.href || '' return href.startsWith('/tutorials/') && href !== '/tutorials/index.html' }) .map(({ meta }) => ({ href: meta.href, title: meta.title || meta.href, description: meta.description || '', createdAt: meta.createdAt, })) .sort((a, b) => { const da = new Date(a.createdAt).getTime() const db = new Date(b.createdAt).getTime() if (!Number.isNaN(da) && !Number.isNaN(db) && da !== db) return db - da return String(a.href).localeCompare(String(b.href)) }) return { ...app, tutorials } } ``` That's the whole plugin. It reads every page, keeps the ones under `/tutorials/`, and returns a new `app` with a sorted `tutorials` array. ## Use it in a layout `app.tutorials` is now available in every view: ```pug if app.tutorials && app.tutorials.length ul.tutorials__list each post in app.tutorials li.tutorials__item a.tutorials__link(href=post.href) h2 #{ post.title } p #{ post.description } ``` ## Why return a *new* object Nera threads each hook's return value into the next. Returning `{ ...app, tutorials }` (rather than mutating `app`) keeps that data flow predictable — the same reason the built-in plugins do it. From here, the same pattern builds related-posts lists, author pages, "recently updated" widgets — anything derived from your content. # How this website is built Source: https://nera.js.org/tutorials/how-this-site-is-built.html This whole site is built with Nera. Nothing here is special-cased — it's the same generator and the same public plugins you'd install. Here's the full recipe. ## Scaffolded like any site It started with the nera CLI, which scaffolds a site whose one Nera dependency is `@nera-static/nera` (it brings the `@nera-static/core` engine): ```bash npx @nera-static/nera new nera-website ``` ## Nine plugins, each doing one thing | Plugin | Job | Config | | --- | --- | --- | | `plugin-navigation` | Header + footer menus with active states | `config/navigation.yaml` | | `plugin-tags` | Tutorial tags + per-language `/tutorials/tags/*` pages | `config/tags.yaml` | | `plugin-search` | Per-language client-side search index + box | `config/search.yaml` | | `plugin-page-pagination` | Prev/next in docs and tutorials | `config/page-pagination.yaml` | | `plugin-canonical-links` | Canonical `` tags for SEO | `config/canonical-links.yaml` | | `plugin-link-attributes` | External links open in a new tab | `config/link-attributes.yaml` | | `plugin-code-blocks` | Build-time syntax highlighting with a copy button | `config/code-blocks.yaml` | | `tutorials-list` (local) | Builds `app.tutorials` for the index | — | | `llms-txt` (local) | `llms.txt`, `llms-full.txt` and a `.md` per page for AI assistants | `config/llms-txt.yaml` | The two local plugins live in `plugins/`, set with `folders.plugins` in `config/app.yaml`. Most of the others have their own tutorial in this section, if you want the details. ## Order matters — canonical links and search run last Search builds its index from the final state of every page, so it has to run after everything else. Canonical links have to come after `plugin-tags` too, or the tag pages it generates get no canonical link — and alphabetically, `canonical-links` would run before `tags`. `config/plugin-order.yaml` says so: ```yaml plugin-order: - end: - plugin-canonical-links - plugin-search ``` Everything else runs alphabetically in between. This is the one place plugin ordering is load-bearing on this site. ## Templates we own Plugins that ship views were published into `theme/views/vendor/` with commands like `npx nera-navigation`. Those copies are ours to edit and style — nothing important is hidden inside `node_modules`. ## Content is just Markdown Every page you're reading is a Markdown file under `pages/` with a `layout` in its frontmatter. The docs are `pages/docs/*.md`; this tutorial is `pages/tutorials/how-this-site-is-built.md`. Folders become URLs; no routing config exists. ## Build ```bash npm run build # pages/ -> public/ ``` That's the entire stack: Markdown in, static HTML out, plugins for the rest. If you've read the other tutorials in this section, you've now seen every piece of it in isolation — this is just all of them together.