uniweb 0.8.3 → 0.8.5

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,6 +27,7 @@ Edit files in `site/pages/` and `foundation/src/sections/` to see changes instan
27
27
  | Template | Description |
28
28
  | --- | --- |
29
29
  | **Starter** | Foundation + site + sample content (default) |
30
+ | **None** | Foundation + site with no content |
30
31
  | **Marketing** | Landing page, features, pricing, testimonials |
31
32
  | **Docs** | Documentation with sidebar and search |
32
33
  | **Academic** | Research site with publications and team |
@@ -34,7 +35,8 @@ Edit files in `site/pages/` and `foundation/src/sections/` to see changes instan
34
35
  | **Dynamic** | Live API data fetching with loading states |
35
36
  | **Store** | E-commerce with product grid |
36
37
  | **Extensions** | Multi-foundation with visual effects extension |
37
- | **Blank** | Empty workspace — grow with `uniweb add` |
38
+
39
+ Use `--blank` for an empty workspace (no packages) — grow with `uniweb add`.
38
40
 
39
41
  **See them live:** [View all template demos](https://uniweb.github.io/templates/)
40
42
 
@@ -234,31 +236,32 @@ Start simple. Add what you need, when you need it:
234
236
  ```bash
235
237
  cd my-site
236
238
 
237
- # Add a second foundation
238
- npx uniweb add foundation blog
239
+ # Add a co-located foundation + site pair
240
+ npx uniweb add project blog
241
+
242
+ # Add a second foundation at root
243
+ npx uniweb add foundation ui
239
244
 
240
- # Add a site wired to the blog foundation
241
- npx uniweb add site blog --foundation blog
245
+ # Add a site wired to a specific foundation
246
+ npx uniweb add site docs --foundation ui
242
247
 
243
248
  # Add an extension (auto-wired to the only site)
244
249
  npx uniweb add extension effects
245
250
 
246
251
  # Scaffold + apply content from an official template
247
- npx uniweb add foundation marketing --from marketing
248
- npx uniweb add site main --from marketing --foundation marketing
252
+ npx uniweb add project marketing --from marketing
249
253
  ```
250
254
 
251
- The workspace grows organically. `add` handles placement, wires dependencies, updates workspace globs, and generates root scripts. Use `--path` to override default placement, or `--project` for co-located layouts (e.g., `marketing/foundation/` + `marketing/site/`).
255
+ The workspace grows organically. `add` handles placement, wires dependencies, updates workspace globs, and generates root scripts. The name you provide becomes both the directory name and the package name. Use `--path` to override default placement, or `--project` for explicit co-located layouts.
252
256
 
253
257
  > `npx uniweb` works before and after install. Once dependencies are installed, you can also use `pnpm uniweb` directly since `uniweb` is a project dependency.
254
258
 
255
259
  **Or start blank and build up:**
256
260
 
257
261
  ```bash
258
- pnpm create uniweb acme --template blank
262
+ pnpm create uniweb acme --blank
259
263
  cd acme
260
- npx uniweb add foundation
261
- npx uniweb add site
264
+ npx uniweb add project main
262
265
  pnpm install
263
266
  pnpm dev
264
267
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uniweb",
3
- "version": "0.8.3",
3
+ "version": "0.8.5",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,9 +41,9 @@
41
41
  "js-yaml": "^4.1.0",
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.0.0",
44
- "@uniweb/runtime": "0.6.4",
45
- "@uniweb/build": "0.8.3",
46
44
  "@uniweb/core": "0.5.4",
47
- "@uniweb/kit": "0.7.3"
45
+ "@uniweb/kit": "0.7.3",
46
+ "@uniweb/runtime": "0.6.4",
47
+ "@uniweb/build": "0.8.4"
48
48
  }
49
49
  }
@@ -27,17 +27,27 @@ pnpm create uniweb my-project
27
27
  cd my-project && pnpm install
28
28
  ```
29
29
 
30
- Use `--template blank` for an empty workspace, or `--template <name>` for an official template (`marketing`, `docs`, `academic`, etc.).
30
+ This creates a workspace with foundation + site + starter content — two commands to a dev server. Use `--template <name>` for an official template (`marketing`, `docs`, `academic`, etc.), `--template none` for foundation + site with no content, or `--blank` for an empty workspace.
31
31
 
32
- ### Adding to an existing workspace
32
+ ### Adding a co-located project
33
33
 
34
34
  ```bash
35
- pnpm uniweb add foundation myname --project myname
36
- pnpm uniweb add site myname --project myname
35
+ pnpm uniweb add project docs
37
36
  pnpm install
38
37
  ```
39
38
 
40
- The `--project` flag co-locates foundation and site under `myname/`. The CLI names them `myname` (foundation) and `myname-site` (site) to avoid workspace name collisions.
39
+ This creates `docs/foundation/` + `docs/site/` with package names `docs-foundation` and `docs-site`. Use `--from <template>` to apply template content to both packages.
40
+
41
+ ### Adding individual packages
42
+
43
+ ```bash
44
+ pnpm uniweb add foundation # First foundation → ./foundation/
45
+ pnpm uniweb add foundation ui # Named → ./ui/
46
+ pnpm uniweb add site # First site → ./site/
47
+ pnpm uniweb add site blog # Named → ./blog/
48
+ ```
49
+
50
+ The name is both the directory name and the package name. Use `--project <name>` to co-locate under a project directory (e.g., `--project docs` → `docs/foundation/`).
41
51
 
42
52
  ### What the CLI generates
43
53
 
@@ -805,7 +815,7 @@ Uniweb section types do more with less because the framework handles concerns th
805
815
 
806
816
  1. **Check if you're inside an existing Uniweb workspace** (look for `pnpm-workspace.yaml` and a `package.json` with `uniweb` as a dependency). If yes, use `pnpm uniweb add` to create projects inside it. If no, create a new workspace:
807
817
  ```bash
808
- pnpm create uniweb my-project --template blank
818
+ pnpm create uniweb my-project --template none
809
819
  ```
810
820
 
811
821
  3. **Use named layouts** for different page groups — a marketing layout for landing pages, a docs layout for `/docs/*`. One site, multiple layouts, each with its own header/footer/sidebar content.
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * Add Command
3
3
  *
4
- * Adds foundations, sites, or extensions to an existing workspace.
4
+ * Adds foundations, sites, extensions, or co-located projects to an existing workspace.
5
5
  *
6
6
  * Usage:
7
+ * uniweb add project [name] [--from <template>]
7
8
  * uniweb add foundation [name] [--from <template>] [--path <dir>] [--project <name>]
8
9
  * uniweb add site [name] [--from <template>] [--foundation <name>] [--path <dir>] [--project <name>]
9
10
  * uniweb add extension [name] [--from <template>] [--site <name>] [--path <dir>]
@@ -14,7 +15,7 @@ import { readFile, writeFile } from 'node:fs/promises'
14
15
  import { join, relative } from 'node:path'
15
16
  import prompts from 'prompts'
16
17
  import yaml from 'js-yaml'
17
- import { scaffoldFoundation, scaffoldSite, applyContent, mergeTemplateDependencies } from '../utils/scaffold.js'
18
+ import { scaffoldFoundation, scaffoldSite, applyContent, applyStarter, mergeTemplateDependencies } from '../utils/scaffold.js'
18
19
  import {
19
20
  readWorkspaceConfig,
20
21
  addWorkspaceGlob,
@@ -25,6 +26,7 @@ import {
25
26
  import { validatePackageName, getExistingPackageNames, resolveUniqueName } from '../utils/names.js'
26
27
  import { findWorkspaceRoot } from '../utils/workspace.js'
27
28
  import { detectPackageManager, filterCmd, installCmd } from '../utils/pm.js'
29
+ import { isNonInteractive, getCliPrefix, stripNonInteractiveFlag, formatOptions } from '../utils/interactive.js'
28
30
  import { resolveTemplate } from '../templates/index.js'
29
31
  import { validateTemplate } from '../templates/validator.js'
30
32
  import { getVersionsForTemplates } from '../versions.js'
@@ -91,13 +93,17 @@ function parseArgs(args) {
91
93
  /**
92
94
  * Main add command handler
93
95
  */
94
- export async function add(args) {
96
+ export async function add(rawArgs) {
97
+ const nonInteractive = isNonInteractive(rawArgs)
98
+ const args = stripNonInteractiveFlag(rawArgs)
99
+
95
100
  if (args[0] === '--help' || args[0] === '-h') {
96
101
  showAddHelp()
97
102
  return
98
103
  }
99
104
 
100
105
  const pm = detectPackageManager()
106
+ const prefix = getCliPrefix()
101
107
 
102
108
  // Find workspace root
103
109
  const rootDir = findWorkspaceRoot()
@@ -110,11 +116,25 @@ export async function add(args) {
110
116
  // Interactive subcommand chooser when no args given
111
117
  let parsed
112
118
  if (!args.length || (args[0] && args[0].startsWith('--'))) {
119
+ if (nonInteractive) {
120
+ error(`Missing subcommand.\n`)
121
+ log(formatOptions([
122
+ { label: 'project', description: 'Co-located foundation + site pair' },
123
+ { label: 'foundation', description: 'Component library' },
124
+ { label: 'site', description: 'Content site' },
125
+ { label: 'extension', description: 'Additional component package' },
126
+ ]))
127
+ log('')
128
+ log(`Usage: ${prefix} add <project|foundation|site|extension> [name]`)
129
+ process.exit(1)
130
+ }
131
+
113
132
  const response = await prompts({
114
133
  type: 'select',
115
134
  name: 'subcommand',
116
135
  message: 'What would you like to add?',
117
136
  choices: [
137
+ { title: 'Project', value: 'project', description: 'Co-located foundation + site pair' },
118
138
  { title: 'Foundation', value: 'foundation', description: 'Component library' },
119
139
  { title: 'Site', value: 'site', description: 'Content site' },
120
140
  { title: 'Extension', value: 'extension', description: 'Additional component package' },
@@ -137,6 +157,9 @@ export async function add(args) {
137
157
  const projectName = rootPkg.name || 'my-project'
138
158
 
139
159
  switch (parsed.subcommand) {
160
+ case 'project':
161
+ await addProject(rootDir, projectName, parsed, pm)
162
+ break
140
163
  case 'foundation':
141
164
  await addFoundation(rootDir, projectName, parsed, pm)
142
165
  break
@@ -148,7 +171,7 @@ export async function add(args) {
148
171
  break
149
172
  default:
150
173
  error(`Unknown subcommand: ${parsed.subcommand}`)
151
- log(`Valid subcommands: foundation, site, extension`)
174
+ log(`Valid subcommands: project, foundation, site, extension`)
152
175
  process.exit(1)
153
176
  }
154
177
  }
@@ -171,25 +194,28 @@ async function addFoundation(rootDir, projectName, opts, pm = 'pnpm') {
171
194
 
172
195
  // Interactive name prompt when name not provided and no --path
173
196
  if (!name && !opts.path) {
174
- const foundations = await discoverFoundations(rootDir)
175
- const hasDefault = foundations.length === 0 && !existsSync(join(rootDir, 'foundation'))
176
- const response = await prompts({
177
- type: 'text',
178
- name: 'name',
179
- message: 'Foundation name:',
180
- initial: hasDefault ? 'foundation' : undefined,
181
- validate: (value) => validatePackageName(value),
182
- }, {
183
- onCancel: () => {
184
- log('\nCancelled.')
185
- process.exit(0)
186
- },
187
- })
188
- // Only set name if user chose something other than the default —
189
- // null name tells resolveFoundationTarget to use default placement (./foundation/)
190
- if (!hasDefault || response.name !== 'foundation') {
191
- name = response.name
197
+ if (!isNonInteractive(process.argv)) {
198
+ const foundations = await discoverFoundations(rootDir)
199
+ const hasDefault = foundations.length === 0 && !existsSync(join(rootDir, 'foundation'))
200
+ const response = await prompts({
201
+ type: 'text',
202
+ name: 'name',
203
+ message: 'Foundation name:',
204
+ initial: hasDefault ? 'foundation' : undefined,
205
+ validate: (value) => validatePackageName(value),
206
+ }, {
207
+ onCancel: () => {
208
+ log('\nCancelled.')
209
+ process.exit(0)
210
+ },
211
+ })
212
+ // Only set name if user chose something other than the default
213
+ // null name tells resolveFoundationTarget to use default placement (./foundation/)
214
+ if (!hasDefault || response.name !== 'foundation') {
215
+ name = response.name
216
+ }
192
217
  }
218
+ // Non-interactive without name: defaults to 'foundation' — resolveFoundationTarget handles it
193
219
  }
194
220
 
195
221
  const target = await resolveFoundationTarget(rootDir, name, opts)
@@ -200,10 +226,12 @@ async function addFoundation(rootDir, projectName, opts, pm = 'pnpm') {
200
226
  process.exit(1)
201
227
  }
202
228
 
203
- // Compute package name auto-suffix if it collides with an existing package
204
- let packageName = name || opts.project || 'foundation'
229
+ // Package name = name or 'foundation'
230
+ const packageName = name || 'foundation'
205
231
  if (existingNames.has(packageName)) {
206
- packageName = resolveUniqueName(packageName, '-foundation', existingNames)
232
+ error(`Package name '${packageName}' already exists in this workspace.`)
233
+ log(`Choose a different name: ${getCliPrefix()} add foundation <name>`)
234
+ process.exit(1)
207
235
  }
208
236
 
209
237
  // Scaffold
@@ -251,25 +279,28 @@ async function addSite(rootDir, projectName, opts, pm = 'pnpm') {
251
279
 
252
280
  // Interactive name prompt when name not provided and no --path
253
281
  if (!name && !opts.path) {
254
- const existingSites = await discoverSites(rootDir)
255
- const hasDefault = existingSites.length === 0 && !existsSync(join(rootDir, 'site'))
256
- const response = await prompts({
257
- type: 'text',
258
- name: 'name',
259
- message: 'Site name:',
260
- initial: hasDefault ? 'site' : undefined,
261
- validate: (value) => validatePackageName(value),
262
- }, {
263
- onCancel: () => {
264
- log('\nCancelled.')
265
- process.exit(0)
266
- },
267
- })
268
- // Only set name if user chose something other than the default —
269
- // null name tells resolveSiteTarget to use default placement (./site/)
270
- if (!hasDefault || response.name !== 'site') {
271
- name = response.name
282
+ if (!isNonInteractive(process.argv)) {
283
+ const existingSites = await discoverSites(rootDir)
284
+ const hasDefault = existingSites.length === 0 && !existsSync(join(rootDir, 'site'))
285
+ const response = await prompts({
286
+ type: 'text',
287
+ name: 'name',
288
+ message: 'Site name:',
289
+ initial: hasDefault ? 'site' : undefined,
290
+ validate: (value) => validatePackageName(value),
291
+ }, {
292
+ onCancel: () => {
293
+ log('\nCancelled.')
294
+ process.exit(0)
295
+ },
296
+ })
297
+ // Only set name if user chose something other than the default
298
+ // null name tells resolveSiteTarget to use default placement (./site/)
299
+ if (!hasDefault || response.name !== 'site') {
300
+ name = response.name
301
+ }
272
302
  }
303
+ // Non-interactive without name: defaults to 'site' — resolveSiteTarget handles it
273
304
  }
274
305
 
275
306
  const target = await resolveSiteTarget(rootDir, name, opts)
@@ -282,17 +313,13 @@ async function addSite(rootDir, projectName, opts, pm = 'pnpm') {
282
313
 
283
314
  // Resolve foundation
284
315
  const foundation = await resolveFoundation(rootDir, opts.foundation)
285
- let siteName
286
- if (opts.project) {
287
- // Co-located: convention is {project}-site (e.g., io-site)
288
- siteName = (!name || name === opts.project) ? `${opts.project}-site` : name
289
- } else {
290
- siteName = name || 'site'
291
- }
292
316
 
293
- // Auto-suffix package name if it collides with an existing package
317
+ // Package name = name or 'site'
318
+ const siteName = name || 'site'
294
319
  if (existingNames.has(siteName)) {
295
- siteName = resolveUniqueName(siteName, '-site', existingNames)
320
+ error(`Package name '${siteName}' already exists in this workspace.`)
321
+ log(`Choose a different name: ${getCliPrefix()} add site <name>`)
322
+ process.exit(1)
296
323
  }
297
324
 
298
325
  if (foundation) {
@@ -371,6 +398,12 @@ async function addExtension(rootDir, projectName, opts, pm = 'pnpm') {
371
398
 
372
399
  // Interactive name prompt when name not provided
373
400
  if (!name) {
401
+ if (isNonInteractive(process.argv)) {
402
+ error(`Missing extension name.\n`)
403
+ log(`Usage: ${getCliPrefix()} add extension <name>`)
404
+ process.exit(1)
405
+ }
406
+
374
407
  const response = await prompts({
375
408
  type: 'text',
376
409
  name: 'name',
@@ -446,8 +479,117 @@ async function addExtension(rootDir, projectName, opts, pm = 'pnpm') {
446
479
  log(`Next: ${colors.cyan}${installCmd(pm)}${colors.reset}`)
447
480
  }
448
481
 
482
+ /**
483
+ * Add a co-located foundation + site pair to the workspace
484
+ */
485
+ async function addProject(rootDir, projectName, opts, pm = 'pnpm') {
486
+ let name = opts.name
487
+ const existingNames = await getExistingPackageNames(rootDir)
488
+
489
+ // Validate name format
490
+ if (name) {
491
+ const valid = validatePackageName(name)
492
+ if (valid !== true) {
493
+ error(valid)
494
+ process.exit(1)
495
+ }
496
+ }
497
+
498
+ // Interactive name prompt when name not provided
499
+ if (!name) {
500
+ if (isNonInteractive(process.argv)) {
501
+ error(`Missing project name.\n`)
502
+ log(`Usage: ${getCliPrefix()} add project <name>`)
503
+ process.exit(1)
504
+ }
505
+
506
+ const response = await prompts({
507
+ type: 'text',
508
+ name: 'name',
509
+ message: 'Project name:',
510
+ validate: (value) => validatePackageName(value),
511
+ }, {
512
+ onCancel: () => {
513
+ log('\nCancelled.')
514
+ process.exit(0)
515
+ },
516
+ })
517
+ name = response.name
518
+ }
519
+
520
+ // Check directory doesn't already exist
521
+ const projectDir = join(rootDir, name)
522
+ if (existsSync(projectDir)) {
523
+ error(`Directory already exists: ${name}/`)
524
+ process.exit(1)
525
+ }
526
+
527
+ // Compute package names
528
+ const foundationPkgName = `${name}-foundation`
529
+ const sitePkgName = `${name}-site`
530
+
531
+ // Check package name collisions
532
+ for (const pkgName of [foundationPkgName, sitePkgName]) {
533
+ if (existingNames.has(pkgName)) {
534
+ error(`Package name '${pkgName}' already exists in this workspace.`)
535
+ process.exit(1)
536
+ }
537
+ }
538
+
539
+ const progressCb = (msg) => info(` ${msg}`)
540
+
541
+ // Scaffold foundation
542
+ info(`Creating foundation: ${foundationPkgName}...`)
543
+ await scaffoldFoundation(join(projectDir, 'foundation'), {
544
+ name: foundationPkgName,
545
+ projectName,
546
+ isExtension: false,
547
+ }, { onProgress: progressCb })
548
+
549
+ // Scaffold site
550
+ info(`Creating site: ${sitePkgName}...`)
551
+ await scaffoldSite(join(projectDir, 'site'), {
552
+ name: sitePkgName,
553
+ projectName,
554
+ foundationName: foundationPkgName,
555
+ foundationPath: 'file:../foundation',
556
+ foundationRef: foundationPkgName,
557
+ }, { onProgress: progressCb })
558
+
559
+ // Apply template content if --from specified
560
+ if (opts.from) {
561
+ await applyFromTemplate(opts.from, 'foundation', join(projectDir, 'foundation'), projectName)
562
+ await applyFromTemplate(opts.from, 'site', join(projectDir, 'site'), projectName)
563
+ }
564
+
565
+ // Update workspace globs for co-located layout
566
+ await addWorkspaceGlob(rootDir, '*/foundation')
567
+ await addWorkspaceGlob(rootDir, '*/site')
568
+
569
+ // Update root scripts
570
+ const sites = await discoverSites(rootDir)
571
+ if (!sites.find(s => s.path === `${name}/site`)) {
572
+ sites.push({ name: sitePkgName, path: `${name}/site` })
573
+ }
574
+ await updateRootScripts(rootDir, sites, pm)
575
+
576
+ success(`Created project '${name}' at ${name}/`)
577
+ log(` ${colors.dim}Foundation: ${name}/foundation/ (${foundationPkgName})${colors.reset}`)
578
+ log(` ${colors.dim}Site: ${name}/site/ (${sitePkgName})${colors.reset}`)
579
+ log('')
580
+ log(`Next: ${colors.cyan}${installCmd(pm)} && ${filterCmd(pm, sitePkgName, 'dev')}${colors.reset}`)
581
+ }
582
+
449
583
  /**
450
584
  * Resolve placement for a foundation
585
+ *
586
+ * Rules:
587
+ * - --path: use it directly
588
+ * - --project: {project}/foundation (co-located)
589
+ * - Existing co-located glob: follow pattern
590
+ * - Existing segregated glob: follow pattern
591
+ * - First foundation: dir name is the name (default: 'foundation')
592
+ * - Already have one: error in non-interactive, ask in interactive
451
593
  */
452
594
  async function resolveFoundationTarget(rootDir, name, opts) {
453
595
  if (opts.path) return opts.path
@@ -460,23 +602,43 @@ async function resolveFoundationTarget(rootDir, name, opts) {
460
602
  const { packages } = await readWorkspaceConfig(rootDir)
461
603
  const hasColocated = packages.some(p => p.includes('*/foundation'))
462
604
  const hasFoundationsGlob = packages.some(p => p.startsWith('foundations/'))
463
- const hasSingleFoundation = existsSync(join(rootDir, 'foundation'))
464
605
 
465
- if (hasColocated && opts.project) {
466
- return `${opts.project}/foundation`
606
+ // Respect existing co-located layout
607
+ if (hasColocated && name) {
608
+ return `${name}/foundation`
467
609
  }
468
610
 
469
- // No name and no foundations exist → ./foundation/
470
- if (!name && !hasSingleFoundation && !hasFoundationsGlob) {
471
- return 'foundation'
611
+ // Respect existing segregated layout
612
+ if (hasFoundationsGlob) {
613
+ return `foundations/${name || 'foundation'}`
472
614
  }
473
615
 
474
- // Named foundation or existing foundation → ./foundations/{name}/
475
- return `foundations/${name || 'foundation'}`
616
+ // dir name = name or 'foundation'
617
+ const dirName = name || 'foundation'
618
+
619
+ // Check if target already exists
620
+ if (!existsSync(join(rootDir, dirName))) {
621
+ return dirName
622
+ }
623
+
624
+ // Already have one at the target path — error with guidance
625
+ if (isNonInteractive(process.argv)) {
626
+ error(`Directory '${dirName}' already exists.`)
627
+ log(`\nTo add another foundation, specify a name:`)
628
+ log(` ${getCliPrefix()} add foundation <name>`)
629
+ log(`\nOr use --path for explicit placement:`)
630
+ log(` ${getCliPrefix()} add foundation --path <dir>`)
631
+ process.exit(1)
632
+ }
633
+
634
+ // Interactive: the existsSync check in addFoundation will catch it
635
+ return dirName
476
636
  }
477
637
 
478
638
  /**
479
639
  * Resolve placement for a site
640
+ *
641
+ * Same rules as resolveFoundationTarget, adapted for sites.
480
642
  */
481
643
  async function resolveSiteTarget(rootDir, name, opts) {
482
644
  if (opts.path) return opts.path
@@ -488,19 +650,37 @@ async function resolveSiteTarget(rootDir, name, opts) {
488
650
  const { packages } = await readWorkspaceConfig(rootDir)
489
651
  const hasColocated = packages.some(p => p.includes('*/site'))
490
652
  const hasSitesGlob = packages.some(p => p.startsWith('sites/'))
491
- const hasSingleSite = existsSync(join(rootDir, 'site'))
492
653
 
493
- if (hasColocated && opts.project) {
494
- return `${opts.project}/site`
654
+ // Respect existing co-located layout
655
+ if (hasColocated && name) {
656
+ return `${name}/site`
657
+ }
658
+
659
+ // Respect existing segregated layout
660
+ if (hasSitesGlob) {
661
+ return `sites/${name || 'site'}`
662
+ }
663
+
664
+ // dir name = name or 'site'
665
+ const dirName = name || 'site'
666
+
667
+ // Check if target already exists
668
+ if (!existsSync(join(rootDir, dirName))) {
669
+ return dirName
495
670
  }
496
671
 
497
- // No name and no sites exist ./site/
498
- if (!name && !hasSingleSite && !hasSitesGlob) {
499
- return 'site'
672
+ // Already have one at the target path — error with guidance
673
+ if (isNonInteractive(process.argv)) {
674
+ error(`Directory '${dirName}' already exists.`)
675
+ log(`\nTo add another site, specify a name:`)
676
+ log(` ${getCliPrefix()} add site <name>`)
677
+ log(`\nOr use --path for explicit placement:`)
678
+ log(` ${getCliPrefix()} add site --path <dir>`)
679
+ process.exit(1)
500
680
  }
501
681
 
502
- // Named site or existing site ./sites/{name}/
503
- return `sites/${name || 'site'}`
682
+ // Interactive: the existsSync check in addSite will catch it
683
+ return dirName
504
684
  }
505
685
 
506
686
  /**
@@ -529,7 +709,18 @@ async function resolveFoundation(rootDir, foundationFlag) {
529
709
  return foundations[0]
530
710
  }
531
711
 
532
- // Multiple foundations — prompt
712
+ // Multiple foundations — prompt (or fail in non-interactive mode)
713
+ if (isNonInteractive(process.argv)) {
714
+ error(`Multiple foundations found. Specify which to use:\n`)
715
+ log(formatOptions(foundations.map(f => ({
716
+ label: f.name,
717
+ description: f.path,
718
+ }))))
719
+ log('')
720
+ log(`Usage: ${getCliPrefix()} add site <name> --foundation <name>`)
721
+ process.exit(1)
722
+ }
723
+
533
724
  const response = await prompts({
534
725
  type: 'select',
535
726
  name: 'foundation',
@@ -691,9 +882,10 @@ function showAddHelp() {
691
882
  log(`
692
883
  ${colors.cyan}${colors.bright}Uniweb Add${colors.reset}
693
884
 
694
- Add foundations, sites, or extensions to your workspace.
885
+ Add projects, foundations, sites, or extensions to your workspace.
695
886
 
696
887
  ${colors.bright}Usage:${colors.reset}
888
+ uniweb add project [name] [options]
697
889
  uniweb add foundation [name] [options]
698
890
  uniweb add site [name] [options]
699
891
  uniweb add extension <name> [options]
@@ -713,11 +905,12 @@ ${colors.bright}Extension Options:${colors.reset}
713
905
  --site <name> Site to wire extension URL into
714
906
 
715
907
  ${colors.bright}Examples:${colors.reset}
716
- uniweb add foundation # Create ./foundation/
717
- uniweb add foundation marketing # Create ./foundations/marketing/
718
- uniweb add foundation marketing --from marketing # Scaffold + marketing sections
719
- uniweb add site blog --foundation marketing # Create ./sites/blog/ wired to marketing
720
- uniweb add site blog --from docs --foundation blog # Scaffold + docs pages
908
+ uniweb add project docs # Create docs/foundation/ + docs/site/
909
+ uniweb add project docs --from academic # Co-located pair + academic content
910
+ uniweb add foundation # Create ./foundation/ at root
911
+ uniweb add foundation ui # Create ./ui/ at root
912
+ uniweb add site # Create ./site/ at root
913
+ uniweb add site blog --foundation marketing # Create ./blog/ wired to marketing
721
914
  uniweb add extension effects --site site # Create ./extensions/effects/
722
915
  uniweb add foundation --project docs # Create ./docs/foundation/ (co-located)
723
916
  uniweb add site --project docs # Create ./docs/site/ (co-located)
@@ -29,6 +29,7 @@ import {
29
29
  findFoundations,
30
30
  promptSelect,
31
31
  } from '../utils/workspace.js'
32
+ import { isNonInteractive, getCliPrefix, formatOptions } from '../utils/interactive.js'
32
33
 
33
34
  // Colors for terminal output
34
35
  const colors = {
@@ -394,6 +395,12 @@ async function generateComponentDocs(args) {
394
395
  if (foundations.length === 1) {
395
396
  targetFoundation = foundations[0]
396
397
  info(`Found foundation: ${targetFoundation}`)
398
+ } else if (isNonInteractive(process.argv)) {
399
+ error(`Multiple foundations found. Specify which to target:\n`)
400
+ log(formatOptions(foundations.map(f => ({ label: f, description: '' }))))
401
+ log('')
402
+ log(`Usage: ${getCliPrefix()} docs --target <path>`)
403
+ process.exit(1)
397
404
  } else {
398
405
  log(`${colors.dim}Multiple foundations found in workspace.${colors.reset}\n`)
399
406
  targetFoundation = await promptSelect('Select foundation:', foundations)
package/src/index.js CHANGED
@@ -29,6 +29,7 @@ import {
29
29
  import { validateTemplate } from './templates/validator.js'
30
30
  import { scaffoldWorkspace, scaffoldFoundation, scaffoldSite, applyContent, applyStarter, mergeTemplateDependencies } from './utils/scaffold.js'
31
31
  import { detectPackageManager, filterCmd, installCmd, runCmd } from './utils/pm.js'
32
+ import { isNonInteractive, getCliPrefix, stripNonInteractiveFlag, formatOptions } from './utils/interactive.js'
32
33
 
33
34
  // Colors for terminal output
34
35
  const colors = {
@@ -43,8 +44,8 @@ const colors = {
43
44
 
44
45
  // Template choices for interactive prompt
45
46
  const TEMPLATE_CHOICES = [
47
+ { title: 'None', value: 'none', description: 'Foundation + site with no content' },
46
48
  { title: 'Starter', value: 'starter', description: 'Foundation + site + sample content' },
47
- { title: 'Blank', value: 'blank', description: 'Empty workspace — grow with uniweb add' },
48
49
  { title: 'Marketing', value: 'marketing', description: 'Landing page, features, pricing, testimonials' },
49
50
  { title: 'Docs', value: 'docs', description: 'Documentation with sidebar and search' },
50
51
  { title: 'Academic', value: 'academic', description: 'Research site with publications and team' },
@@ -52,6 +53,7 @@ const TEMPLATE_CHOICES = [
52
53
  { title: 'International', value: 'international', description: 'Multilingual site with i18n and blog' },
53
54
  { title: 'Store', value: 'store', description: 'E-commerce with product grid' },
54
55
  { title: 'Extensions', value: 'extensions', description: 'Multi-foundation with visual effects extension' },
56
+ { title: 'Blank workspace', value: 'blank', description: 'Empty workspace — grow with uniweb add' },
55
57
  ]
56
58
 
57
59
  function log(message) {
@@ -74,7 +76,7 @@ function title(message) {
74
76
  * Create a project using the new package template flow (default)
75
77
  */
76
78
  async function createFromPackageTemplates(projectDir, projectName, options = {}) {
77
- const { onProgress, onWarning, pm = 'pnpm' } = options
79
+ const { onProgress, onWarning, pm = 'pnpm', includeStarter = true } = options
78
80
 
79
81
  onProgress?.('Setting up workspace...')
80
82
 
@@ -106,9 +108,11 @@ async function createFromPackageTemplates(projectDir, projectName, options = {})
106
108
  foundationPath: 'file:../foundation',
107
109
  }, { onProgress, onWarning })
108
110
 
109
- // 4. Apply starter content
110
- onProgress?.('Adding starter content...')
111
- await applyStarter(projectDir, { projectName }, { onProgress, onWarning })
111
+ // 4. Apply starter content (unless creating a "none" project)
112
+ if (includeStarter) {
113
+ onProgress?.('Adding starter content...')
114
+ await applyStarter(projectDir, { projectName }, { onProgress, onWarning })
115
+ }
112
116
 
113
117
  success(`Created project: ${projectName}`)
114
118
  }
@@ -288,7 +292,9 @@ function computeFoundationFilePath(sitePath, foundationPath) {
288
292
  }
289
293
 
290
294
  async function main() {
291
- const args = process.argv.slice(2)
295
+ const rawArgs = process.argv.slice(2)
296
+ const nonInteractive = isNonInteractive(rawArgs)
297
+ const args = stripNonInteractiveFlag(rawArgs)
292
298
  const command = args[0]
293
299
  const pm = detectPackageManager()
294
300
 
@@ -361,6 +367,15 @@ async function main() {
361
367
  displayName = args[nameIndex + 1]
362
368
  }
363
369
 
370
+ // Check for --blank flag
371
+ let isBlank = args.includes('--blank')
372
+
373
+ // Handle --template blank as alias for --blank
374
+ if (templateType === 'blank') {
375
+ isBlank = true
376
+ templateType = null
377
+ }
378
+
364
379
  // Check for --no-git flag
365
380
  const noGit = args.includes('--no-git')
366
381
 
@@ -369,6 +384,20 @@ async function main() {
369
384
  projectName = null
370
385
  }
371
386
 
387
+ const prefix = getCliPrefix()
388
+
389
+ // Non-interactive: fail with actionable message instead of prompting
390
+ if (nonInteractive && !projectName) {
391
+ error(`Missing project name.\n`)
392
+ log(`Usage: ${prefix} create <project-name> [--template <name>] [--blank]`)
393
+ process.exit(1)
394
+ }
395
+
396
+ // Non-interactive: default to starter when no template specified
397
+ if (nonInteractive && !templateType && !isBlank) {
398
+ templateType = 'starter'
399
+ }
400
+
372
401
  // Interactive prompts
373
402
  const response = await prompts([
374
403
  {
@@ -398,13 +427,14 @@ async function main() {
398
427
  process.exit(1)
399
428
  }
400
429
 
401
- // Prompt for template if not specified via --template
402
- if (!templateType) {
430
+ // Prompt for template if not specified via --template or --blank
431
+ if (!templateType && !isBlank) {
403
432
  const templateResponse = await prompts({
404
433
  type: 'select',
405
434
  name: 'template',
406
435
  message: 'Template:',
407
436
  choices: TEMPLATE_CHOICES,
437
+ initial: 1,
408
438
  }, {
409
439
  onCancel: () => {
410
440
  log('\nScaffolding cancelled.')
@@ -412,6 +442,11 @@ async function main() {
412
442
  },
413
443
  })
414
444
  templateType = templateResponse.template
445
+ // Handle "blank" selection from interactive prompt
446
+ if (templateType === 'blank') {
447
+ isBlank = true
448
+ templateType = null
449
+ }
415
450
  }
416
451
 
417
452
  const effectiveName = displayName || projectName
@@ -428,13 +463,22 @@ async function main() {
428
463
  const progressCb = (msg) => log(` ${colors.dim}${msg}${colors.reset}`)
429
464
  const warningCb = (msg) => log(` ${colors.yellow}Warning: ${msg}${colors.reset}`)
430
465
 
431
- if (templateType === 'blank') {
432
- // Blank workspace
466
+ if (isBlank) {
467
+ // Blank workspace (--blank or --template blank)
433
468
  log('\nCreating blank workspace...')
434
469
  await createBlankWorkspace(projectDir, effectiveName, {
435
470
  onProgress: progressCb,
436
471
  onWarning: warningCb,
437
472
  })
473
+ } else if (templateType === 'none') {
474
+ // Foundation + site with no content
475
+ log('\nCreating project...')
476
+ await createFromPackageTemplates(projectDir, effectiveName, {
477
+ onProgress: progressCb,
478
+ onWarning: warningCb,
479
+ pm,
480
+ includeStarter: false,
481
+ })
438
482
  } else if (templateType === 'starter') {
439
483
  // Starter: foundation + site + sample content
440
484
  log('\nCreating project...')
@@ -498,11 +542,10 @@ async function main() {
498
542
  // Success message
499
543
  title('Project created successfully!')
500
544
 
501
- if (templateType === 'blank') {
545
+ if (isBlank) {
502
546
  log(`Next steps:\n`)
503
547
  log(` ${colors.cyan}cd ${projectName}${colors.reset}`)
504
- log(` ${colors.cyan}npx uniweb add foundation${colors.reset}`)
505
- log(` ${colors.cyan}npx uniweb add site${colors.reset}`)
548
+ log(` ${colors.cyan}${prefix} add project${colors.reset}`)
506
549
  log(` ${colors.cyan}${installCmd(pm)}${colors.reset}`)
507
550
  log(` ${colors.cyan}${runCmd(pm, 'dev')}${colors.reset}`)
508
551
  } else {
@@ -530,15 +573,21 @@ ${colors.bright}Commands:${colors.reset}
530
573
  i18n <cmd> Internationalization (extract, sync, status)
531
574
 
532
575
  ${colors.bright}Create Options:${colors.reset}
533
- --template <type> Project template (prompts if not specified)
576
+ --template <type> Project template (default: starter)
577
+ --blank Create an empty workspace (grow with uniweb add)
534
578
  --name <name> Project display name
535
579
  --no-git Skip git repository initialization
536
580
 
537
581
  ${colors.bright}Add Subcommands:${colors.reset}
582
+ add project [name] Add a co-located foundation + site pair
538
583
  add foundation [name] Add a foundation (--from, --path, --project)
539
584
  add site [name] Add a site (--from, --foundation, --path, --project)
540
585
  add extension <name> Add an extension (--from, --site, --path)
541
586
 
587
+ ${colors.bright}Global Options:${colors.reset}
588
+ --non-interactive Fail with usage info instead of prompting
589
+ Auto-detected when CI=true or no TTY (pipes, agents)
590
+
542
591
  ${colors.bright}Build Options:${colors.reset}
543
592
  --target <type> Build target (foundation, site) - auto-detected if not specified
544
593
  --prerender Force pre-rendering (overrides site.yml)
@@ -567,7 +616,7 @@ ${colors.bright}i18n Commands:${colors.reset}
567
616
 
568
617
  ${colors.bright}Template Types:${colors.reset}
569
618
  starter Foundation + site + sample content (default)
570
- blank Empty workspace (grow with 'add')
619
+ none Foundation + site with no content
571
620
  marketing Official marketing template
572
621
  ./path/to/template Local directory
573
622
  @scope/template-name npm package
@@ -575,17 +624,17 @@ ${colors.bright}Template Types:${colors.reset}
575
624
  https://github.com/user/repo GitHub URL
576
625
 
577
626
  ${colors.bright}Examples:${colors.reset}
578
- npx uniweb create my-project # Interactive (prompts for template)
579
- npx uniweb create my-project --template starter # Foundation + site + starter content
580
- npx uniweb create my-project --template blank # Blank workspace
627
+ npx uniweb create my-project # Foundation + site + starter content
628
+ npx uniweb create my-project --template none # Foundation + site, no content
629
+ npx uniweb create my-project --blank # Empty workspace
581
630
  npx uniweb create my-project --template marketing # Official template
582
631
  npx uniweb create my-project --template ./my-template # Local template
583
632
 
584
633
  cd my-project
585
- npx uniweb add foundation marketing # Add foundations/marketing/
586
- npx uniweb add foundation marketing --from marketing # Scaffold + marketing sections
587
- npx uniweb add site blog --foundation marketing # Add sites/blog/ wired to marketing
588
- npx uniweb add site blog --from docs --foundation blog # Scaffold + docs pages
634
+ npx uniweb add project docs # Add docs/foundation/ + docs/site/
635
+ npx uniweb add project docs --from academic # Co-located pair + academic content
636
+ npx uniweb add foundation # Add foundation at root
637
+ npx uniweb add site blog --foundation marketing # Add site wired to marketing
589
638
  npx uniweb add extension effects --site site # Add extensions/effects/
590
639
 
591
640
  npx uniweb build
@@ -3,7 +3,7 @@
3
3
  */
4
4
 
5
5
  // Built-in templates (programmatic, not file-based)
6
- export const BUILTIN_TEMPLATES = ['blank', 'starter']
6
+ export const BUILTIN_TEMPLATES = ['blank', 'starter', 'none']
7
7
 
8
8
  // Official templates from @uniweb/templates package (downloaded from GitHub releases)
9
9
  export const OFFICIAL_TEMPLATES = ['marketing', 'academic', 'docs', 'international', 'dynamic', 'store', 'extensions']
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Non-Interactive Mode Detection
3
+ *
4
+ * Detects when the CLI is running without a TTY (AI agents, CI, piped input)
5
+ * and provides helpers for printing actionable error messages.
6
+ */
7
+
8
+ /**
9
+ * Detect if the CLI is running in non-interactive mode.
10
+ * True when --non-interactive flag, CI env var, or no TTY.
11
+ * @param {string[]} args - Command line arguments
12
+ * @returns {boolean}
13
+ */
14
+ export function isNonInteractive(args) {
15
+ if (args.includes('--non-interactive')) return true
16
+ if (process.env.CI) return true
17
+ if (!process.stdin.isTTY) return true
18
+ return false
19
+ }
20
+
21
+ /**
22
+ * Get the CLI invocation prefix to use in suggested commands.
23
+ * Mirrors however the user actually ran the CLI.
24
+ * @returns {string}
25
+ */
26
+ export function getCliPrefix() {
27
+ const ua = process.env.npm_config_user_agent || ''
28
+ if (ua.startsWith('pnpm/')) return 'pnpm uniweb'
29
+ if (ua.startsWith('npm/')) return 'npx uniweb'
30
+ return 'uniweb'
31
+ }
32
+
33
+ /**
34
+ * Strip --non-interactive from an args array so it doesn't interfere
35
+ * with positional argument parsing.
36
+ * @param {string[]} args
37
+ * @returns {string[]}
38
+ */
39
+ export function stripNonInteractiveFlag(args) {
40
+ return args.filter(a => a !== '--non-interactive')
41
+ }
42
+
43
+ /**
44
+ * Format a list of options with aligned descriptions for terminal output.
45
+ * @param {{ label: string, description: string }[]} options
46
+ * @returns {string}
47
+ */
48
+ export function formatOptions(options) {
49
+ const maxLen = Math.max(...options.map(o => o.label.length))
50
+ return options
51
+ .map(o => ` ${o.label.padEnd(maxLen + 3)}${o.description}`)
52
+ .join('\n')
53
+ }
@@ -1 +1,99 @@
1
- primary: '#3b82f6'
1
+ # Theme Configuration
2
+ # Controls colors, typography, and visual style for the site.
3
+ # Only set what you want to change — everything has sensible defaults.
4
+
5
+ # ─── Colors ────────────────────────────────────────────────────────────────────
6
+ # Palette colors generate shades (50–950) used by semantic tokens.
7
+ # Values: any CSS color (hex, rgb, hsl, oklch).
8
+
9
+ colors:
10
+ primary: '#3b82f6' # Brand color — buttons, links, focus rings
11
+ # secondary: '#64748b' # Secondary actions
12
+ # accent: '#8b5cf6' # Highlights, decorative elements
13
+ # neutral: stone # Gray family — stone, zinc, gray, slate, neutral
14
+
15
+ # ─── Fonts ─────────────────────────────────────────────────────────────────────
16
+ # Font families for text, headings, and code blocks.
17
+ # Default: system-ui stack. Uncomment to use custom fonts.
18
+
19
+ # fonts:
20
+ # heading: '"Inter", system-ui, sans-serif'
21
+ # body: '"Inter", system-ui, sans-serif'
22
+ # mono: '"JetBrains Mono", ui-monospace, monospace'
23
+ # import:
24
+ # - url: https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap
25
+ # - url: https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap
26
+
27
+ # ─── Page Background ──────────────────────────────────────────────────────────
28
+ # Background applied to the page body. Any CSS background value.
29
+
30
+ # background: '#fafaf9'
31
+ # background: 'linear-gradient(to bottom, #fafaf9, white)'
32
+
33
+ # ─── Appearance ────────────────────────────────────────────────────────────────
34
+ # Color scheme support. Simple value: light, dark, or system.
35
+ # Or use the expanded form for more control.
36
+
37
+ # appearance: light
38
+ # appearance:
39
+ # default: light
40
+ # schemes: [light, dark]
41
+ # allowToggle: true
42
+ # respectSystemPreference: true
43
+
44
+ # ─── Context Overrides ─────────────────────────────────────────────────────────
45
+ # Sections declare a context (light, medium, dark) in frontmatter.
46
+ # Each context maps semantic tokens to palette values.
47
+ # Override individual tokens here — unlisted tokens keep their defaults.
48
+ #
49
+ # Available tokens:
50
+ # Surfaces: section, card, muted
51
+ # Text: body, heading, subtle
52
+ # Border: border, ring
53
+ # Links: link, link-hover
54
+ # Actions: primary, primary-foreground, primary-hover
55
+ # secondary, secondary-foreground, secondary-hover
56
+ # Status: success, warning, error, info (+ -subtle variants)
57
+
58
+ # contexts:
59
+ # light:
60
+ # section: white
61
+ # medium:
62
+ # section: 'var(--neutral-100)'
63
+ # dark:
64
+ # heading: white
65
+
66
+ # ─── Inline Text Styles ───────────────────────────────────────────────────────
67
+ # Named styles for inline text in markdown: [text]{emphasis}
68
+ # Each name maps to CSS properties.
69
+ # Defaults: emphasis (colored + bold), muted (subtle).
70
+
71
+ # inline:
72
+ # emphasis:
73
+ # color: 'var(--link)'
74
+ # font-weight: '600'
75
+ # muted:
76
+ # color: 'var(--subtle)'
77
+
78
+ # ─── Code Blocks ───────────────────────────────────────────────────────────────
79
+ # Syntax highlighting colors for code blocks (uses Shiki).
80
+
81
+ # code:
82
+ # background: '#1e1e2e'
83
+ # foreground: '#cdd6f4'
84
+ # keyword: '#cba6f7'
85
+ # string: '#a6e3a1'
86
+ # number: '#fab387'
87
+ # comment: '#6c7086'
88
+ # function: '#89b4fa'
89
+ # variable: '#f5e0dc'
90
+ # type: '#f9e2af'
91
+ # property: '#94e2d5'
92
+ # tag: '#89b4fa'
93
+
94
+ # ─── Foundation Variables ──────────────────────────────────────────────────────
95
+ # Override variables declared by the foundation (e.g., header-height, max-width).
96
+ # Available variables depend on the foundation — check its foundation.js.
97
+
98
+ # vars:
99
+ # header-height: 5rem