@nera-static/nera 1.0.0 → 1.2.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
@@ -4,6 +4,8 @@ The Nera CLI — scaffold, build, preview and update a [Nera](https://github.com
4
4
  static site with a single command. It runs over the [`@nera-static/core`](https://github.com/seebaermichi/nera)
5
5
  engine and is the one package a Nera site depends on.
6
6
 
7
+ 📖 **Documentation:** [nera.js.org](https://nera.js.org)
8
+
7
9
  ## Quick start
8
10
 
9
11
  ```bash
@@ -16,11 +18,12 @@ npm run dev # or: nera dev
16
18
 
17
19
  | Command | What it does |
18
20
  |---|---|
19
- | `nera new <name>` | Scaffold a new thin Nera site (one dependency: `@nera-static/nera`). |
21
+ | `nera new <name>` | Scaffold a new thin Nera site (one dependency: `@nera-static/nera`). Add `--theme <name>` to start from a theme. |
20
22
  | `nera build` | Render `pages/` → `public/`. |
21
- | `nera dev` | Build, serve `public/`, and rebuild on change with live reload. |
23
+ | `nera dev` | Build, serve `public/`, and rebuild on change with live reload. Watches `pages/`, `config/`, `theme/` and a local theme (`theme: ./themes/<name>`). |
22
24
  | `nera serve` | Serve the already-built `public/` folder. |
23
25
  | `nera update` | Update the site's Nera packages. On a legacy cloned site, `nera update --migrate` converts it to the thin model. |
26
+ | `nera validate` | Check the site (layouts, includes, YAML) before publishing; exits non-zero on any error. |
24
27
 
25
28
  A scaffolded site is a thin project — no vendored engine, no clone. It lists one
26
29
  dependency and its scripts call the CLI:
@@ -32,6 +35,26 @@ dependency and its scripts call the CLI:
32
35
  }
33
36
  ```
34
37
 
38
+ ## Starting from a theme
39
+
40
+ ```bash
41
+ npx @nera-static/nera new my-site --theme example
42
+ ```
43
+
44
+ This installs the theme (`example` → `@nera-static/theme-example`; a full
45
+ package name such as `@acme/my-theme`, or a local path such as `./my-theme`,
46
+ works too), sets `theme:` in `config/app.yaml`, and leaves out the scaffold's
47
+ starter templates.
48
+
49
+ That last part matters. A site's own `theme/views/` wins over its theme's views
50
+ **file by file** — that is how you override a single theme template. Without
51
+ `--theme`, `nera new` writes a starter `theme/views/layouts/layout.pug` and
52
+ `theme/views/pages/default.pug`, the same names a theme uses, so a theme added
53
+ later stays hidden behind them. Each starter file carries a
54
+ `//- nera:scaffold-default` first line, and `nera validate` warns
55
+ (`theme-shadowed`) while such a file hides a theme's. Delete it to use the
56
+ theme's version, or delete the marker line to keep yours.
57
+
35
58
  ## Migrating a cloned (legacy) site
36
59
 
37
60
  Older Nera sites were git clones that vendored the engine under `src/`. Inside
@@ -43,7 +66,17 @@ nera update --migrate
43
66
 
44
67
  It adds `@nera-static/nera`, rewrites the scripts, removes the vendored `src/`
45
68
  engine and root `index.js`, and installs — leaving your `pages/`, `config/` and
46
- `theme/` untouched. Local plugins in `src/plugins/` are moved to `plugins/`.
69
+ `theme/` untouched. Local plugins in `src/plugins/` are moved to `plugins/`;
70
+ add this to `config/app.yaml` so the engine discovers them there:
71
+
72
+ ```yaml
73
+ folders:
74
+ plugins: ./plugins
75
+ ```
76
+
77
+ A root `views/` and `assets/` (no `theme/` folder) keeps rendering but prints a
78
+ deprecation warning — move them to `theme/views/` and `theme/assets/`, the
79
+ layout `nera new` scaffolds.
47
80
 
48
81
  ## Requirements
49
82
 
package/bin/nera.js CHANGED
@@ -11,7 +11,7 @@ const [, , command, ...rest] = process.argv
11
11
  const usage = `Nera — the static site generator CLI
12
12
 
13
13
  Usage:
14
- nera new <name> scaffold a new Nera site
14
+ nera new <name> scaffold a new Nera site (--theme <name> to start from a theme)
15
15
  nera build render pages/ → public/
16
16
  nera dev build, serve and live-reload
17
17
  nera serve serve the built public/ folder
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nera-static/nera",
3
- "version": "1.0.0",
3
+ "version": "1.2.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": {
@@ -42,15 +42,15 @@
42
42
  "access": "public"
43
43
  },
44
44
  "dependencies": {
45
- "@nera-static/core": "^4.9.0",
45
+ "@nera-static/core": "^4.12.0",
46
46
  "@nera-static/validate": "^1.0.0",
47
47
  "chokidar": "^5.0.0",
48
48
  "semver": "^7.8.5",
49
49
  "vite": "^8.1.5"
50
50
  },
51
51
  "devDependencies": {
52
- "eslint": "^9.17.0",
52
+ "eslint": "^10.8.0",
53
53
  "husky": "^9.1.7",
54
- "vitest": "^3.2.4"
54
+ "vitest": "^5.0.3"
55
55
  }
56
56
  }
@@ -1,5 +1,6 @@
1
1
  import fssync from 'fs'
2
- import run from '@nera-static/core'
2
+ import path from 'path'
3
+ import run, { resolveSiteModel } from '@nera-static/core'
3
4
  import { startServer } from './serve.js'
4
5
 
5
6
  // Build once, serve `public/`, then rebuild on any change to the site's
@@ -19,13 +20,8 @@ export async function runDev(args = []) {
19
20
 
20
21
  const { default: chokidar } = await import('chokidar')
21
22
 
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))
23
+ const watched = new Set(watchTargets())
24
+ const watcher = chokidar.watch([...watched], { ignoreInitial: true })
29
25
 
30
26
  // Serialise rebuilds: coalesce changes that land mid-build into one re-run.
31
27
  let building = false
@@ -42,22 +38,59 @@ export async function runDev(args = []) {
42
38
  console.error('❌ Build error:', err.message)
43
39
  }
44
40
  building = false
41
+ // config/app.yaml may now name a different local theme — start
42
+ // watching it too. A dropped one stays watched; that only costs an
43
+ // extra rebuild if it is edited.
44
+ for (const dir of watchTargets()) {
45
+ if (!watched.has(dir)) {
46
+ watched.add(dir)
47
+ watcher.add(dir)
48
+ }
49
+ }
45
50
  if (queued) {
46
51
  queued = false
47
52
  await rebuild()
48
53
  }
49
54
  }
50
55
 
51
- chokidar
52
- .watch(watchDirs, { ignoreInitial: true })
53
- .on('all', async (event, filePath) => {
54
- console.log(`↻ ${event} ${filePath} — rebuilding`)
55
- await rebuild()
56
- })
56
+ watcher.on('all', async (event, filePath) => {
57
+ console.log(
58
+ `↻ ${event} ${path.relative(process.cwd(), filePath)} — rebuilding`
59
+ )
60
+ await rebuild()
61
+ })
57
62
 
58
63
  return server
59
64
  }
60
65
 
66
+ // What a rebuild depends on: pages/, config/, the site's presentation folder
67
+ // — theme/ in the current layout, or the deprecated root views/ on an
68
+ // unmigrated site — and a LOCAL theme (`theme: ./themes/classic`, or the same
69
+ // via NERA_THEME), which lives outside all of those. The theme is resolved
70
+ // through core, so the watcher follows exactly the spec the build uses. Only
71
+ // the theme's payload folders are watched, never its root: `theme: .` makes
72
+ // the root the site itself, and watching public/ would rebuild forever. An
73
+ // installed theme package under node_modules is left alone: it changes only on
74
+ // npm install, and watching node_modules is expensive.
75
+ export function watchTargets(cwd = process.cwd()) {
76
+ const dirs = [
77
+ 'pages',
78
+ 'config',
79
+ fssync.existsSync(path.join(cwd, 'theme')) ? 'theme' : 'views',
80
+ ].map((d) => path.join(cwd, d))
81
+
82
+ const { theme } = resolveSiteModel({ cwd })
83
+ if (theme && !theme.root.split(path.sep).includes('node_modules')) {
84
+ dirs.push(
85
+ theme.viewsRoot,
86
+ theme.assetsRoot,
87
+ path.join(theme.root, 'config')
88
+ )
89
+ }
90
+
91
+ return [...new Set(dirs)].filter((d) => fssync.existsSync(d))
92
+ }
93
+
61
94
  const parsePort = (args) => {
62
95
  const i = args.indexOf('--port')
63
96
  const val = i >= 0 ? Number(args[i + 1]) : NaN
@@ -1,7 +1,23 @@
1
1
  import { scaffoldProject } from '../scaffold.js'
2
2
 
3
+ // `nera new <name> [--theme <spec> | --theme=<spec>] [--no-install]`
4
+ export function parseNewArgs(args = []) {
5
+ let projectName
6
+ let theme
7
+ for (let i = 0; i < args.length; i++) {
8
+ const arg = args[i]
9
+ if (arg === '--theme') {
10
+ theme = args[++i] ?? ''
11
+ } else if (arg.startsWith('--theme=')) {
12
+ theme = arg.slice('--theme='.length)
13
+ } else if (!arg.startsWith('-') && projectName === undefined) {
14
+ projectName = arg
15
+ }
16
+ }
17
+ return { projectName, theme, install: !args.includes('--no-install') }
18
+ }
19
+
3
20
  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 })
21
+ const { projectName, theme, install } = parseNewArgs(args)
22
+ await scaffoldProject(projectName, { install, theme })
7
23
  }
package/src/scaffold.js CHANGED
@@ -26,11 +26,54 @@ export function validateProjectName(projectName) {
26
26
  return projectName
27
27
  }
28
28
 
29
+ // What `--theme` accepts, mirroring the three forms `app.theme` takes in
30
+ // @nera-static/core (src/theme.js): a bare name (`example` →
31
+ // @nera-static/theme-example), a full package name (`@acme/my-theme`), or a
32
+ // local path (`./my-theme`). Validated because it lands in app.yaml and, for
33
+ // the package forms, on the npm command line.
34
+ const BARE_THEME = /^[a-z0-9][a-z0-9._-]*$/
35
+ const SCOPED_THEME = /^@[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*$/
36
+ const LOCAL_THEME = /^\.{1,2}(\/[A-Za-z0-9._-]+)*\/?$/
37
+
38
+ export function validateThemeSpec(theme) {
39
+ if (
40
+ typeof theme === 'string' &&
41
+ (BARE_THEME.test(theme) ||
42
+ SCOPED_THEME.test(theme) ||
43
+ LOCAL_THEME.test(theme))
44
+ ) {
45
+ return theme
46
+ }
47
+ throw new Error(
48
+ `Invalid theme "${theme}". Use a theme name (example), a package ` +
49
+ 'name (@scope/theme) or a local path (./my-theme).'
50
+ )
51
+ }
52
+
53
+ // The npm package a theme spec installs, or null for a local path. Same rule
54
+ // as core's packageName() — keep the two in step.
55
+ export function themePackageName(theme) {
56
+ if (theme.startsWith('.')) return null
57
+ return theme.includes('/') || theme.startsWith('@')
58
+ ? theme
59
+ : `@nera-static/theme-${theme}`
60
+ }
61
+
62
+ // Starter templates carry this marker on their first line. With a theme they
63
+ // are left out: site views win over theme views file by file, so a starter
64
+ // layout would hide the theme's own. @nera-static/validate warns on the same
65
+ // marker (`theme-shadowed`) for sites that kept them.
66
+ const SCAFFOLD_MARKER = 'nera:scaffold-default'
67
+
68
+ const isStarterTemplate = async (file) =>
69
+ file.endsWith('.pug') &&
70
+ (await fs.readFile(file, 'utf-8')).includes(SCAFFOLD_MARKER)
71
+
29
72
  // The scaffold template ships inside this package (package.json `files`).
30
73
  export const templateDir = () =>
31
74
  path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'template')
32
75
 
33
- async function copyDir(src, dest) {
76
+ async function copyDir(src, dest, { skipStarters = false } = {}) {
34
77
  await fs.mkdir(dest, { recursive: true })
35
78
  for (const entry of await fs.readdir(src, { withFileTypes: true })) {
36
79
  const from = path.join(src, entry.name)
@@ -39,7 +82,9 @@ async function copyDir(src, dest) {
39
82
  const name = entry.name === '_gitignore' ? '.gitignore' : entry.name
40
83
  const to = path.join(dest, name)
41
84
  if (entry.isDirectory()) {
42
- await copyDir(from, to)
85
+ await copyDir(from, to, { skipStarters })
86
+ } else if (skipStarters && (await isStarterTemplate(from))) {
87
+ continue
43
88
  } else {
44
89
  await fs.copyFile(from, to)
45
90
  }
@@ -54,13 +99,35 @@ async function personalize(targetDir, projectName) {
54
99
  console.log(` ✓ Configured project as "${projectName}"`)
55
100
  }
56
101
 
102
+ // Point the site at its theme: `theme:` in app.yaml (appended, so the
103
+ // template's own lines and comments stay as they are) and, for a package
104
+ // theme, a dependency that `npm install` resolves.
105
+ async function applyTheme(targetDir, theme) {
106
+ const appYaml = path.join(targetDir, 'config', 'app.yaml')
107
+ const yaml = await fs.readFile(appYaml, 'utf-8')
108
+ await fs.writeFile(
109
+ appYaml,
110
+ `${yaml.replace(/\n*$/, '\n')}\ntheme: ${theme}\n`
111
+ )
112
+
113
+ const pkgName = themePackageName(theme)
114
+ if (pkgName) {
115
+ const pkgPath = path.join(targetDir, 'package.json')
116
+ const pkg = JSON.parse(await fs.readFile(pkgPath, 'utf-8'))
117
+ pkg.dependencies = { ...pkg.dependencies, [pkgName]: 'latest' }
118
+ await fs.writeFile(pkgPath, `${JSON.stringify(pkg, null, 4)}\n`)
119
+ }
120
+ console.log(` ✓ Using theme "${theme}"${pkgName ? ` (${pkgName})` : ''}`)
121
+ }
122
+
57
123
  // Scaffold a thin Nera site: copy the template, name it, and (by default)
58
124
  // install its single dependency, @nera-static/nera. No git clone, no vendored
59
125
  // engine — the opposite of the old installer's clone-and-strip flow.
60
126
  export async function scaffoldProject(projectName, options = {}) {
61
- const { install = true, cwd = process.cwd() } = options
127
+ const { install = true, cwd = process.cwd(), theme } = options
62
128
 
63
129
  validateProjectName(projectName)
130
+ if (theme !== undefined) validateThemeSpec(theme)
64
131
 
65
132
  const targetDir = path.resolve(cwd, projectName)
66
133
  if (fssync.existsSync(targetDir)) {
@@ -68,12 +135,18 @@ export async function scaffoldProject(projectName, options = {}) {
68
135
  }
69
136
 
70
137
  console.log(`📦 Creating a new Nera site in ${targetDir}...`)
71
- await copyDir(templateDir(), targetDir)
138
+ await copyDir(templateDir(), targetDir, { skipStarters: Boolean(theme) })
72
139
  await personalize(targetDir, projectName)
140
+ if (theme) await applyTheme(targetDir, theme)
73
141
 
74
142
  if (install) {
75
143
  console.log('📦 Installing dependencies...')
76
- execFileSync('npm', ['install'], { cwd: targetDir, stdio: 'inherit' })
144
+ // Naming the theme package installs everything else too, and makes npm
145
+ // replace the `latest` placeholder with a caret range for the version
146
+ // it resolved.
147
+ const themePkg = theme ? themePackageName(theme) : null
148
+ const args = themePkg ? ['install', themePkg] : ['install']
149
+ execFileSync('npm', args, { cwd: targetDir, stdio: 'inherit' })
77
150
  }
78
151
 
79
152
  console.log('✅ Done!')
@@ -1,3 +1,4 @@
1
+ //- nera:scaffold-default — `nera new` starter file. While this line is here, `nera validate` warns if it hides a theme's file of the same name; delete the line once the file is your own.
1
2
  <!DOCTYPE html>
2
3
  html(lang=app.lang)
3
4
  head
@@ -1,3 +1,4 @@
1
+ //- nera:scaffold-default — `nera new` starter file. While this line is here, `nera validate` warns if it hides a theme's file of the same name; delete the line once the file is your own.
1
2
  extends ../layouts/layout
2
3
 
3
4
  block content