@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 +51 -1
- package/bin/nera.js +1 -1
- package/package.json +1 -1
- package/src/commands/new.js +69 -11
- package/src/prompt.js +127 -0
- package/src/scaffold.js +158 -10
- package/src/starter.js +261 -0
- package/src/starters.js +12 -0
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
package/src/commands/new.js
CHANGED
|
@@ -1,23 +1,81 @@
|
|
|
1
|
-
import {
|
|
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
|
-
//
|
|
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
|
-
|
|
18
|
+
const values = {}
|
|
7
19
|
for (let i = 0; i < args.length; i++) {
|
|
8
20
|
const arg = args[i]
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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 {
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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,
|
|
197
|
-
//
|
|
198
|
-
//
|
|
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 {
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
await
|
|
217
|
-
|
|
218
|
-
|
|
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
|
+
}
|
package/src/starters.js
ADDED
|
@@ -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
|
+
]
|