@nera-static/nera 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) [2025] [Michael Becker]
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,54 @@
1
+ # @nera-static/nera
2
+
3
+ The Nera CLI — scaffold, build, preview and update a [Nera](https://github.com/seebaermichi/nera)
4
+ static site with a single command. It runs over the [`@nera-static/core`](https://github.com/seebaermichi/nera)
5
+ engine and is the one package a Nera site depends on.
6
+
7
+ ## Quick start
8
+
9
+ ```bash
10
+ npx @nera-static/nera new my-site
11
+ cd my-site
12
+ npm run dev # or: nera dev
13
+ ```
14
+
15
+ ## Commands
16
+
17
+ | Command | What it does |
18
+ |---|---|
19
+ | `nera new <name>` | Scaffold a new thin Nera site (one dependency: `@nera-static/nera`). |
20
+ | `nera build` | Render `pages/` → `public/`. |
21
+ | `nera dev` | Build, serve `public/`, and rebuild on change with live reload. |
22
+ | `nera serve` | Serve the already-built `public/` folder. |
23
+ | `nera update` | Update the site's Nera packages. On a legacy cloned site, `nera update --migrate` converts it to the thin model. |
24
+
25
+ A scaffolded site is a thin project — no vendored engine, no clone. It lists one
26
+ dependency and its scripts call the CLI:
27
+
28
+ ```jsonc
29
+ {
30
+ "scripts": { "dev": "nera dev", "build": "nera build", "serve": "nera serve" },
31
+ "dependencies": { "@nera-static/nera": "^1.0.0" }
32
+ }
33
+ ```
34
+
35
+ ## Migrating a cloned (legacy) site
36
+
37
+ Older Nera sites were git clones that vendored the engine under `src/`. Inside
38
+ such a site run:
39
+
40
+ ```bash
41
+ nera update --migrate
42
+ ```
43
+
44
+ It adds `@nera-static/nera`, rewrites the scripts, removes the vendored `src/`
45
+ engine and root `index.js`, and installs — leaving your `pages/`, `config/` and
46
+ `theme/` untouched. Local plugins in `src/plugins/` are moved to `plugins/`.
47
+
48
+ ## Requirements
49
+
50
+ Node.js >= 20.
51
+
52
+ ## License
53
+
54
+ MIT
package/bin/nera.js ADDED
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env node
2
+ import { runNew } from '../src/commands/new.js'
3
+ import { runBuild } from '../src/commands/build.js'
4
+ import { runDev } from '../src/commands/dev.js'
5
+ import { runServe } from '../src/commands/serve.js'
6
+ import { runUpdate } from '../src/commands/update.js'
7
+ import { runValidate } from '../src/commands/validate.js'
8
+
9
+ const [, , command, ...rest] = process.argv
10
+
11
+ const usage = `Nera — the static site generator CLI
12
+
13
+ Usage:
14
+ nera new <name> scaffold a new Nera site
15
+ nera build render pages/ → public/
16
+ nera dev build, serve and live-reload
17
+ nera serve serve the built public/ folder
18
+ nera update update Nera packages (or migrate a cloned site with --migrate)
19
+ nera validate check the site (layouts, includes, YAML) before publish
20
+ `
21
+
22
+ try {
23
+ switch (command) {
24
+ case 'new':
25
+ await runNew(rest)
26
+ break
27
+ case 'build':
28
+ await runBuild(rest)
29
+ break
30
+ case 'dev':
31
+ await runDev(rest)
32
+ break
33
+ case 'serve':
34
+ await runServe(rest)
35
+ break
36
+ case 'update':
37
+ await runUpdate(rest)
38
+ break
39
+ case 'validate':
40
+ process.exit(runValidate())
41
+ break
42
+ default:
43
+ console.log(usage)
44
+ // No command → help (exit 0); an unknown command → error (exit 1).
45
+ process.exit(command ? 1 : 0)
46
+ }
47
+ } catch (error) {
48
+ console.error('❌', error.message)
49
+ process.exit(1)
50
+ }
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@nera-static/nera",
3
+ "version": "1.0.0",
4
+ "description": "The Nera CLI — scaffold, build, preview and update a Nera static site with one command over the @nera-static/core engine",
5
+ "type": "module",
6
+ "bin": {
7
+ "nera": "./bin/nera.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "src",
12
+ "template"
13
+ ],
14
+ "scripts": {
15
+ "lint": "eslint .",
16
+ "lint:fix": "eslint . --fix",
17
+ "test": "vitest",
18
+ "prepare": "husky"
19
+ },
20
+ "keywords": [
21
+ "nera",
22
+ "static-site-generator",
23
+ "cli",
24
+ "scaffolding",
25
+ "ssg",
26
+ "jamstack"
27
+ ],
28
+ "author": "Michael Becker",
29
+ "license": "MIT",
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "https://github.com/seebaermichi/nera-cli.git"
33
+ },
34
+ "bugs": {
35
+ "url": "https://github.com/seebaermichi/nera-cli/issues"
36
+ },
37
+ "homepage": "https://github.com/seebaermichi/nera-cli#readme",
38
+ "engines": {
39
+ "node": ">=20.0.0"
40
+ },
41
+ "publishConfig": {
42
+ "access": "public"
43
+ },
44
+ "dependencies": {
45
+ "@nera-static/core": "^4.9.0",
46
+ "@nera-static/validate": "^1.0.0",
47
+ "chokidar": "^5.0.0",
48
+ "semver": "^7.8.5",
49
+ "vite": "^8.1.5"
50
+ },
51
+ "devDependencies": {
52
+ "eslint": "^9.17.0",
53
+ "husky": "^9.1.7",
54
+ "vitest": "^3.2.4"
55
+ }
56
+ }
@@ -0,0 +1,8 @@
1
+ import run from '@nera-static/core'
2
+
3
+ // Render the current site (pages/ → public/) using the shared engine. `run`
4
+ // reads config and folders relative to the current working directory, so this
5
+ // builds whichever site the CLI is invoked in.
6
+ export async function runBuild() {
7
+ await run()
8
+ }
@@ -0,0 +1,65 @@
1
+ import fssync from 'fs'
2
+ import run from '@nera-static/core'
3
+ import { startServer } from './serve.js'
4
+
5
+ // Build once, serve `public/`, then rebuild on any change to the site's
6
+ // sources. `run()` already copies assets as part of a build, so a single
7
+ // watcher over pages/, config/ and the presentation folder covers everything —
8
+ // no separate asset-copy watcher (this replaces the old `concurrently` script
9
+ // that chained render + vite + watch-assets + nodemon).
10
+ //
11
+ // This is the code form of the previous npm-script orchestration. chokidar is
12
+ // imported lazily so loading this module does not require it to be installed.
13
+ export async function runDev(args = []) {
14
+ const port = parsePort(args)
15
+
16
+ await run()
17
+ const server = await startServer(port)
18
+ server.printUrls()
19
+
20
+ const { default: chokidar } = await import('chokidar')
21
+
22
+ // Presentation lives under theme/ in the current layout, or the deprecated
23
+ // root views/ on an unmigrated site — watch whichever exists.
24
+ const watchDirs = [
25
+ 'pages',
26
+ 'config',
27
+ fssync.existsSync('theme') ? 'theme' : 'views',
28
+ ].filter((d) => fssync.existsSync(d))
29
+
30
+ // Serialise rebuilds: coalesce changes that land mid-build into one re-run.
31
+ let building = false
32
+ let queued = false
33
+ const rebuild = async () => {
34
+ if (building) {
35
+ queued = true
36
+ return
37
+ }
38
+ building = true
39
+ try {
40
+ await run()
41
+ } catch (err) {
42
+ console.error('❌ Build error:', err.message)
43
+ }
44
+ building = false
45
+ if (queued) {
46
+ queued = false
47
+ await rebuild()
48
+ }
49
+ }
50
+
51
+ chokidar
52
+ .watch(watchDirs, { ignoreInitial: true })
53
+ .on('all', async (event, filePath) => {
54
+ console.log(`↻ ${event} ${filePath} — rebuilding`)
55
+ await rebuild()
56
+ })
57
+
58
+ return server
59
+ }
60
+
61
+ const parsePort = (args) => {
62
+ const i = args.indexOf('--port')
63
+ const val = i >= 0 ? Number(args[i + 1]) : NaN
64
+ return Number.isInteger(val) ? val : 3000
65
+ }
@@ -0,0 +1,7 @@
1
+ import { scaffoldProject } from '../scaffold.js'
2
+
3
+ export async function runNew(args = []) {
4
+ const projectName = args.find((a) => !a.startsWith('-'))
5
+ const install = !args.includes('--no-install')
6
+ await scaffoldProject(projectName, { install })
7
+ }
@@ -0,0 +1,26 @@
1
+ // Serve the already-built `public/` folder with Vite (static preview, no
2
+ // rebuild). Vite is imported lazily so merely loading this module — as the CLI
3
+ // dispatcher and the tests do — does not require Vite to be installed; it is
4
+ // only needed when `serve`/`dev` actually run.
5
+
6
+ const parsePort = (args) => {
7
+ const i = args.indexOf('--port')
8
+ const val = i >= 0 ? Number(args[i + 1]) : NaN
9
+ return Number.isInteger(val) ? val : 3000
10
+ }
11
+
12
+ export async function startServer(port = 3000, open = true) {
13
+ const { createServer } = await import('vite')
14
+ const server = await createServer({
15
+ root: 'public',
16
+ server: { port, open, watch: { usePolling: true } },
17
+ })
18
+ await server.listen()
19
+ return server
20
+ }
21
+
22
+ export async function runServe(args = []) {
23
+ const server = await startServer(parsePort(args))
24
+ server.printUrls()
25
+ return server
26
+ }
@@ -0,0 +1,130 @@
1
+ import { execFileSync } from 'child_process'
2
+ import fs from 'fs/promises'
3
+ import fssync from 'fs'
4
+ import path from 'path'
5
+
6
+ const defaultRunNpm = (npmArgs, cwd) =>
7
+ execFileSync('npm', npmArgs, { cwd, stdio: 'inherit' })
8
+
9
+ // The Nera packages a site consumes, so `nera update` bumps only those and
10
+ // leaves the user's other dependencies alone.
11
+ export function neraDeps(pkg) {
12
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies }
13
+ return Object.keys(deps).filter(
14
+ (name) => name === '@nera-static/nera' || name.startsWith('@nera-static/')
15
+ )
16
+ }
17
+
18
+ // A THIN site depends on @nera-static/nera and carries no engine source.
19
+ // A LEGACY site is a clone of the old generator: it vendors the engine under
20
+ // src/ and has no @nera-static/nera dependency — the case `nera update` migrates.
21
+ export function classifyProject(pkg, cwd = process.cwd()) {
22
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies }
23
+ if (deps['@nera-static/nera']) return 'thin'
24
+
25
+ const vendorsEngine =
26
+ fssync.existsSync(path.join(cwd, 'src', 'core.js')) &&
27
+ fssync.existsSync(path.join(cwd, 'src', 'render.js'))
28
+ if (vendorsEngine) return 'legacy'
29
+
30
+ return 'unknown'
31
+ }
32
+
33
+ function printLegacyGuidance() {
34
+ console.log(
35
+ '🔧 This is a legacy Nera site — a clone that vendors the engine under `src/`.'
36
+ )
37
+ console.log(
38
+ ' `nera update` can convert it to the thin @nera-static/nera model:'
39
+ )
40
+ console.log(' • add @nera-static/nera as a dependency')
41
+ console.log(' • rewrite scripts to `nera build`/`dev`/`serve`')
42
+ console.log(' • remove the vendored `src/` engine and root `index.js`')
43
+ console.log(' • run `npm install`')
44
+ console.log(' Your pages/, config/ and theme/ are left untouched.')
45
+ console.log('\n👉 Run `nera update --migrate` to do it.')
46
+ }
47
+
48
+ async function migrateLegacy(cwd, pkg, runNpm) {
49
+ console.log('🔧 Migrating this cloned site to the thin @nera-static/nera model...')
50
+
51
+ pkg.dependencies = {
52
+ ...pkg.dependencies,
53
+ '@nera-static/nera': '^1.0.0',
54
+ }
55
+
56
+ pkg.scripts = {
57
+ ...pkg.scripts,
58
+ dev: 'nera dev',
59
+ build: 'nera build',
60
+ serve: 'nera serve',
61
+ }
62
+ // Generator-only scripts that no longer apply in the thin model.
63
+ for (const s of ['render', 'preserve', 'watch:assets', 'start', 'dev:old']) {
64
+ delete pkg.scripts[s]
65
+ }
66
+ // The `nera.version` stamp existed only because a cloned engine had no npm
67
+ // version; npm now tracks it.
68
+ delete pkg.nera
69
+
70
+ await fs.writeFile(
71
+ path.join(cwd, 'package.json'),
72
+ `${JSON.stringify(pkg, null, 4)}\n`
73
+ )
74
+ console.log(' ✓ Rewrote package.json (dependency + scripts)')
75
+
76
+ // A site's own local plugins must survive — move them out of the engine
77
+ // folder before it is deleted, and tell the user how to re-point discovery.
78
+ const srcPlugins = path.join(cwd, 'src', 'plugins')
79
+ let movedPlugins = false
80
+ if (fssync.existsSync(srcPlugins)) {
81
+ await fs.rename(srcPlugins, path.join(cwd, 'plugins'))
82
+ movedPlugins = true
83
+ console.log(' ✓ Moved src/plugins → plugins/')
84
+ }
85
+
86
+ await fs.rm(path.join(cwd, 'src'), { recursive: true, force: true })
87
+ await fs.rm(path.join(cwd, 'index.js'), { force: true })
88
+ console.log(' ✓ Removed the vendored engine (src/, index.js)')
89
+
90
+ console.log('📦 Installing...')
91
+ runNpm(['install'], cwd)
92
+
93
+ console.log('✅ Migrated. Run `nera dev` to start.')
94
+ if (movedPlugins) {
95
+ console.log(
96
+ '⚠️ Local plugins moved to plugins/. Add `folders:\\n plugins: ./plugins`' +
97
+ ' to config/app.yaml so the engine discovers them (see ROADMAP-core.md).'
98
+ )
99
+ }
100
+ }
101
+
102
+ export async function runUpdate(args = [], options = {}) {
103
+ const { cwd = process.cwd(), runNpm = defaultRunNpm } = options
104
+
105
+ const pkg = JSON.parse(
106
+ await fs.readFile(path.join(cwd, 'package.json'), 'utf-8')
107
+ )
108
+ const kind = classifyProject(pkg, cwd)
109
+
110
+ if (kind === 'legacy') {
111
+ if (args.includes('--migrate') || args.includes('--yes')) {
112
+ await migrateLegacy(cwd, pkg, runNpm)
113
+ } else {
114
+ printLegacyGuidance()
115
+ }
116
+ return
117
+ }
118
+
119
+ if (kind === 'unknown') {
120
+ throw new Error(
121
+ 'This does not look like a Nera site — no @nera-static/nera ' +
122
+ 'dependency and no vendored engine under src/.'
123
+ )
124
+ }
125
+
126
+ const targets = neraDeps(pkg)
127
+ console.log(`🔄 Updating Nera packages: ${targets.join(', ')}`)
128
+ runNpm(['update', ...targets], cwd)
129
+ console.log('✅ Up to date.')
130
+ }
@@ -0,0 +1,10 @@
1
+ import { validateSite, hasErrors, formatResults } from '@nera-static/validate'
2
+
3
+ // Validate the current site and print the results. Returns the process exit code
4
+ // (1 on any error, 0 otherwise) rather than calling process.exit itself, so it
5
+ // stays testable; the bin turns the return value into the exit.
6
+ export function runValidate({ cwd = process.cwd() } = {}) {
7
+ const results = validateSite({ cwd })
8
+ console.log(formatResults(results))
9
+ return hasErrors(results) ? 1 : 0
10
+ }
@@ -0,0 +1,82 @@
1
+ import { execFileSync } from 'child_process'
2
+ import fs from 'fs/promises'
3
+ import fssync from 'fs'
4
+ import path from 'path'
5
+ import { fileURLToPath } from 'url'
6
+
7
+ // Directory-safe and npm-safe: no separators, no traversal, no leading dot or
8
+ // dash, nothing the shell could reinterpret. Deliberately stricter than npm's
9
+ // own package-name rules, because this value is also a directory name.
10
+ const VALID_PROJECT_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/
11
+
12
+ export function validateProjectName(projectName) {
13
+ if (typeof projectName !== 'string' || projectName.trim() === '') {
14
+ throw new Error(
15
+ 'Project name is required. Usage: nera new <project-name>'
16
+ )
17
+ }
18
+
19
+ if (!VALID_PROJECT_NAME.test(projectName)) {
20
+ throw new Error(
21
+ `Invalid project name "${projectName}". Use letters, digits, dots, ` +
22
+ 'dashes and underscores only, starting with a letter or digit.'
23
+ )
24
+ }
25
+
26
+ return projectName
27
+ }
28
+
29
+ // The scaffold template ships inside this package (package.json `files`).
30
+ export const templateDir = () =>
31
+ path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'template')
32
+
33
+ async function copyDir(src, dest) {
34
+ await fs.mkdir(dest, { recursive: true })
35
+ for (const entry of await fs.readdir(src, { withFileTypes: true })) {
36
+ const from = path.join(src, entry.name)
37
+ // `_gitignore` → `.gitignore`: npm strips a literal .gitignore from the
38
+ // published tarball, so the template ships it under an underscore name.
39
+ const name = entry.name === '_gitignore' ? '.gitignore' : entry.name
40
+ const to = path.join(dest, name)
41
+ if (entry.isDirectory()) {
42
+ await copyDir(from, to)
43
+ } else {
44
+ await fs.copyFile(from, to)
45
+ }
46
+ }
47
+ }
48
+
49
+ async function personalize(targetDir, projectName) {
50
+ const pkgPath = path.join(targetDir, 'package.json')
51
+ const pkg = JSON.parse(await fs.readFile(pkgPath, 'utf-8'))
52
+ pkg.name = projectName
53
+ await fs.writeFile(pkgPath, `${JSON.stringify(pkg, null, 4)}\n`)
54
+ console.log(` ✓ Configured project as "${projectName}"`)
55
+ }
56
+
57
+ // Scaffold a thin Nera site: copy the template, name it, and (by default)
58
+ // install its single dependency, @nera-static/nera. No git clone, no vendored
59
+ // engine — the opposite of the old installer's clone-and-strip flow.
60
+ export async function scaffoldProject(projectName, options = {}) {
61
+ const { install = true, cwd = process.cwd() } = options
62
+
63
+ validateProjectName(projectName)
64
+
65
+ const targetDir = path.resolve(cwd, projectName)
66
+ if (fssync.existsSync(targetDir)) {
67
+ throw new Error(`Target directory "${projectName}" already exists.`)
68
+ }
69
+
70
+ console.log(`📦 Creating a new Nera site in ${targetDir}...`)
71
+ await copyDir(templateDir(), targetDir)
72
+ await personalize(targetDir, projectName)
73
+
74
+ if (install) {
75
+ console.log('📦 Installing dependencies...')
76
+ execFileSync('npm', ['install'], { cwd: targetDir, stdio: 'inherit' })
77
+ }
78
+
79
+ console.log('✅ Done!')
80
+ console.log(`👉 Next steps:\n cd ${projectName}\n nera dev\n`)
81
+ return targetDir
82
+ }
@@ -0,0 +1,5 @@
1
+ node_modules
2
+ public
3
+ .env
4
+ .npmrc
5
+ .vite
@@ -0,0 +1,8 @@
1
+ lang: en
2
+ name: My Nera Site
3
+ robots: index,follow
4
+
5
+ translations:
6
+ en:
7
+ app_description: A static site built with Nera
8
+ app_keywords: nera, static site generator
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "nera-site",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "private": true,
6
+ "scripts": {
7
+ "dev": "nera dev",
8
+ "build": "nera build",
9
+ "serve": "nera serve"
10
+ },
11
+ "dependencies": {
12
+ "@nera-static/nera": "^1.0.0"
13
+ }
14
+ }
@@ -0,0 +1,19 @@
1
+ ---
2
+ layout: pages/default.pug
3
+ title: Home
4
+ description: Welcome to a site built with Nera
5
+ keywords: nera, static site generator
6
+ ---
7
+ # Welcome to Nera
8
+
9
+ This page was scaffolded by `nera new` and rendered by `nera build`. Edit
10
+ `pages/index.md` to change this content, and `theme/views/` to change the layout.
11
+
12
+ ## Next steps
13
+
14
+ - `nera dev` — build and preview with live reload
15
+ - `nera build` — render `pages/` into `public/`
16
+ - Add a plugin: install any `@nera-static/plugin-*` package and configure it under `config/`
17
+
18
+ See the [plugin catalog](https://github.com/seebaermichi/nera/blob/main/PLUGINS.md)
19
+ for what you can add.
File without changes
@@ -0,0 +1,13 @@
1
+ <!DOCTYPE html>
2
+ html(lang=app.lang)
3
+ head
4
+ meta(charset="UTF-8")
5
+ meta(name="viewport", content="width=device-width, initial-scale=1.0")
6
+ title #{ meta.title } | #{ app.name }
7
+ meta(name="description", content=`${ meta.description || t('app_description') }`)
8
+ meta(name="keywords", content=`${ meta.keywords || t('app_keywords') }`)
9
+ meta(name="generator" content="nera")
10
+ meta(name="robots" content=`${ meta.robots || app.robots }`)
11
+
12
+ body
13
+ block content
@@ -0,0 +1,4 @@
1
+ extends ../layouts/layout
2
+
3
+ block content
4
+ main !{ content }