@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 +21 -0
- package/README.md +54 -0
- package/bin/nera.js +50 -0
- package/package.json +56 -0
- package/src/commands/build.js +8 -0
- package/src/commands/dev.js +65 -0
- package/src/commands/new.js +7 -0
- package/src/commands/serve.js +26 -0
- package/src/commands/update.js +130 -0
- package/src/commands/validate.js +10 -0
- package/src/scaffold.js +82 -0
- package/template/_gitignore +5 -0
- package/template/config/app.yaml +8 -0
- package/template/package.json +14 -0
- package/template/pages/index.md +19 -0
- package/template/theme/assets/.gitkeep +0 -0
- package/template/theme/views/layouts/layout.pug +13 -0
- package/template/theme/views/pages/default.pug +4 -0
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,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
|
+
}
|
package/src/scaffold.js
ADDED
|
@@ -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,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
|