@nera-static/nera 1.12.0 → 1.14.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
@@ -122,14 +122,15 @@ A starter kit is a whole ready-to-edit site: sample pages, config, optional
122
122
  site-level `theme/` overrides, assets and the theme and plugins it depends on.
123
123
  `nera new` copies its `pages/`, `config/`, `theme/`, `assets/`, `plugins/` and
124
124
  `.neraignore` over the base template, adds its `dependencies` to the site's
125
- `package.json`, applies `--name` / `--lang`, installs, and prints the starter's
126
- README. After that the site owns the files; a starter has no update path (the
127
- theme it depends on still updates through npm).
125
+ `package.json`, applies `--name` / `--lang`, installs, and prints the
126
+ `## First steps` section of the starter's README (nothing, when it has none).
127
+ After that the site owns the files; a starter has no update path (the theme it
128
+ depends on still updates through npm).
128
129
 
129
130
  `--starter` takes:
130
131
 
131
132
  - a starter name: `blank` → `@nera-static/starter-blank`, `blog` →
132
- `@nera-static/starter-blog`;
133
+ `@nera-static/starter-blog`, `business` → `@nera-static/starter-business`;
133
134
  - a package name: `@acme/starter-shop`, or unscoped `nera-starter-shop`;
134
135
  - a GitHub URL: `https://github.com/owner/repo` (optionally `.git`, `#ref`),
135
136
  or `github:owner/repo#ref`;
@@ -161,8 +162,11 @@ default. Every question has a flag (`--starter`, `--name`, `--lang`); with
161
162
  `nera new` scaffolds the base template.
162
163
 
163
164
  The official starters: `blank` (`@nera-static/starter-blank`, one accessible
164
- layout, no theme) and `blog` (`@nera-static/starter-blog`, a blog on
165
- `@nera-static/theme-blog` with tags, an archive, an Atom feed and a sitemap).
165
+ layout, no theme), `blog` (`@nera-static/starter-blog`, a blog on
166
+ `@nera-static/theme-blog` with tags, an archive, an Atom feed and a sitemap)
167
+ and `business` (`@nera-static/starter-business`, a small-business site on
168
+ `@nera-static/theme-business` with services, opening hours and a contact
169
+ form).
166
170
 
167
171
  ## Checking the built site
168
172
 
@@ -285,7 +289,7 @@ layout `nera new` scaffolds.
285
289
 
286
290
  ## Requirements
287
291
 
288
- Node.js 20.19 or later, or 22.12 or later.
292
+ Node.js 22.12 or later (its bundled npm 10.9 or later).
289
293
 
290
294
  ## License
291
295
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nera-static/nera",
3
- "version": "1.12.0",
3
+ "version": "1.14.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": {
@@ -37,7 +37,7 @@
37
37
  },
38
38
  "homepage": "https://github.com/seebaermichi/nera-cli#readme",
39
39
  "engines": {
40
- "node": "^20.19.0 || >=22.12.0"
40
+ "node": ">=22.12.0"
41
41
  },
42
42
  "publishConfig": {
43
43
  "access": "public"
@@ -1,4 +1,6 @@
1
1
  import {
2
+ assertNpmCanInstall,
3
+ probeNpmVersion,
2
4
  scaffoldProject,
3
5
  isCurrentDir,
4
6
  validateProjectName,
@@ -44,12 +46,13 @@ async function defaultPrompt(options) {
44
46
  return runPicker({ ...options, listStarters, validateSiteName, validateLang })
45
47
  }
46
48
 
47
- // `prompt`, `scaffold` and the streams are injectable for tests.
49
+ // `prompt`, `scaffold`, `npmVersion` and the streams are injectable for tests.
48
50
  export async function runNew(args = [], {
49
51
  stdin = process.stdin,
50
52
  stdout = process.stdout,
51
53
  prompt = defaultPrompt,
52
54
  scaffold = scaffoldProject,
55
+ npmVersion = probeNpmVersion,
53
56
  } = {}) {
54
57
  const { projectName, yes, install, ...rest } = parseNewArgs(args)
55
58
  const options = Object.fromEntries(
@@ -63,6 +66,9 @@ export async function runNew(args = [], {
63
66
  if (options.theme !== undefined) validateThemeSpec(options.theme)
64
67
  if (options.name !== undefined) validateSiteName(options.name)
65
68
  if (options.lang !== undefined) validateLang(options.lang)
69
+ // An npm that would crash on install is refused before the
70
+ // questions too; scaffoldProject checks again for every caller.
71
+ if (install) assertNpmCanInstall(npmVersion())
66
72
  const answers = await prompt({
67
73
  input: stdin,
68
74
  output: stdout,
package/src/scaffold.js CHANGED
@@ -293,11 +293,49 @@ async function addSkill(targetDir) {
293
293
  )
294
294
  }
295
295
 
296
+ // npm before 10.9 (bundled with Node < 22.12) crashes in arborist on the
297
+ // optional peer chain of vite 8, which every site installs (ROADMAP-ai.md,
298
+ // slice 5, "the Node floor"). Checked before anything is written or asked.
299
+ const MIN_NPM = [10, 9, 0]
300
+
301
+ const defaultExecNpm = (args, options) => execFileSync('npm', args, options)
302
+
303
+ // The installed npm's version, or null when it cannot be read (no npm on the
304
+ // PATH, an unparseable answer): then nothing is refused, and `npm install`
305
+ // reports its own error.
306
+ export function probeNpmVersion(execNpm = defaultExecNpm) {
307
+ try {
308
+ const out = execNpm(['--version'], {
309
+ encoding: 'utf-8',
310
+ stdio: ['ignore', 'pipe', 'ignore'],
311
+ })
312
+ const version = String(out).trim()
313
+ return /^\d+\.\d+\.\d+/.test(version) ? version : null
314
+ } catch {
315
+ return null
316
+ }
317
+ }
318
+
319
+ // Throws when `version` is an npm that cannot install a site; null passes.
320
+ export function assertNpmCanInstall(version) {
321
+ if (version === null) return
322
+ const parts = version.split(/[.-]/).slice(0, 3).map(Number)
323
+ const i = parts.findIndex((n, j) => n !== MIN_NPM[j])
324
+ if (i === -1 || parts[i] > MIN_NPM[i]) return
325
+ throw new Error(
326
+ `npm ${version} cannot install a Nera site: it crashes on vite's ` +
327
+ 'dependencies. Use Node.js 22.12 or later, which bundles npm 10.9, ' +
328
+ 'or update npm with `npm i -g npm@10.9`, then run `nera new` ' +
329
+ 'again. Nothing was written.'
330
+ )
331
+ }
332
+
296
333
  // Scaffold a thin Nera site: copy the template, lay a starter kit over it if
297
334
  // one is given, name it, and (by default) install its dependencies. The one
298
335
  // code path for flags, the interactive picker and the future MCP tool
299
336
  // (ROADMAP-ai.md principle 4): everything comes in as plain options.
300
- // `fetchStarter(spec, { cwd })` → `{ dir, cleanup }` is injectable for tests.
337
+ // `fetchStarter(spec, { cwd })` → `{ dir, cleanup }` and `execNpm(args,
338
+ // options)` (execFileSync's shape) are injectable for tests.
301
339
  export async function scaffoldProject(projectName, options = {}) {
302
340
  const {
303
341
  install = true,
@@ -307,6 +345,7 @@ export async function scaffoldProject(projectName, options = {}) {
307
345
  name,
308
346
  lang,
309
347
  fetchStarter = defaultFetchStarter,
348
+ execNpm = defaultExecNpm,
310
349
  } = options
311
350
 
312
351
  // Everything is checked before anything is written.
@@ -321,6 +360,7 @@ export async function scaffoldProject(projectName, options = {}) {
321
360
  }
322
361
  if (name !== undefined) validateSiteName(name)
323
362
  if (lang !== undefined) validateLang(lang)
363
+ if (install) assertNpmCanInstall(probeNpmVersion(execNpm))
324
364
 
325
365
  const targetDir = path.resolve(cwd, inPlace ? '.' : projectName)
326
366
  if (inPlace) {
@@ -371,7 +411,7 @@ export async function scaffoldProject(projectName, options = {}) {
371
411
  // it resolved. A starter's dependencies are already in package.json.
372
412
  const themePkg = theme ? themePackageName(theme) : null
373
413
  const args = themePkg ? ['install', themePkg] : ['install']
374
- execFileSync('npm', args, { cwd: targetDir, stdio: 'inherit' })
414
+ execNpm(args, { cwd: targetDir, stdio: 'inherit' })
375
415
  }
376
416
 
377
417
  // `npm run dev`, not `nera dev`: it works without a global `nera`.
@@ -381,7 +421,7 @@ export async function scaffoldProject(projectName, options = {}) {
381
421
  'npm run dev',
382
422
  ]
383
423
  console.log('✅ Done!')
384
- if (readme) console.log(`\n${readme.replace(/\n*$/, '')}\n`)
424
+ if (readme) console.log(`\n${readme}\n`)
385
425
  console.log(
386
426
  `👉 Next steps:\n${nextSteps.map((l) => ` ${l}\n`).join('')}`
387
427
  )
package/src/starter.js CHANGED
@@ -170,7 +170,7 @@ const OWN_PACKAGE = '@nera-static/nera'
170
170
 
171
171
  // Lay the starter over a scaffolded site, file by file (the starter wins),
172
172
  // and merge its `dependencies` into the site's package.json. Symlinks are not
173
- // copied. Returns the starter's README text, or null.
173
+ // copied. Returns the "First steps" section of the starter's README, or null.
174
174
  export async function applyStarter(targetDir, starterDir) {
175
175
  const pkg = await readStarterManifest(starterDir)
176
176
  const notLink = async (src) => !(await fs.lstat(src)).isSymbolicLink()
@@ -200,12 +200,48 @@ export async function applyStarter(targetDir, starterDir) {
200
200
  console.log(` ✓ Applied the "${label}" starter${pkg.name ? ` (${pkg.name})` : ''}`)
201
201
 
202
202
  try {
203
- return await fs.readFile(path.join(starterDir, 'README.md'), 'utf-8')
203
+ return firstStepsSection(await fs.readFile(path.join(starterDir, 'README.md'), 'utf-8'))
204
204
  } catch {
205
205
  return null
206
206
  }
207
207
  }
208
208
 
209
+ const FIRST_STEPS = /^## +first steps *#* *$/i
210
+ const SECTION_END = /^#{1,2} /
211
+ const FENCE = /^ {0,3}(`{3,}|~{3,})/
212
+
213
+ // The `## First steps` section of a starter README, from its heading to the
214
+ // next `#`/`## ` heading or the end, trimmed; null when there is none. Headings
215
+ // inside fenced code blocks do not count. Line endings come back as `\n`.
216
+ export function firstStepsSection(readme) {
217
+ const lines = readme.replace(/\r\n?/g, '\n').split('\n')
218
+ let fence = null
219
+ let start = -1
220
+ let end = lines.length
221
+ for (let i = 0; i < lines.length; i++) {
222
+ const line = lines[i]
223
+ const marker = FENCE.exec(line)?.[1]
224
+ if (fence) {
225
+ // A fence closes on the same character, at least as long, alone on its line.
226
+ const closes = marker && marker[0] === fence[0] && marker.length >= fence.length
227
+ if (closes && line.trim() === marker) fence = null
228
+ continue
229
+ }
230
+ if (marker) {
231
+ fence = marker
232
+ continue
233
+ }
234
+ if (start < 0) {
235
+ if (FIRST_STEPS.test(line)) start = i
236
+ } else if (SECTION_END.test(line)) {
237
+ end = i
238
+ break
239
+ }
240
+ }
241
+ if (start < 0) return null
242
+ return lines.slice(start, end).join('\n').trim()
243
+ }
244
+
209
245
  const REGISTRY_SEARCH =
210
246
  'https://registry.npmjs.org/-/v1/search?text=keywords:nera-starter&size=50'
211
247
  const OFFICIAL_PREFIX = '@nera-static/'
package/src/starters.js CHANGED
@@ -15,4 +15,10 @@ export const BUNDLED_STARTERS = [
15
15
  label: 'Blog',
16
16
  description: 'A ready-to-edit Nera blog on @nera-static/theme-blog: three sample posts, tags, an archive by year, prev/next, an Atom feed, a sitemap, code blocks, navigation, responsive images, social links and imprint and privacy placeholders. No JavaScript.',
17
17
  },
18
+ {
19
+ name: 'business',
20
+ package: '@nera-static/starter-business',
21
+ label: 'Business',
22
+ description: 'A ready-to-edit Nera site for a small business on @nera-static/theme-business: home, about, services, opening hours, a contact form, navigation, responsive images, social links, a sitemap and imprint and privacy placeholders. One small script for the contact form.',
23
+ },
18
24
  ]
@@ -6,7 +6,9 @@
6
6
  "scripts": {
7
7
  "dev": "nera dev",
8
8
  "build": "nera build",
9
- "serve": "nera serve"
9
+ "serve": "nera serve",
10
+ "validate": "nera validate",
11
+ "check": "nera build --check"
10
12
  },
11
13
  "dependencies": {
12
14
  "@nera-static/nera": "^1.0.0"