@nera-static/nera 1.11.1 → 1.13.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 +10 -7
- package/package.json +4 -4
- package/src/commands/new.js +7 -1
- package/src/scaffold.js +43 -3
- package/src/starter.js +38 -2
- package/src/starters.js +6 -0
- package/template/package.json +3 -1
package/README.md
CHANGED
|
@@ -122,13 +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
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
- a starter name: `blank` → `@nera-static/starter-blank
|
|
132
|
+
- a starter name: `blank` → `@nera-static/starter-blank`, `blog` →
|
|
133
|
+
`@nera-static/starter-blog`;
|
|
132
134
|
- a package name: `@acme/starter-shop`, or unscoped `nera-starter-shop`;
|
|
133
135
|
- a GitHub URL: `https://github.com/owner/repo` (optionally `.git`, `#ref`),
|
|
134
136
|
or `github:owner/repo#ref`;
|
|
@@ -159,8 +161,9 @@ default. Every question has a flag (`--starter`, `--name`, `--lang`); with
|
|
|
159
161
|
`--yes`, or without a terminal (scripts, CI, AI agents), nothing is asked and
|
|
160
162
|
`nera new` scaffolds the base template.
|
|
161
163
|
|
|
162
|
-
The official starters
|
|
163
|
-
|
|
164
|
+
The official starters: `blank` (`@nera-static/starter-blank`, one accessible
|
|
165
|
+
layout, no theme) and `blog` (`@nera-static/starter-blog`, a blog on
|
|
166
|
+
`@nera-static/theme-blog` with tags, an archive, an Atom feed and a sitemap).
|
|
164
167
|
|
|
165
168
|
## Checking the built site
|
|
166
169
|
|
|
@@ -283,7 +286,7 @@ layout `nera new` scaffolds.
|
|
|
283
286
|
|
|
284
287
|
## Requirements
|
|
285
288
|
|
|
286
|
-
Node.js
|
|
289
|
+
Node.js 22.12 or later (its bundled npm 10.9 or later).
|
|
287
290
|
|
|
288
291
|
## License
|
|
289
292
|
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nera-static/nera",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.13.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": {
|
|
7
|
-
"nera": "
|
|
7
|
+
"nera": "bin/nera.js"
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"bin",
|
|
@@ -30,14 +30,14 @@
|
|
|
30
30
|
"license": "MIT",
|
|
31
31
|
"repository": {
|
|
32
32
|
"type": "git",
|
|
33
|
-
"url": "https://github.com/seebaermichi/nera-cli.git"
|
|
33
|
+
"url": "git+https://github.com/seebaermichi/nera-cli.git"
|
|
34
34
|
},
|
|
35
35
|
"bugs": {
|
|
36
36
|
"url": "https://github.com/seebaermichi/nera-cli/issues"
|
|
37
37
|
},
|
|
38
38
|
"homepage": "https://github.com/seebaermichi/nera-cli#readme",
|
|
39
39
|
"engines": {
|
|
40
|
-
"node": "
|
|
40
|
+
"node": ">=22.12.0"
|
|
41
41
|
},
|
|
42
42
|
"publishConfig": {
|
|
43
43
|
"access": "public"
|
package/src/commands/new.js
CHANGED
|
@@ -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 }`
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
@@ -9,4 +9,10 @@ export const BUNDLED_STARTERS = [
|
|
|
9
9
|
label: 'Blank',
|
|
10
10
|
description: 'The smallest Nera starter: a home page, imprint and privacy placeholders, and one accessible layout. No theme, no plugins, no JavaScript.',
|
|
11
11
|
},
|
|
12
|
+
{
|
|
13
|
+
name: 'blog',
|
|
14
|
+
package: '@nera-static/starter-blog',
|
|
15
|
+
label: 'Blog',
|
|
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
|
+
},
|
|
12
18
|
]
|
package/template/package.json
CHANGED