@nera-static/nera 1.10.1 → 1.11.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
@@ -27,7 +27,7 @@ package is named after the folder (`My Bakery` → `my-bakery`).
27
27
 
28
28
  | Command | What it does |
29
29
  |---|---|
30
- | `nera new <name>` | Scaffold a new thin Nera site (one dependency: `@nera-static/nera`). Add `--theme <name>` to start from a theme, `--no-install` to skip `npm install`. `nera new .` scaffolds into the current, empty folder (dotfiles, `AGENTS.md` and `CLAUDE.md` allowed). Every new site also gets the `nera` skill for AI assistants. |
30
+ | `nera new <name>` | Scaffold a new thin Nera site (one dependency: `@nera-static/nera`). Add `--starter <spec>` to start from a starter kit or `--theme <name>` to start from a theme (not both), `--name "<site name>"` and `--lang <code>` to set `name:` and `lang:` in `config/app.yaml` (a new language gets a copy of the current language's `translations:` entry to translate), `--no-install` to skip `npm install`. On a terminal, without `--starter` or `--yes`, it asks what you are building, the site name and the language; without a terminal it never asks. `nera new .` scaffolds into the current, empty folder (dotfiles, `AGENTS.md` and `CLAUDE.md` allowed). Every new site also gets the `nera` skill for AI assistants. |
31
31
  | `nera build` | Render `pages/` → `public/`. With `--check`, then run `nera check` on the result — one command for CI. `--json` prints the result as one JSON object (see [Machine-readable output](#machine-readable-output)). |
32
32
  | `nera dev` | Build, serve `public/`, and rebuild on change with live reload. Watches `pages/`, `config/`, `theme/` and a local theme (`theme: ./themes/<name>`). |
33
33
  | `nera serve` | Serve the already-built `public/` folder. |
@@ -112,6 +112,56 @@ later stays hidden behind them. Each starter file carries a
112
112
  (`theme-shadowed`) while such a file hides a theme's. Delete it to use the
113
113
  theme's version, or delete the marker line to keep yours.
114
114
 
115
+ ## Starter kits
116
+
117
+ ```bash
118
+ npx @nera-static/nera new my-site --starter blank --name "Brot & Zeit" --lang de
119
+ ```
120
+
121
+ A starter kit is a whole ready-to-edit site: sample pages, config, optional
122
+ site-level `theme/` overrides, assets and the theme and plugins it depends on.
123
+ `nera new` copies its `pages/`, `config/`, `theme/`, `assets/`, `plugins/` and
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).
128
+
129
+ `--starter` takes:
130
+
131
+ - a starter name: `blank` → `@nera-static/starter-blank`;
132
+ - a package name: `@acme/starter-shop`, or unscoped `nera-starter-shop`;
133
+ - a GitHub URL: `https://github.com/owner/repo` (optionally `.git`, `#ref`),
134
+ or `github:owner/repo#ref`;
135
+ - a local path: `./my-starter`, `../my-starter` or an absolute path.
136
+
137
+ Packages and GitHub repositories are downloaded with
138
+ `npm pack --ignore-scripts`, so no script of the starter runs. A starter is a
139
+ package whose `package.json` has a `nera.starter` field
140
+ (`{ "label": "…", "description": "…" }`); anything else is refused. One that
141
+ sets `theme:` in its `config/app.yaml` leaves out the scaffold's starter
142
+ templates, as `--theme` does.
143
+
144
+ Run `nera new my-site` on a terminal and it asks instead:
145
+
146
+ ```text
147
+ What are you building?
148
+ 1) Empty — the base template, no starter
149
+ 2) …
150
+ Choose 1–2 [1]:
151
+ Site name [My Nera Site]:
152
+ Language [en]:
153
+ ```
154
+
155
+ The list comes from the npm registry (packages with the keyword
156
+ `nera-starter`, official `@nera-static/` ones first), or from a short list
157
+ bundled with the CLI when the registry cannot be reached. Enter accepts the
158
+ default. Every question has a flag (`--starter`, `--name`, `--lang`); with
159
+ `--yes`, or without a terminal (scripts, CI, AI agents), nothing is asked and
160
+ `nera new` scaffolds the base template.
161
+
162
+ The official starters are coming: `@nera-static/starter-blank` is the first,
163
+ more will follow. Until they are on npm, use a local path or your own package.
164
+
115
165
  ## Checking the built site
116
166
 
117
167
  ```bash
package/bin/nera.js CHANGED
@@ -13,7 +13,7 @@ const json = rest.includes('--json')
13
13
  const usage = `Nera — the static site generator CLI
14
14
 
15
15
  Usage:
16
- nera new <name> scaffold a new Nera site (--theme <name> to start from a theme, --no-install to skip npm install)
16
+ nera new <name> scaffold a new Nera site (--starter <name|package|github-url|./path> to start from a starter kit, --theme <name> to start from a theme, --name "<site name>", --lang <code>, --yes to never ask, --no-install to skip npm install; asks what you are building only on a terminal)
17
17
  nera new . scaffold into the current, empty folder
18
18
  nera build render pages/ → public/ (--check to check the output after, --json for machine-readable output)
19
19
  nera dev build, serve and live-reload
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nera-static/nera",
3
- "version": "1.10.1",
3
+ "version": "1.11.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": {
@@ -1,23 +1,81 @@
1
- import { scaffoldProject } from '../scaffold.js'
1
+ import {
2
+ scaffoldProject,
3
+ isCurrentDir,
4
+ validateProjectName,
5
+ validateThemeSpec,
6
+ validateSiteName,
7
+ validateLang,
8
+ } from '../scaffold.js'
9
+ import { validateStarterSpec } from '../starter.js'
2
10
 
3
- // `nera new <name> [--theme <spec> | --theme=<spec>] [--no-install]`
11
+ // Options that take a value, as `--opt <value>` or `--opt=<value>`.
12
+ const VALUE_OPTIONS = { '--theme': 'theme', '--starter': 'starter', '--name': 'name', '--lang': 'lang' }
13
+
14
+ // `nera new <name> [--starter <spec>] [--theme <spec>] [--name <text>]
15
+ // [--lang <code>] [--yes] [--no-install]`
4
16
  export function parseNewArgs(args = []) {
5
17
  let projectName
6
- let theme
18
+ const values = {}
7
19
  for (let i = 0; i < args.length; i++) {
8
20
  const arg = args[i]
9
- if (arg === '--theme') {
10
- theme = args[++i] ?? ''
11
- } else if (arg.startsWith('--theme=')) {
12
- theme = arg.slice('--theme='.length)
21
+ const eq = arg.indexOf('=')
22
+ const flag = eq === -1 ? arg : arg.slice(0, eq)
23
+ if (Object.hasOwn(VALUE_OPTIONS, flag)) {
24
+ values[VALUE_OPTIONS[flag]] = eq === -1 ? (args[++i] ?? '') : arg.slice(eq + 1)
13
25
  } else if (!arg.startsWith('-') && projectName === undefined) {
14
26
  projectName = arg
15
27
  }
16
28
  }
17
- return { projectName, theme, install: !args.includes('--no-install') }
29
+ return {
30
+ projectName,
31
+ ...values,
32
+ yes: args.includes('--yes'),
33
+ install: !args.includes('--no-install'),
34
+ }
35
+ }
36
+
37
+ // Lazily loaded: the picker and the registry lookup are only needed on a
38
+ // terminal.
39
+ async function defaultPrompt(options) {
40
+ const [{ runPicker }, { listStarters }] = await Promise.all([
41
+ import('../prompt.js'),
42
+ import('../starter.js'),
43
+ ])
44
+ return runPicker({ ...options, listStarters, validateSiteName, validateLang })
18
45
  }
19
46
 
20
- export async function runNew(args = []) {
21
- const { projectName, theme, install } = parseNewArgs(args)
22
- await scaffoldProject(projectName, { install, theme })
47
+ // `prompt`, `scaffold` and the streams are injectable for tests.
48
+ export async function runNew(args = [], {
49
+ stdin = process.stdin,
50
+ stdout = process.stdout,
51
+ prompt = defaultPrompt,
52
+ scaffold = scaffoldProject,
53
+ } = {}) {
54
+ const { projectName, yes, install, ...rest } = parseNewArgs(args)
55
+ const options = Object.fromEntries(
56
+ Object.entries(rest).filter(([, v]) => v !== undefined)
57
+ )
58
+
59
+ const { shouldPrompt } = await import('../prompt.js')
60
+ if (shouldPrompt({ yes, starter: options.starter, stdin, stdout })) {
61
+ // Fail on a bad flag before asking anything.
62
+ if (!isCurrentDir(projectName)) validateProjectName(projectName)
63
+ if (options.theme !== undefined) validateThemeSpec(options.theme)
64
+ if (options.name !== undefined) validateSiteName(options.name)
65
+ if (options.lang !== undefined) validateLang(options.lang)
66
+ const answers = await prompt({
67
+ input: stdin,
68
+ output: stdout,
69
+ // A theme rules out a starter, so that question is not asked.
70
+ skip: {
71
+ starter: options.theme !== undefined,
72
+ name: options.name !== undefined,
73
+ lang: options.lang !== undefined,
74
+ },
75
+ })
76
+ Object.assign(options, answers)
77
+ if (options.starter !== undefined) validateStarterSpec(options.starter)
78
+ }
79
+
80
+ return scaffold(projectName, { ...options, install })
23
81
  }
package/src/prompt.js ADDED
@@ -0,0 +1,127 @@
1
+ import readline from 'readline'
2
+
3
+ // The interactive part of `nera new` (ROADMAP-ai.md L5, principle 5). It only
4
+ // collects options; scaffoldProject does the work, the same as with flags.
5
+
6
+ // Questions are asked only on a terminal, never with --yes, and never once
7
+ // --starter is given.
8
+ export const shouldPrompt = ({ yes, starter, stdin = process.stdin, stdout = process.stdout }) =>
9
+ Boolean(stdin.isTTY && stdout.isTTY) && !yes && starter === undefined
10
+
11
+ export const EMPTY_CHOICE = 'Empty — the base template, no starter'
12
+ const TEMPLATE_NAME = 'My Nera Site'
13
+ const TEMPLATE_LANG = 'en'
14
+
15
+ // One question; Enter (or the end of input) gives ''.
16
+ const ask = (rl, question) =>
17
+ new Promise((resolve) => {
18
+ const onClose = () => resolve('')
19
+ rl.once('close', onClose)
20
+ rl.question(question, (answer) => {
21
+ rl.off('close', onClose)
22
+ resolve(answer.trim())
23
+ })
24
+ })
25
+
26
+ // Ask until `parse` accepts the answer; it returns undefined for "ask again".
27
+ // At the end of input every question takes its default.
28
+ async function askUntil(rl, question, parse) {
29
+ for (;;) {
30
+ const answer = rl.ended ? '' : await ask(rl, question)
31
+ const value = parse(answer)
32
+ if (value !== undefined) return value
33
+ }
34
+ }
35
+
36
+ // Ask "What are you building?", "Site name" and "Language", each skipped when
37
+ // its option is already set (`skip`). Enter keeps the default, which for name
38
+ // and language means "leave app.yaml as the template or the starter has it":
39
+ // the result then has no `name` / `lang`. `listStarters` and `validate*` are
40
+ // passed in so tests need no network and the rules stay in one place.
41
+ // Returns `{ starter?, name?, lang? }`.
42
+ export async function runPicker({
43
+ input = process.stdin,
44
+ output = process.stdout,
45
+ listStarters,
46
+ validateSiteName,
47
+ validateLang,
48
+ skip = {},
49
+ }) {
50
+ const rl = readline.createInterface({ input, output, terminal: Boolean(output.isTTY) })
51
+ let cancelled = false
52
+ rl.once('close', () => {
53
+ rl.ended = true
54
+ })
55
+ rl.on('SIGINT', () => {
56
+ output.write('\n')
57
+ cancelled = true
58
+ rl.close()
59
+ })
60
+ const check = () => {
61
+ if (cancelled) throw new Error('Cancelled — nothing was created.')
62
+ }
63
+ const say = (text) => output.write(`${text}\n`)
64
+ const invalid = (fn, value) => {
65
+ try {
66
+ fn(value)
67
+ return false
68
+ } catch (err) {
69
+ say(` ${err.message}`)
70
+ return true
71
+ }
72
+ }
73
+
74
+ try {
75
+ const result = {}
76
+
77
+ if (!skip.starter) {
78
+ const starters = await listStarters()
79
+ check()
80
+ const choices = [
81
+ { label: EMPTY_CHOICE },
82
+ ...starters.map((s) => ({
83
+ label: s.description ? `${s.name} — ${s.description}` : s.name,
84
+ name: s.name,
85
+ })),
86
+ ]
87
+ say('What are you building?')
88
+ choices.forEach((c, i) => say(` ${i + 1}) ${c.label}`))
89
+ const picked = await askUntil(
90
+ rl,
91
+ `Choose 1–${choices.length} [1]: `,
92
+ (answer) => {
93
+ if (answer === '') return choices[0]
94
+ const byNumber = /^\d+$/.test(answer) ? choices[Number(answer) - 1] : undefined
95
+ const found = byNumber ?? choices.find((c) => c.name && c.name === answer)
96
+ if (!found) say(` Please enter a number from 1 to ${choices.length}.`)
97
+ return found
98
+ }
99
+ )
100
+ check()
101
+ if (picked.name) result.starter = picked.name
102
+ }
103
+
104
+ const fromStarter = result.starter !== undefined
105
+ if (!skip.name) {
106
+ const shown = fromStarter ? 'the starter\'s' : TEMPLATE_NAME
107
+ const name = await askUntil(rl, `Site name [${shown}]: `, (answer) =>
108
+ answer === '' ? '' : invalid(validateSiteName, answer) ? undefined : answer
109
+ )
110
+ check()
111
+ if (name !== '') result.name = name
112
+ }
113
+
114
+ if (!skip.lang) {
115
+ const shown = fromStarter ? 'the starter\'s' : TEMPLATE_LANG
116
+ const lang = await askUntil(rl, `Language [${shown}]: `, (answer) =>
117
+ answer === '' ? '' : invalid(validateLang, answer) ? undefined : answer
118
+ )
119
+ check()
120
+ if (lang !== '') result.lang = lang
121
+ }
122
+
123
+ return result
124
+ } finally {
125
+ rl.close()
126
+ }
127
+ }
package/src/scaffold.js CHANGED
@@ -4,6 +4,13 @@ import fssync from 'fs'
4
4
  import path from 'path'
5
5
  import { fileURLToPath } from 'url'
6
6
  import { skillSourceDir, syncSkill } from './skill.js'
7
+ import {
8
+ applyStarter,
9
+ fetchStarter as defaultFetchStarter,
10
+ readStarterManifest,
11
+ starterSetsTheme,
12
+ validateStarterSpec,
13
+ } from './starter.js'
7
14
 
8
15
  // Directory-safe and npm-safe: no separators, no traversal, no leading dot or
9
16
  // dash, nothing the shell could reinterpret. Deliberately stricter than npm's
@@ -183,6 +190,99 @@ async function applyTheme(targetDir, theme) {
183
190
  console.log(` ✓ Using theme "${theme}"${pkgName ? ` (${pkgName})` : ''}`)
184
191
  }
185
192
 
193
+ // `--name`: any non-empty text without line breaks. It is written as a JSON
194
+ // string, which is also valid YAML, so `:`, `#` and quotes are safe.
195
+ export function validateSiteName(name) {
196
+ if (typeof name === 'string' && name.trim() !== '' && !/[\0-\x1f\x7f]/.test(name)) {
197
+ return name
198
+ }
199
+ throw new Error(
200
+ `Invalid site name ${JSON.stringify(name)}. Give a name on one line, ` +
201
+ 'e.g. --name "Brot & Zeit".'
202
+ )
203
+ }
204
+
205
+ // `--lang`: a BCP 47-style language tag (`de`, `en`, `pt-BR`).
206
+ const LANG = /^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$/
207
+
208
+ export function validateLang(lang) {
209
+ if (typeof lang === 'string' && LANG.test(lang)) return lang
210
+ throw new Error(
211
+ `Invalid language "${lang}". Use a language code such as en, de or pt-BR.`
212
+ )
213
+ }
214
+
215
+ // Set a top-level `key: value` line in an app.yaml text: replace the first
216
+ // one, or append it if there is none. Every other line, comment and nested
217
+ // block (translations:) stays as it is. `value` is written verbatim.
218
+ export function setAppValue(yaml, key, value) {
219
+ const line = new RegExp(`^${key}:.*$`, 'm')
220
+ if (line.test(yaml)) return yaml.replace(line, () => `${key}: ${value}`)
221
+ return `${yaml.replace(/\n*$/, '\n')}${key}: ${value}\n`
222
+ }
223
+
224
+ // Give a new language its own entry in the block-style `translations:` of an
225
+ // app.yaml text, copied from language `from`: core's `t(key)` falls back to
226
+ // the key itself, so a site switched to `de` with only an `en` block would
227
+ // render `app_description` on every page without its own description.
228
+ // Line-based like setAppValue; returns the text unchanged when there is no
229
+ // such block, `from` has no entry, or `to` already has one.
230
+ export function addLangTranslations(yaml, from, to) {
231
+ const lines = yaml.split('\n')
232
+ const start = lines.findIndex((l) => /^translations:[ \t]*(#.*)?$/.test(l))
233
+ if (start === -1 || !from || from === to) return yaml
234
+ let end = start + 1
235
+ while (end < lines.length && (lines[end].trim() === '' || /^[ \t]/.test(lines[end]))) end++
236
+ while (end > start + 1 && lines[end - 1].trim() === '') end--
237
+
238
+ // The line ` <lang>:` inside the block, or -1. Codes are validated
239
+ // (letters, digits, dashes), so they are safe in a RegExp.
240
+ const entry = (lang) => {
241
+ const re = new RegExp(`^[ \\t]+${lang}:[ \\t]*(#.*)?$`)
242
+ for (let i = start + 1; i < end; i++) if (re.test(lines[i])) return i
243
+ return -1
244
+ }
245
+ if (entry(to) !== -1) return yaml
246
+ const fromStart = entry(from)
247
+ if (fromStart === -1) return yaml
248
+
249
+ const indent = lines[fromStart].match(/^[ \t]+/)[0]
250
+ let fromEnd = fromStart + 1
251
+ while (
252
+ fromEnd < end &&
253
+ (lines[fromEnd].trim() === '' || lines[fromEnd].match(/^[ \t]*/)[0].length > indent.length)
254
+ ) fromEnd++
255
+ while (lines[fromEnd - 1].trim() === '') fromEnd--
256
+ const copy = [`${indent}${to}:`, ...lines.slice(fromStart + 1, fromEnd)]
257
+ return [...lines.slice(0, end), ...copy, ...lines.slice(end)].join('\n')
258
+ }
259
+
260
+ async function applyNameAndLang(targetDir, { name, lang }) {
261
+ if (name === undefined && lang === undefined) return
262
+ const appYaml = path.join(targetDir, 'config', 'app.yaml')
263
+ let yaml = await fs.readFile(appYaml, 'utf-8')
264
+ const oldLang = yaml.match(/^lang:[ \t]*["']?([A-Za-z0-9-]+)["']?[ \t]*(#.*)?$/m)?.[1]
265
+ if (name !== undefined) yaml = setAppValue(yaml, 'name', JSON.stringify(name))
266
+ let copied = false
267
+ if (lang !== undefined) {
268
+ yaml = setAppValue(yaml, 'lang', lang)
269
+ const withLang = addLangTranslations(yaml, oldLang, lang)
270
+ copied = withLang !== yaml
271
+ yaml = withLang
272
+ }
273
+ await fs.writeFile(appYaml, yaml)
274
+ const set = [
275
+ ...(name !== undefined ? [`name ${JSON.stringify(name)}`] : []),
276
+ ...(lang !== undefined ? [`language "${lang}"`] : []),
277
+ ]
278
+ console.log(` ✓ Set the site's ${set.join(' and ')} in config/app.yaml`)
279
+ if (copied) {
280
+ console.log(
281
+ ` ✓ Copied the "${oldLang}" translations to "${lang}" in config/app.yaml — translate them there`
282
+ )
283
+ }
284
+ }
285
+
186
286
  // The Nera skill for AI assistants, in both folders the clients read
187
287
  // (.agents/skills/nera, .claude/skills/nera). Meant to be committed, and
188
288
  // refreshed by `nera update`.
@@ -193,15 +293,34 @@ async function addSkill(targetDir) {
193
293
  )
194
294
  }
195
295
 
196
- // Scaffold a thin Nera site: copy the template, name it, and (by default)
197
- // install its single dependency, @nera-static/nera. No git clone, no vendored
198
- // engine — the opposite of the old installer's clone-and-strip flow.
296
+ // Scaffold a thin Nera site: copy the template, lay a starter kit over it if
297
+ // one is given, name it, and (by default) install its dependencies. The one
298
+ // code path for flags, the interactive picker and the future MCP tool
299
+ // (ROADMAP-ai.md principle 4): everything comes in as plain options.
300
+ // `fetchStarter(spec, { cwd })` → `{ dir, cleanup }` is injectable for tests.
199
301
  export async function scaffoldProject(projectName, options = {}) {
200
- const { install = true, cwd = process.cwd(), theme } = options
302
+ const {
303
+ install = true,
304
+ cwd = process.cwd(),
305
+ theme,
306
+ starter,
307
+ name,
308
+ lang,
309
+ fetchStarter = defaultFetchStarter,
310
+ } = options
201
311
 
312
+ // Everything is checked before anything is written.
202
313
  const inPlace = isCurrentDir(projectName)
203
314
  if (!inPlace) validateProjectName(projectName)
204
315
  if (theme !== undefined) validateThemeSpec(theme)
316
+ if (starter !== undefined) validateStarterSpec(starter)
317
+ if (starter !== undefined && theme !== undefined) {
318
+ throw new Error(
319
+ 'A starter brings its own theme; use --starter or --theme, not both.'
320
+ )
321
+ }
322
+ if (name !== undefined) validateSiteName(name)
323
+ if (lang !== undefined) validateLang(lang)
205
324
 
206
325
  const targetDir = path.resolve(cwd, inPlace ? '.' : projectName)
207
326
  if (inPlace) {
@@ -211,17 +330,45 @@ export async function scaffoldProject(projectName, options = {}) {
211
330
  }
212
331
  const packageName = inPlace ? projectNameFromDir(targetDir) : projectName
213
332
 
214
- console.log(`📦 Creating a new Nera site in ${targetDir}...`)
215
- await copyDir(templateDir(), targetDir, { skipStarters: Boolean(theme) })
216
- await personalize(targetDir, packageName)
217
- if (theme) await applyTheme(targetDir, theme)
218
- await addSkill(targetDir)
333
+ // The starter is fetched and checked before the site folder exists, so a
334
+ // download error or a package that is no starter leaves nothing behind.
335
+ const fetched = starter === undefined ? null : await fetchStarter(starter, { cwd })
336
+ let readme = null
337
+ try {
338
+ if (fetched) await readStarterManifest(fetched.dir, starter)
339
+ const skipStarters = Boolean(theme) || (fetched ? await starterSetsTheme(fetched.dir) : false)
340
+ const before = inPlace ? new Set(await fs.readdir(targetDir)) : null
341
+
342
+ console.log(`📦 Creating a new Nera site in ${targetDir}...`)
343
+ try {
344
+ await copyDir(templateDir(), targetDir, { skipStarters })
345
+ await personalize(targetDir, packageName)
346
+ if (theme) await applyTheme(targetDir, theme)
347
+ if (fetched) readme = await applyStarter(targetDir, fetched.dir)
348
+ await applyNameAndLang(targetDir, { name, lang })
349
+ await addSkill(targetDir)
350
+ } catch (err) {
351
+ if (!inPlace) {
352
+ await fs.rm(targetDir, { recursive: true, force: true })
353
+ throw err
354
+ }
355
+ const written = (await fs.readdir(targetDir)).filter((n) => !before.has(n))
356
+ err.message +=
357
+ written.length > 0
358
+ ? ` Already written to the current folder: ${written.join(', ')}. ` +
359
+ 'Remove them before running `nera new .` again.'
360
+ : ''
361
+ throw err
362
+ }
363
+ } finally {
364
+ await fetched?.cleanup()
365
+ }
219
366
 
220
367
  if (install) {
221
368
  console.log('📦 Installing dependencies...')
222
369
  // Naming the theme package installs everything else too, and makes npm
223
370
  // replace the `latest` placeholder with a caret range for the version
224
- // it resolved.
371
+ // it resolved. A starter's dependencies are already in package.json.
225
372
  const themePkg = theme ? themePackageName(theme) : null
226
373
  const args = themePkg ? ['install', themePkg] : ['install']
227
374
  execFileSync('npm', args, { cwd: targetDir, stdio: 'inherit' })
@@ -234,6 +381,7 @@ export async function scaffoldProject(projectName, options = {}) {
234
381
  'npm run dev',
235
382
  ]
236
383
  console.log('✅ Done!')
384
+ if (readme) console.log(`\n${readme.replace(/\n*$/, '')}\n`)
237
385
  console.log(
238
386
  `👉 Next steps:\n${nextSteps.map((l) => ` ${l}\n`).join('')}`
239
387
  )
package/src/starter.js ADDED
@@ -0,0 +1,261 @@
1
+ import { execFileSync } from 'child_process'
2
+ import fs from 'fs/promises'
3
+ import fssync from 'fs'
4
+ import os from 'os'
5
+ import path from 'path'
6
+ import { BUNDLED_STARTERS } from './starters.js'
7
+
8
+ // What `--starter` accepts (ROADMAP-ai.md L5), validated before anything is
9
+ // written because the package forms end up on the npm command line:
10
+ // blank → @nera-static/starter-blank
11
+ // @acme/starter-x, nera-starter-x → verbatim
12
+ // https://github.com/o/r[.git][#ref] → github:o/r[#ref]
13
+ // github:o/r[#ref] → verbatim
14
+ // ./x, ../x, /abs/x → read in place
15
+ const BARE_STARTER = /^[a-z0-9][a-z0-9._-]*$/
16
+ const SCOPED_STARTER = /^@[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*$/
17
+ const UNSCOPED_STARTER = /^nera-starter-[a-z0-9][a-z0-9._-]*$/
18
+ const GH_OWNER = '[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?'
19
+ const GH_REPO = '[A-Za-z0-9._-]+?'
20
+ const GH_REF = '[A-Za-z0-9._][A-Za-z0-9._/-]*'
21
+ const GITHUB_URL = new RegExp(
22
+ `^https://github\\.com/(${GH_OWNER})/(${GH_REPO})(?:\\.git)?(?:#(${GH_REF}))?$`
23
+ )
24
+ const GITHUB_SHORT = new RegExp(
25
+ `^github:(${GH_OWNER})/(${GH_REPO})(?:\\.git)?(?:#(${GH_REF}))?$`
26
+ )
27
+ // LOCAL_THEME in scaffold.js, plus absolute paths.
28
+ const LOCAL_STARTER = /^(?:\.{1,2}(?:\/[A-Za-z0-9._-]+)*|(?:\/[A-Za-z0-9._-]+)+)\/?$/
29
+
30
+ const isLocal = (spec) => LOCAL_STARTER.test(spec)
31
+
32
+ export function validateStarterSpec(spec) {
33
+ if (
34
+ typeof spec === 'string' &&
35
+ (BARE_STARTER.test(spec) ||
36
+ SCOPED_STARTER.test(spec) ||
37
+ UNSCOPED_STARTER.test(spec) ||
38
+ GITHUB_URL.test(spec) ||
39
+ GITHUB_SHORT.test(spec) ||
40
+ isLocal(spec))
41
+ ) {
42
+ return spec
43
+ }
44
+ throw new Error(
45
+ `Invalid starter "${spec}". Use a starter name (blank), a package ` +
46
+ 'name (@scope/starter-x or nera-starter-x), a GitHub URL ' +
47
+ '(https://github.com/owner/repo#ref or github:owner/repo) or a ' +
48
+ 'local path (./my-starter).'
49
+ )
50
+ }
51
+
52
+ // Where a (valid) spec comes from: a folder read in place, or a spec for
53
+ // `npm pack`.
54
+ export function starterSource(spec) {
55
+ validateStarterSpec(spec)
56
+ if (isLocal(spec)) return { kind: 'local', path: spec }
57
+ const gh = spec.match(GITHUB_URL) ?? spec.match(GITHUB_SHORT)
58
+ if (gh) {
59
+ const [, owner, repo, ref] = gh
60
+ return { kind: 'npm', spec: `github:${owner}/${repo}${ref ? `#${ref}` : ''}` }
61
+ }
62
+ if (spec.startsWith('@') || UNSCOPED_STARTER.test(spec)) {
63
+ return { kind: 'npm', spec }
64
+ }
65
+ return { kind: 'npm', spec: `@nera-static/starter-${spec}` }
66
+ }
67
+
68
+ const downloadError = (spec, npmSpec, err) => {
69
+ const said = String(err?.stderr ?? '')
70
+ .split('\n')
71
+ .find((l) => /^npm (error|ERR!) /.test(l) && !/ (code|errno) |A complete log/.test(l))
72
+ return new Error(
73
+ `Could not download the starter "${spec}" (npm pack ${npmSpec} failed` +
74
+ `${said ? `: ${said.replace(/^npm (error|ERR!)\s*/, '')}` : ''}). ` +
75
+ 'Check the starter name for typos and your network connection, ' +
76
+ 'then run `nera new` again.'
77
+ )
78
+ }
79
+
80
+ // Download a package or GitHub starter into a temp folder: `npm pack` (with
81
+ // --ignore-scripts, so a git dependency's `prepare` never runs) and `tar`.
82
+ // npm tarballs unpack to `package/`. The temp folder is gone on failure, and
83
+ // `cleanup()` removes it after use. `exec` is injectable for tests.
84
+ export async function packStarter(npmSpec, { spec = npmSpec, exec = execFileSync } = {}) {
85
+ console.log(`📦 Downloading the starter "${spec}"...`)
86
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'nera-starter-'))
87
+ const cleanup = () => fs.rm(tmp, { recursive: true, force: true })
88
+ try {
89
+ const packDir = path.join(tmp, 'pack')
90
+ const outDir = path.join(tmp, 'out')
91
+ await fs.mkdir(packDir)
92
+ await fs.mkdir(outDir)
93
+ try {
94
+ exec(
95
+ 'npm',
96
+ ['pack', npmSpec, '--ignore-scripts', '--pack-destination', packDir],
97
+ { stdio: 'pipe', encoding: 'utf-8' }
98
+ )
99
+ } catch (err) {
100
+ throw downloadError(spec, npmSpec, err)
101
+ }
102
+ const tarball = (await fs.readdir(packDir)).find((n) => n.endsWith('.tgz'))
103
+ if (!tarball) throw downloadError(spec, npmSpec)
104
+ exec('tar', ['-xzf', path.join(packDir, tarball), '-C', outDir], {
105
+ stdio: 'pipe',
106
+ })
107
+ return { dir: path.join(outDir, 'package'), cleanup }
108
+ } catch (err) {
109
+ await cleanup()
110
+ throw err
111
+ }
112
+ }
113
+
114
+ // The starter as a folder on disk plus a `cleanup()`. A local path is read in
115
+ // place (relative to `cwd`) and needs no cleanup.
116
+ export async function fetchStarter(spec, { cwd = process.cwd(), pack = packStarter } = {}) {
117
+ const source = starterSource(spec)
118
+ if (source.kind === 'local') {
119
+ const dir = path.resolve(cwd, source.path)
120
+ if (!fssync.existsSync(dir)) {
121
+ throw new Error(
122
+ `Starter folder "${spec}" not found (looked in ${dir}). ` +
123
+ 'Check the path; a local starter is a folder with a package.json.'
124
+ )
125
+ }
126
+ return { dir, cleanup: async () => {} }
127
+ }
128
+ return pack(source.spec, { spec })
129
+ }
130
+
131
+ // The starter's package.json. Only a package with a `nera.starter` field is a
132
+ // starter; anything else is refused before the site is written.
133
+ export async function readStarterManifest(dir, spec = dir) {
134
+ let pkg
135
+ try {
136
+ pkg = JSON.parse(await fs.readFile(path.join(dir, 'package.json'), 'utf-8'))
137
+ } catch {
138
+ throw new Error(
139
+ `"${spec}" is not a Nera starter: it has no readable package.json. ` +
140
+ 'A starter is a folder whose package.json has a `nera.starter` field.'
141
+ )
142
+ }
143
+ const starter = pkg?.nera?.starter
144
+ if (!starter || typeof starter !== 'object') {
145
+ throw new Error(
146
+ `"${spec}" is not a Nera starter: its package.json has no ` +
147
+ '`nera.starter` field.'
148
+ )
149
+ }
150
+ return pkg
151
+ }
152
+
153
+ // Does the starter's config/app.yaml pick a theme? Then the base template's
154
+ // own views (nera:scaffold-default) are left out, as with `--theme`.
155
+ export async function starterSetsTheme(dir) {
156
+ try {
157
+ const yaml = await fs.readFile(path.join(dir, 'config', 'app.yaml'), 'utf-8')
158
+ return /^theme:[ \t]*[^\s#]/m.test(yaml)
159
+ } catch {
160
+ return false
161
+ }
162
+ }
163
+
164
+ // The only parts of a starter copied into the site. Never its package.json,
165
+ // AGENTS.md, CLAUDE.md, .claude/, .agents/, node_modules/ or anything else.
166
+ export const STARTER_ENTRIES = ['pages', 'config', 'theme', 'assets', 'plugins', '.neraignore']
167
+
168
+ // The site's own CLI dependency is never replaced by a starter's.
169
+ const OWN_PACKAGE = '@nera-static/nera'
170
+
171
+ // Lay the starter over a scaffolded site, file by file (the starter wins),
172
+ // and merge its `dependencies` into the site's package.json. Symlinks are not
173
+ // copied. Returns the starter's README text, or null.
174
+ export async function applyStarter(targetDir, starterDir) {
175
+ const pkg = await readStarterManifest(starterDir)
176
+ const notLink = async (src) => !(await fs.lstat(src)).isSymbolicLink()
177
+ for (const entry of STARTER_ENTRIES) {
178
+ const from = path.join(starterDir, entry)
179
+ if (!fssync.existsSync(from) || !(await notLink(from))) continue
180
+ await fs.cp(from, path.join(targetDir, entry), {
181
+ recursive: true,
182
+ force: true,
183
+ filter: notLink,
184
+ })
185
+ }
186
+
187
+ const deps = Object.fromEntries(
188
+ Object.entries(pkg.dependencies ?? {}).filter(
189
+ ([name, range]) => name !== OWN_PACKAGE && typeof range === 'string'
190
+ )
191
+ )
192
+ if (Object.keys(deps).length > 0) {
193
+ const pkgPath = path.join(targetDir, 'package.json')
194
+ const site = JSON.parse(await fs.readFile(pkgPath, 'utf-8'))
195
+ site.dependencies = { ...site.dependencies, ...deps }
196
+ await fs.writeFile(pkgPath, `${JSON.stringify(site, null, 4)}\n`)
197
+ }
198
+
199
+ const label = pkg.nera.starter.label ?? pkg.name
200
+ console.log(` ✓ Applied the "${label}" starter${pkg.name ? ` (${pkg.name})` : ''}`)
201
+
202
+ try {
203
+ return await fs.readFile(path.join(starterDir, 'README.md'), 'utf-8')
204
+ } catch {
205
+ return null
206
+ }
207
+ }
208
+
209
+ const REGISTRY_SEARCH =
210
+ 'https://registry.npmjs.org/-/v1/search?text=keywords:nera-starter&size=50'
211
+ const OFFICIAL_PREFIX = '@nera-static/'
212
+ const OFFICIAL_STARTER_PREFIX = '@nera-static/starter-'
213
+
214
+ // The name the picker shows and `--starter` takes: the bare name for an
215
+ // official starter, the full package name otherwise.
216
+ const displayName = (pkg) =>
217
+ pkg.startsWith(OFFICIAL_STARTER_PREFIX) ? pkg.slice(OFFICIAL_STARTER_PREFIX.length) : pkg
218
+
219
+ // Only results whose shown name leads back to the same package: an unscoped
220
+ // `cool-site` would otherwise be offered as `cool-site`, which `--starter`
221
+ // reads as @nera-static/starter-cool-site.
222
+ const roundTrips = ({ name, package: pkg }) => {
223
+ try {
224
+ return starterSource(name).spec === pkg
225
+ } catch {
226
+ return false
227
+ }
228
+ }
229
+
230
+ // The starters on npm (keyword `nera-starter`), official ones first, then
231
+ // alphabetically: `[{ name, package, description }]`. Labels come from the
232
+ // search result's description; manifests are not fetched. On any failure
233
+ // (offline, timeout, non-200, bad JSON) the bundled list is returned instead.
234
+ export async function listStarters({ fetch = globalThis.fetch, timeout = 3000 } = {}) {
235
+ try {
236
+ const res = await fetch(REGISTRY_SEARCH, { signal: AbortSignal.timeout(timeout) })
237
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
238
+ const body = await res.json()
239
+ if (!Array.isArray(body?.objects)) throw new Error('unexpected response')
240
+ const seen = new Set()
241
+ return body.objects
242
+ .map((o) => o?.package)
243
+ .filter((p) => typeof p?.name === 'string' && !seen.has(p.name) && seen.add(p.name))
244
+ .map((p) => ({
245
+ name: displayName(p.name),
246
+ package: p.name,
247
+ description: typeof p.description === 'string' ? p.description : '',
248
+ }))
249
+ .filter(roundTrips)
250
+ .sort((a, b) => {
251
+ const official = (s) => (s.package.startsWith(OFFICIAL_PREFIX) ? 0 : 1)
252
+ return official(a) - official(b) || a.package.localeCompare(b.package)
253
+ })
254
+ } catch {
255
+ return BUNDLED_STARTERS.map(({ name, package: pkg, label, description }) => ({
256
+ name,
257
+ package: pkg,
258
+ description: label ? `${label}: ${description}` : description,
259
+ }))
260
+ }
261
+ }
@@ -0,0 +1,12 @@
1
+ // The official starters `nera new` offers when the npm registry cannot be
2
+ // reached. Hand-kept: add each official @nera-static/starter-* here once it
3
+ // is published, so the offline list never names a package that does not
4
+ // exist.
5
+ export const BUNDLED_STARTERS = [
6
+ {
7
+ name: 'blank',
8
+ package: '@nera-static/starter-blank',
9
+ label: 'Blank',
10
+ description: 'The smallest Nera starter: one page, one layout.',
11
+ },
12
+ ]