@nera-static/nera 1.5.1 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -16,18 +16,19 @@ npm run dev # or: nera dev
16
16
 
17
17
  Already in the folder the site should live in? `nera new .` scaffolds into the
18
18
  current folder instead. The folder must be empty apart from dotfiles such as
19
- `.git` (an existing `.gitignore` is kept), and the package is named after the
20
- folder (`My Bakery` → `my-bakery`).
19
+ `.git` and an `AGENTS.md`/`CLAUDE.md` an AI assistant may have written first
20
+ (an existing `.gitignore`, `AGENTS.md` or `CLAUDE.md` is kept), and the
21
+ package is named after the folder (`My Bakery` → `my-bakery`).
21
22
 
22
23
  ## Commands
23
24
 
24
25
  | Command | What it does |
25
26
  |---|---|
26
- | `nera new <name>` | Scaffold a new thin Nera site (one dependency: `@nera-static/nera`). Add `--theme <name>` to start from a theme. `nera new .` scaffolds into the current, empty folder. |
27
+ | `nera new <name>` | Scaffold a new thin Nera site (one dependency: `@nera-static/nera`). Add `--theme <name>` to start from a theme. `nera new .` scaffolds into the current, empty folder (dotfiles, `AGENTS.md` and `CLAUDE.md` allowed). |
27
28
  | `nera build` | Render `pages/` → `public/`. With `--check`, then run `nera check` on the result — one command for CI. |
28
29
  | `nera dev` | Build, serve `public/`, and rebuild on change with live reload. Watches `pages/`, `config/`, `theme/` and a local theme (`theme: ./themes/<name>`). |
29
30
  | `nera serve` | Serve the already-built `public/` folder. |
30
- | `nera update` | Update the site's Nera packages. On a legacy cloned site, `nera update --migrate` converts it to the thin model. |
31
+ | `nera update` | Update the site's Nera packages, then create a missing `AGENTS.md`/`CLAUDE.md` and refresh the Nera part of `AGENTS.md` (see [Instructions for AI assistants](#instructions-for-ai-assistants)). On a legacy cloned site, `nera update --migrate` converts it to the thin model. |
31
32
  | `nera validate` | Check the site's sources (layouts, includes, YAML) before publishing; exits non-zero on any error. |
32
33
  | `nera check` | Check the built `public/` for accessibility, privacy and legal-notice problems (see below). Build first. |
33
34
 
@@ -41,6 +42,36 @@ dependency and its scripts call the CLI:
41
42
  }
42
43
  ```
43
44
 
45
+ ## Instructions for AI assistants
46
+
47
+ Every scaffolded site carries an `AGENTS.md`, the file Codex, Cursor, Copilot
48
+ and other assistants read first, and a `CLAUDE.md` holding the single line
49
+ `@AGENTS.md`, so Claude Code loads the same text. `AGENTS.md` covers what
50
+ Nera is, the folder map, the commands, the common traps, and the check loop:
51
+ `nera validate`, then `nera build --check`, after every change.
52
+
53
+ It ends with a **Notes for this site** section for your own instructions —
54
+ brand voice, pages not to touch, where the site is deployed. It starts below
55
+ the marker line `<!-- nera:site-notes … -->`; keep that line in place. The
56
+ part above it is Nera's and may be replaced by a later Nera version; your
57
+ notes below it are yours.
58
+
59
+ `nera update` keeps both files current, with no extra flag. After the
60
+ packages are updated (and after `nera update --migrate`) it:
61
+
62
+ - creates `AGENTS.md` and `CLAUDE.md` when they are missing, so a site made
63
+ before 1.6.0 gets them too;
64
+ - replaces only the part of `AGENTS.md` above the first line starting with
65
+ `<!-- nera:site-notes`, taking the new text from the installed
66
+ `@nera-static/nera`'s template; the marker line and everything below it
67
+ stay byte for byte as they are;
68
+ - leaves an `AGENTS.md` without that line alone and prints a hint: copy the
69
+ marker line from the template directly above your own notes to opt in;
70
+ - never touches an existing `CLAUDE.md`.
71
+
72
+ A file is written only when its content changes, so running `nera update`
73
+ again changes nothing.
74
+
44
75
  ## Starting from a theme
45
76
 
46
77
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nera-static/nera",
3
- "version": "1.5.1",
3
+ "version": "1.7.0",
4
4
  "description": "The Nera CLI — scaffold, build, preview and update a Nera static site with one command over the @nera-static/core engine",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,83 @@
1
+ import fs from 'fs/promises'
2
+ import path from 'path'
3
+
4
+ // Start of the ownership line in AGENTS.md. Everything above the first line
5
+ // that starts with it is Nera's and is refreshed by `nera update`; that line
6
+ // and everything below it belong to the site owner and are kept byte for byte.
7
+ export const SITE_NOTES_MARKER = '<!-- nera:site-notes'
8
+
9
+ // Offset of the first line that starts with SITE_NOTES_MARKER, or -1.
10
+ // Only a match at a line start counts, so the marker quoted inside a line
11
+ // never moves the split.
12
+ function markerOffset(text) {
13
+ if (text.startsWith(SITE_NOTES_MARKER)) return 0
14
+ const at = text.indexOf(`\n${SITE_NOTES_MARKER}`)
15
+ return at === -1 ? -1 : at + 1
16
+ }
17
+
18
+ async function readIfExists(file) {
19
+ try {
20
+ return await fs.readFile(file, 'utf-8')
21
+ } catch (err) {
22
+ if (err.code === 'ENOENT') return null
23
+ throw err
24
+ }
25
+ }
26
+
27
+ // The site's AGENTS.md with its Nera part replaced by the template's. The
28
+ // template's line endings follow the site's, so a CRLF checkout stays CRLF.
29
+ // null when the site file has no marker: then none of it is Nera's to touch.
30
+ function spliceAgents(siteText, templateHead) {
31
+ const at = markerOffset(siteText)
32
+ if (at === -1) return null
33
+ const eol = siteText.slice(0, at).includes('\r\n') ? '\r\n' : '\n'
34
+ return templateHead.replace(/\r?\n/g, eol) + siteText.slice(at)
35
+ }
36
+
37
+ // Bring a site's AGENTS.md and CLAUDE.md in line with the scaffold template
38
+ // in templateDir: create what is missing, refresh the Nera part of an
39
+ // AGENTS.md that has the marker, leave a marker-less AGENTS.md and any
40
+ // existing CLAUDE.md alone. Writes only on change, so a repeated run is a
41
+ // no-op. Returns one { file, action } per file, action being `created`,
42
+ // `updated`, `unchanged` or `skipped`; printing is the caller's job.
43
+ export async function syncAgentFiles({ cwd = process.cwd(), templateDir }) {
44
+ const template = await fs.readFile(path.join(templateDir, 'AGENTS.md'), 'utf-8')
45
+ const templateAt = markerOffset(template)
46
+ if (templateAt === -1) {
47
+ throw new Error(
48
+ `The template's AGENTS.md (${templateDir}) has no ` +
49
+ `\`${SITE_NOTES_MARKER}\` line. This is a packaging bug in ` +
50
+ '@nera-static/nera, not a problem with your site.'
51
+ )
52
+ }
53
+
54
+ const agentsPath = path.join(cwd, 'AGENTS.md')
55
+ const agents = await readIfExists(agentsPath)
56
+ let agentsAction
57
+ if (agents === null) {
58
+ await fs.writeFile(agentsPath, template)
59
+ agentsAction = 'created'
60
+ } else {
61
+ const next = spliceAgents(agents, template.slice(0, templateAt))
62
+ if (next === null) {
63
+ agentsAction = 'skipped'
64
+ } else if (next === agents) {
65
+ agentsAction = 'unchanged'
66
+ } else {
67
+ await fs.writeFile(agentsPath, next)
68
+ agentsAction = 'updated'
69
+ }
70
+ }
71
+
72
+ const claudePath = path.join(cwd, 'CLAUDE.md')
73
+ let claudeAction = 'unchanged'
74
+ if ((await readIfExists(claudePath)) === null) {
75
+ await fs.copyFile(path.join(templateDir, 'CLAUDE.md'), claudePath)
76
+ claudeAction = 'created'
77
+ }
78
+
79
+ return [
80
+ { file: 'AGENTS.md', action: agentsAction },
81
+ { file: 'CLAUDE.md', action: claudeAction },
82
+ ]
83
+ }
@@ -2,6 +2,8 @@ import { execFileSync } from 'child_process'
2
2
  import fs from 'fs/promises'
3
3
  import fssync from 'fs'
4
4
  import path from 'path'
5
+ import { syncAgentFiles, SITE_NOTES_MARKER } from '../agent-files.js'
6
+ import { templateDir as packageTemplateDir } from '../scaffold.js'
5
7
 
6
8
  const defaultRunNpm = (npmArgs, cwd) =>
7
9
  execFileSync('npm', npmArgs, { cwd, stdio: 'inherit' })
@@ -45,7 +47,46 @@ function printLegacyGuidance() {
45
47
  console.log('\n👉 Run `nera update --migrate` to do it.')
46
48
  }
47
49
 
48
- async function migrateLegacy(cwd, pkg, runNpm) {
50
+ // The scaffold template whose AGENTS.md / CLAUDE.md the site is synced to:
51
+ // the one in the site's installed @nera-static/nera (read after npm has
52
+ // updated it), so the instructions match the Nera version the site runs even
53
+ // when a global or older `nera` performs the update. Falls back to the
54
+ // running package's template when nothing is installed there or the installed
55
+ // version predates 1.6.0, which shipped no AGENTS.md.
56
+ export function agentTemplateDir(cwd, fallbackDir) {
57
+ const installed = path.join(cwd, 'node_modules', '@nera-static', 'nera', 'template')
58
+ return fssync.existsSync(path.join(installed, 'AGENTS.md')) ? installed : fallbackDir
59
+ }
60
+
61
+ const AGENT_FILE_MESSAGES = {
62
+ created: (file) => ` ✓ Created ${file}`,
63
+ updated: (file) => ` ✓ Refreshed the Nera part of ${file}`,
64
+ // Neutral on purpose: an existing CLAUDE.md is kept without comparing it.
65
+ unchanged: (file) => ` ✓ ${file} unchanged`,
66
+ skipped: (file) => ` ⚠️ Left ${file} alone: it has no \`${SITE_NOTES_MARKER}\` line`,
67
+ }
68
+
69
+ async function syncAndReportAgentFiles(cwd, fallbackDir) {
70
+ const dir = agentTemplateDir(cwd, fallbackDir)
71
+ const results = await syncAgentFiles({ cwd, templateDir: dir })
72
+ for (const { file, action } of results) {
73
+ console.log(AGENT_FILE_MESSAGES[action](file))
74
+ }
75
+ if (results.some(({ action }) => action === 'skipped')) {
76
+ // Relative inside the site, absolute for a global or npx-cached CLI,
77
+ // where a relative path would be a long run of `../`.
78
+ const template = path.join(dir, 'AGENTS.md')
79
+ const relative = path.relative(cwd, template)
80
+ const source = relative.startsWith('..') ? template : relative
81
+ console.log(
82
+ ` To let \`nera update\` keep Nera's instructions current, copy the \`${SITE_NOTES_MARKER}\`` +
83
+ ` line from ${source} directly above your own notes. Everything above that` +
84
+ ' line is then Nera\'s and is replaced on update; the line and everything below it stay yours.'
85
+ )
86
+ }
87
+ }
88
+
89
+ async function migrateLegacy(cwd, pkg, runNpm, templateDir) {
49
90
  console.log('🔧 Migrating this cloned site to the thin @nera-static/nera model...')
50
91
 
51
92
  pkg.dependencies = {
@@ -89,6 +130,7 @@ async function migrateLegacy(cwd, pkg, runNpm) {
89
130
 
90
131
  console.log('📦 Installing...')
91
132
  runNpm(['install'], cwd)
133
+ await syncAndReportAgentFiles(cwd, templateDir)
92
134
 
93
135
  console.log('✅ Migrated. Run `nera dev` to start.')
94
136
  if (movedPlugins) {
@@ -100,7 +142,13 @@ async function migrateLegacy(cwd, pkg, runNpm) {
100
142
  }
101
143
 
102
144
  export async function runUpdate(args = [], options = {}) {
103
- const { cwd = process.cwd(), runNpm = defaultRunNpm } = options
145
+ // templateDir: the fallback scaffold template for AGENTS.md / CLAUDE.md
146
+ // (see agentTemplateDir); injectable so tests need no installed package.
147
+ const {
148
+ cwd = process.cwd(),
149
+ runNpm = defaultRunNpm,
150
+ templateDir = packageTemplateDir(),
151
+ } = options
104
152
 
105
153
  const pkg = JSON.parse(
106
154
  await fs.readFile(path.join(cwd, 'package.json'), 'utf-8')
@@ -109,7 +157,7 @@ export async function runUpdate(args = [], options = {}) {
109
157
 
110
158
  if (kind === 'legacy') {
111
159
  if (args.includes('--migrate') || args.includes('--yes')) {
112
- await migrateLegacy(cwd, pkg, runNpm)
160
+ await migrateLegacy(cwd, pkg, runNpm, templateDir)
113
161
  } else {
114
162
  printLegacyGuidance()
115
163
  }
@@ -126,5 +174,6 @@ export async function runUpdate(args = [], options = {}) {
126
174
  const targets = neraDeps(pkg)
127
175
  console.log(`🔄 Updating Nera packages: ${targets.join(', ')}`)
128
176
  runNpm(['update', ...targets], cwd)
177
+ await syncAndReportAgentFiles(cwd, templateDir)
129
178
  console.log('✅ Up to date.')
130
179
  }
package/src/scaffold.js CHANGED
@@ -62,11 +62,18 @@ export function projectNameFromDir(dir) {
62
62
  : validateProjectName(name)
63
63
  }
64
64
 
65
+ // Files an AI assistant may have written before running `nera new .`. They
66
+ // do not make the folder count as non-empty, and the user's copy is kept.
67
+ // Exact names only, matched case-sensitively.
68
+ const AGENT_FILES = new Set(['AGENTS.md', 'CLAUDE.md'])
69
+
65
70
  // In-place scaffolding only goes ahead in an empty folder. Dotfiles such as
66
71
  // `.git` or an editor folder do not count: `git init` first is the usual way
67
- // to start a project.
72
+ // to start a project. Neither do AGENT_FILES.
68
73
  async function assertEmptyDir(dir) {
69
- const entries = (await fs.readdir(dir)).filter((n) => !n.startsWith('.'))
74
+ const entries = (await fs.readdir(dir)).filter(
75
+ (n) => !n.startsWith('.') && !AGENT_FILES.has(n)
76
+ )
70
77
  if (entries.length > 0) {
71
78
  throw new Error(
72
79
  `The current folder is not empty (${entries.slice(0, 3).join(', ')}` +
@@ -136,8 +143,9 @@ async function copyDir(src, dest, { skipStarters = false } = {}) {
136
143
  } else if (skipStarters && (await isStarterTemplate(from))) {
137
144
  continue
138
145
  } else if (fssync.existsSync(to)) {
139
- // Only in place, and then only a dotfile such as .gitignore can be
140
- // there already (assertEmptyDir): the user's copy wins.
146
+ // Only in place, and then only a dotfile such as .gitignore, or
147
+ // AGENTS.md / CLAUDE.md, can be there already (assertEmptyDir):
148
+ // the user's copy wins.
141
149
  console.log(` • Kept your existing ${path.basename(to)}`)
142
150
  } else {
143
151
  await fs.copyFile(from, to)
@@ -0,0 +1,118 @@
1
+ # AGENTS.md
2
+
3
+ Instructions for AI coding agents working on this site. People are welcome to
4
+ read them too.
5
+
6
+ ## What this is
7
+
8
+ This site is built with [Nera](https://nera.js.org), a static site generator:
9
+ Markdown pages plus Pug templates in, plain HTML out. Nera is not Eleventy,
10
+ Hugo, Astro or Jekyll, so do not apply their conventions here. The docs are
11
+ at https://nera.js.org.
12
+
13
+ ## Folder map
14
+
15
+ - `pages/` — the content: one Markdown file with YAML frontmatter per page
16
+ - `theme/views/` — the Pug templates (`layouts/`, `pages/`, and `vendor/`
17
+ for copied plugin templates)
18
+ - `theme/assets/` — CSS, JavaScript, images and fonts, copied into
19
+ `public/` on every build
20
+ - `config/` — YAML settings: `app.yaml` for the site, `<plugin>.yaml` for
21
+ each plugin
22
+ - `src/plugins/<name>/index.js` — local plugins for this site only (optional;
23
+ another folder can be set with `folders.plugins` in `config/app.yaml`)
24
+ - `public/` — the generated site. **Never edit it**: every build deletes it
25
+ and writes it again
26
+
27
+ ## Commands
28
+
29
+ - `npm run dev` — build, serve on http://localhost:3000 and rebuild on
30
+ every change
31
+ - `npm run build` — render `pages/` into `public/`
32
+ - `npx nera validate` — check pages, layouts, includes and YAML before a build
33
+ - `npx nera build --check` — build, then check the built `public/` for
34
+ accessibility, privacy and legal issues (`npx nera check` checks without
35
+ building)
36
+
37
+ ## After every change
38
+
39
+ 1. Run `npx nera validate` and fix every error it reports.
40
+ 2. Run `npx nera build --check` and fix what it reports.
41
+
42
+ `nera validate` names the file and line of a broken layout or include and
43
+ finds pages the build would skip silently. Run it first.
44
+
45
+ ## How Nera works (and where it trips agents up)
46
+
47
+ - **Pages are Markdown.** Write prose in Markdown, not HTML. Page structure
48
+ and markup shared between pages belong in `theme/views/`.
49
+ - **URLs end in `.html`.** `pages/a/b.md` becomes `public/a/b.html` and is
50
+ linked as `/a/b.html`; `pages/index.md` is `/index.html`. There are no
51
+ pretty URLs such as `/a/b/`.
52
+ - **A page needs `layout`.** The `layout` key in a page's frontmatter names a
53
+ file under `theme/views/`; the pages this site was created with use
54
+ `pages/default.pug`. A page without `layout` is not rendered and the build
55
+ says nothing; `nera validate` warns (`layout-missing`). Renaming or deleting
56
+ a layout breaks every page that uses it.
57
+ - **The page body.** The rendered Markdown reaches the template as `content`.
58
+ Without a theme, `theme/views/pages/default.pug` prints it with
59
+ `main !{ content }` inside `block content`; with a theme, the theme's layout
60
+ may already hold the `<main>`. A page has exactly one `<main>`: check the
61
+ layout and the page template before adding one.
62
+ - **`public/` is wiped on every build.** Put source files in `pages/`,
63
+ `theme/` or `config/`; anything written into `public/` by hand is lost.
64
+ - **Site files override theme files.** If `config/app.yaml` sets `theme:`,
65
+ the layouts come from that theme (a package or a folder), and a file in
66
+ `theme/views/` or `theme/assets/` replaces the theme's file at the same
67
+ path.
68
+ - **Translations.** Templates call `t('key')`, which reads
69
+ `translations.<lang>.key` from `config/app.yaml` (the page's `lang`, else
70
+ the site's).
71
+
72
+ ## Plugins: look for one before writing a feature
73
+
74
+ Features come from npm packages named `@nera-static/plugin-*`. Install one with
75
+ `npm install`, and Nera loads it on the next build; there is nothing to
76
+ register. Most read optional settings from `config/<name>.yaml` (the plugin's
77
+ README shows the keys). Plugin catalog:
78
+ https://github.com/seebaermichi/nera/blob/main/PLUGINS.md
79
+
80
+ Check the catalog before hand-writing navigation, a contact form, tags, search,
81
+ pagination or image handling. Two that sites often need:
82
+
83
+ - `@nera-static/plugin-contact-form` — a contact form that opens the visitor's
84
+ mail client (`mailto:`). No backend and no form service; configured in
85
+ `config/contact-form.yaml`.
86
+ - `@nera-static/plugin-navigation` — menus from `config/navigation.yaml`.
87
+
88
+ Plugin templates are not used automatically. Copy them into the site first,
89
+ then include them by hand in your own template:
90
+
91
+ - `npx nera-contact-form` copies the form to
92
+ `theme/views/vendor/plugin-contact-form/` (and its script to
93
+ `theme/assets/js/`); include it with
94
+ `include /vendor/plugin-contact-form/contact-form`
95
+ - `npx nera-navigation` copies the menus to
96
+ `theme/views/vendor/plugin-navigation/`
97
+
98
+ The copy is skipped when the target folder already exists, so later plugin
99
+ updates do not reach it; `--force` overwrites the copy.
100
+
101
+ ## Preview
102
+
103
+ `npm run dev` keeps running until it is stopped. If you start it in the
104
+ background, stop that process yourself when you are done, by its process ID.
105
+ Never kill processes by name (`pkill -f nera`), which stops other people's
106
+ servers too. If port 3000 is taken, the server picks another one and prints
107
+ the URL.
108
+
109
+ ## Getting online
110
+
111
+ `npm run build` writes the finished site into `public/`. That folder is what
112
+ gets deployed: upload its contents to any static web host.
113
+
114
+ <!-- nera:site-notes — everything below this line belongs to the site owner; `nera update` keeps it as it is -->
115
+ ## Notes for this site
116
+
117
+ Add what an agent should know about this particular site: its audience, tone,
118
+ languages, pages that must not change, the hosting.
@@ -0,0 +1 @@
1
+ @AGENTS.md