uniweb 0.8.4 → 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.4",
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/build": "0.8.4",
45
44
  "@uniweb/core": "0.5.4",
45
+ "@uniweb/kit": "0.7.3",
46
46
  "@uniweb/runtime": "0.6.4",
47
- "@uniweb/kit": "0.7.3"
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,
@@ -118,12 +119,13 @@ export async function add(rawArgs) {
118
119
  if (nonInteractive) {
119
120
  error(`Missing subcommand.\n`)
120
121
  log(formatOptions([
122
+ { label: 'project', description: 'Co-located foundation + site pair' },
121
123
  { label: 'foundation', description: 'Component library' },
122
124
  { label: 'site', description: 'Content site' },
123
125
  { label: 'extension', description: 'Additional component package' },
124
126
  ]))
125
127
  log('')
126
- log(`Usage: ${prefix} add <foundation|site|extension> [name]`)
128
+ log(`Usage: ${prefix} add <project|foundation|site|extension> [name]`)
127
129
  process.exit(1)
128
130
  }
129
131
 
@@ -132,6 +134,7 @@ export async function add(rawArgs) {
132
134
  name: 'subcommand',
133
135
  message: 'What would you like to add?',
134
136
  choices: [
137
+ { title: 'Project', value: 'project', description: 'Co-located foundation + site pair' },
135
138
  { title: 'Foundation', value: 'foundation', description: 'Component library' },
136
139
  { title: 'Site', value: 'site', description: 'Content site' },
137
140
  { title: 'Extension', value: 'extension', description: 'Additional component package' },
@@ -154,6 +157,9 @@ export async function add(rawArgs) {
154
157
  const projectName = rootPkg.name || 'my-project'
155
158
 
156
159
  switch (parsed.subcommand) {
160
+ case 'project':
161
+ await addProject(rootDir, projectName, parsed, pm)
162
+ break
157
163
  case 'foundation':
158
164
  await addFoundation(rootDir, projectName, parsed, pm)
159
165
  break
@@ -165,7 +171,7 @@ export async function add(rawArgs) {
165
171
  break
166
172
  default:
167
173
  error(`Unknown subcommand: ${parsed.subcommand}`)
168
- log(`Valid subcommands: foundation, site, extension`)
174
+ log(`Valid subcommands: project, foundation, site, extension`)
169
175
  process.exit(1)
170
176
  }
171
177
  }
@@ -188,31 +194,28 @@ async function addFoundation(rootDir, projectName, opts, pm = 'pnpm') {
188
194
 
189
195
  // Interactive name prompt when name not provided and no --path
190
196
  if (!name && !opts.path) {
191
- if (isNonInteractive(process.argv)) {
192
- error(`Missing foundation name.\n`)
193
- log(`Usage: ${getCliPrefix()} add foundation <name>`)
194
- process.exit(1)
195
- }
196
-
197
- const foundations = await discoverFoundations(rootDir)
198
- const hasDefault = foundations.length === 0 && !existsSync(join(rootDir, 'foundation'))
199
- const response = await prompts({
200
- type: 'text',
201
- name: 'name',
202
- message: 'Foundation name:',
203
- initial: hasDefault ? 'foundation' : undefined,
204
- validate: (value) => validatePackageName(value),
205
- }, {
206
- onCancel: () => {
207
- log('\nCancelled.')
208
- process.exit(0)
209
- },
210
- })
211
- // Only set name if user chose something other than the default —
212
- // null name tells resolveFoundationTarget to use default placement (./foundation/)
213
- if (!hasDefault || response.name !== 'foundation') {
214
- 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
+ }
215
217
  }
218
+ // Non-interactive without name: defaults to 'foundation' — resolveFoundationTarget handles it
216
219
  }
217
220
 
218
221
  const target = await resolveFoundationTarget(rootDir, name, opts)
@@ -223,10 +226,12 @@ async function addFoundation(rootDir, projectName, opts, pm = 'pnpm') {
223
226
  process.exit(1)
224
227
  }
225
228
 
226
- // Compute package name auto-suffix if it collides with an existing package
227
- let packageName = name || opts.project || 'foundation'
229
+ // Package name = name or 'foundation'
230
+ const packageName = name || 'foundation'
228
231
  if (existingNames.has(packageName)) {
229
- 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)
230
235
  }
231
236
 
232
237
  // Scaffold
@@ -274,31 +279,28 @@ async function addSite(rootDir, projectName, opts, pm = 'pnpm') {
274
279
 
275
280
  // Interactive name prompt when name not provided and no --path
276
281
  if (!name && !opts.path) {
277
- if (isNonInteractive(process.argv)) {
278
- error(`Missing site name.\n`)
279
- log(`Usage: ${getCliPrefix()} add site <name>`)
280
- process.exit(1)
281
- }
282
-
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
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
+ }
301
302
  }
303
+ // Non-interactive without name: defaults to 'site' — resolveSiteTarget handles it
302
304
  }
303
305
 
304
306
  const target = await resolveSiteTarget(rootDir, name, opts)
@@ -311,17 +313,13 @@ async function addSite(rootDir, projectName, opts, pm = 'pnpm') {
311
313
 
312
314
  // Resolve foundation
313
315
  const foundation = await resolveFoundation(rootDir, opts.foundation)
314
- let siteName
315
- if (opts.project) {
316
- // Co-located: convention is {project}-site (e.g., io-site)
317
- siteName = (!name || name === opts.project) ? `${opts.project}-site` : name
318
- } else {
319
- siteName = name || 'site'
320
- }
321
316
 
322
- // Auto-suffix package name if it collides with an existing package
317
+ // Package name = name or 'site'
318
+ const siteName = name || 'site'
323
319
  if (existingNames.has(siteName)) {
324
- 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)
325
323
  }
326
324
 
327
325
  if (foundation) {
@@ -481,8 +479,117 @@ async function addExtension(rootDir, projectName, opts, pm = 'pnpm') {
481
479
  log(`Next: ${colors.cyan}${installCmd(pm)}${colors.reset}`)
482
480
  }
483
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
+
484
583
  /**
485
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
486
593
  */
487
594
  async function resolveFoundationTarget(rootDir, name, opts) {
488
595
  if (opts.path) return opts.path
@@ -495,23 +602,43 @@ async function resolveFoundationTarget(rootDir, name, opts) {
495
602
  const { packages } = await readWorkspaceConfig(rootDir)
496
603
  const hasColocated = packages.some(p => p.includes('*/foundation'))
497
604
  const hasFoundationsGlob = packages.some(p => p.startsWith('foundations/'))
498
- const hasSingleFoundation = existsSync(join(rootDir, 'foundation'))
499
605
 
500
- if (hasColocated && opts.project) {
501
- return `${opts.project}/foundation`
606
+ // Respect existing co-located layout
607
+ if (hasColocated && name) {
608
+ return `${name}/foundation`
502
609
  }
503
610
 
504
- // No name and no foundations exist → ./foundation/
505
- if (!name && !hasSingleFoundation && !hasFoundationsGlob) {
506
- return 'foundation'
611
+ // Respect existing segregated layout
612
+ if (hasFoundationsGlob) {
613
+ return `foundations/${name || 'foundation'}`
507
614
  }
508
615
 
509
- // Named foundation or existing foundation → ./foundations/{name}/
510
- 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
511
636
  }
512
637
 
513
638
  /**
514
639
  * Resolve placement for a site
640
+ *
641
+ * Same rules as resolveFoundationTarget, adapted for sites.
515
642
  */
516
643
  async function resolveSiteTarget(rootDir, name, opts) {
517
644
  if (opts.path) return opts.path
@@ -523,19 +650,37 @@ async function resolveSiteTarget(rootDir, name, opts) {
523
650
  const { packages } = await readWorkspaceConfig(rootDir)
524
651
  const hasColocated = packages.some(p => p.includes('*/site'))
525
652
  const hasSitesGlob = packages.some(p => p.startsWith('sites/'))
526
- const hasSingleSite = existsSync(join(rootDir, 'site'))
527
653
 
528
- if (hasColocated && opts.project) {
529
- return `${opts.project}/site`
654
+ // Respect existing co-located layout
655
+ if (hasColocated && name) {
656
+ return `${name}/site`
530
657
  }
531
658
 
532
- // No name and no sites exist → ./site/
533
- if (!name && !hasSingleSite && !hasSitesGlob) {
534
- return 'site'
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
670
+ }
671
+
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)
535
680
  }
536
681
 
537
- // Named site or existing site ./sites/{name}/
538
- return `sites/${name || 'site'}`
682
+ // Interactive: the existsSync check in addSite will catch it
683
+ return dirName
539
684
  }
540
685
 
541
686
  /**
@@ -737,9 +882,10 @@ function showAddHelp() {
737
882
  log(`
738
883
  ${colors.cyan}${colors.bright}Uniweb Add${colors.reset}
739
884
 
740
- Add foundations, sites, or extensions to your workspace.
885
+ Add projects, foundations, sites, or extensions to your workspace.
741
886
 
742
887
  ${colors.bright}Usage:${colors.reset}
888
+ uniweb add project [name] [options]
743
889
  uniweb add foundation [name] [options]
744
890
  uniweb add site [name] [options]
745
891
  uniweb add extension <name> [options]
@@ -759,11 +905,12 @@ ${colors.bright}Extension Options:${colors.reset}
759
905
  --site <name> Site to wire extension URL into
760
906
 
761
907
  ${colors.bright}Examples:${colors.reset}
762
- uniweb add foundation # Create ./foundation/
763
- uniweb add foundation marketing # Create ./foundations/marketing/
764
- uniweb add foundation marketing --from marketing # Scaffold + marketing sections
765
- uniweb add site blog --foundation marketing # Create ./sites/blog/ wired to marketing
766
- 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
767
914
  uniweb add extension effects --site site # Create ./extensions/effects/
768
915
  uniweb add foundation --project docs # Create ./docs/foundation/ (co-located)
769
916
  uniweb add site --project docs # Create ./docs/site/ (co-located)
package/src/index.js CHANGED
@@ -44,8 +44,8 @@ const colors = {
44
44
 
45
45
  // Template choices for interactive prompt
46
46
  const TEMPLATE_CHOICES = [
47
+ { title: 'None', value: 'none', description: 'Foundation + site with no content' },
47
48
  { title: 'Starter', value: 'starter', description: 'Foundation + site + sample content' },
48
- { title: 'Blank', value: 'blank', description: 'Empty workspace — grow with uniweb add' },
49
49
  { title: 'Marketing', value: 'marketing', description: 'Landing page, features, pricing, testimonials' },
50
50
  { title: 'Docs', value: 'docs', description: 'Documentation with sidebar and search' },
51
51
  { title: 'Academic', value: 'academic', description: 'Research site with publications and team' },
@@ -53,6 +53,7 @@ const TEMPLATE_CHOICES = [
53
53
  { title: 'International', value: 'international', description: 'Multilingual site with i18n and blog' },
54
54
  { title: 'Store', value: 'store', description: 'E-commerce with product grid' },
55
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' },
56
57
  ]
57
58
 
58
59
  function log(message) {
@@ -75,7 +76,7 @@ function title(message) {
75
76
  * Create a project using the new package template flow (default)
76
77
  */
77
78
  async function createFromPackageTemplates(projectDir, projectName, options = {}) {
78
- const { onProgress, onWarning, pm = 'pnpm' } = options
79
+ const { onProgress, onWarning, pm = 'pnpm', includeStarter = true } = options
79
80
 
80
81
  onProgress?.('Setting up workspace...')
81
82
 
@@ -107,9 +108,11 @@ async function createFromPackageTemplates(projectDir, projectName, options = {})
107
108
  foundationPath: 'file:../foundation',
108
109
  }, { onProgress, onWarning })
109
110
 
110
- // 4. Apply starter content
111
- onProgress?.('Adding starter content...')
112
- 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
+ }
113
116
 
114
117
  success(`Created project: ${projectName}`)
115
118
  }
@@ -364,6 +367,15 @@ async function main() {
364
367
  displayName = args[nameIndex + 1]
365
368
  }
366
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
+
367
379
  // Check for --no-git flag
368
380
  const noGit = args.includes('--no-git')
369
381
 
@@ -377,19 +389,13 @@ async function main() {
377
389
  // Non-interactive: fail with actionable message instead of prompting
378
390
  if (nonInteractive && !projectName) {
379
391
  error(`Missing project name.\n`)
380
- log(`Usage: ${prefix} create <project-name> [--template <name>]`)
392
+ log(`Usage: ${prefix} create <project-name> [--template <name>] [--blank]`)
381
393
  process.exit(1)
382
394
  }
383
395
 
384
- if (nonInteractive && !templateType) {
385
- error(`Missing --template flag. Available templates:\n`)
386
- log(formatOptions(TEMPLATE_CHOICES.map(c => ({
387
- label: c.value,
388
- description: c.description,
389
- }))))
390
- log('')
391
- log(`Usage: ${prefix} create ${projectName || '<project-name>'} --template <name>`)
392
- process.exit(1)
396
+ // Non-interactive: default to starter when no template specified
397
+ if (nonInteractive && !templateType && !isBlank) {
398
+ templateType = 'starter'
393
399
  }
394
400
 
395
401
  // Interactive prompts
@@ -421,13 +427,14 @@ async function main() {
421
427
  process.exit(1)
422
428
  }
423
429
 
424
- // Prompt for template if not specified via --template
425
- if (!templateType) {
430
+ // Prompt for template if not specified via --template or --blank
431
+ if (!templateType && !isBlank) {
426
432
  const templateResponse = await prompts({
427
433
  type: 'select',
428
434
  name: 'template',
429
435
  message: 'Template:',
430
436
  choices: TEMPLATE_CHOICES,
437
+ initial: 1,
431
438
  }, {
432
439
  onCancel: () => {
433
440
  log('\nScaffolding cancelled.')
@@ -435,6 +442,11 @@ async function main() {
435
442
  },
436
443
  })
437
444
  templateType = templateResponse.template
445
+ // Handle "blank" selection from interactive prompt
446
+ if (templateType === 'blank') {
447
+ isBlank = true
448
+ templateType = null
449
+ }
438
450
  }
439
451
 
440
452
  const effectiveName = displayName || projectName
@@ -451,13 +463,22 @@ async function main() {
451
463
  const progressCb = (msg) => log(` ${colors.dim}${msg}${colors.reset}`)
452
464
  const warningCb = (msg) => log(` ${colors.yellow}Warning: ${msg}${colors.reset}`)
453
465
 
454
- if (templateType === 'blank') {
455
- // Blank workspace
466
+ if (isBlank) {
467
+ // Blank workspace (--blank or --template blank)
456
468
  log('\nCreating blank workspace...')
457
469
  await createBlankWorkspace(projectDir, effectiveName, {
458
470
  onProgress: progressCb,
459
471
  onWarning: warningCb,
460
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
+ })
461
482
  } else if (templateType === 'starter') {
462
483
  // Starter: foundation + site + sample content
463
484
  log('\nCreating project...')
@@ -521,11 +542,10 @@ async function main() {
521
542
  // Success message
522
543
  title('Project created successfully!')
523
544
 
524
- if (templateType === 'blank') {
545
+ if (isBlank) {
525
546
  log(`Next steps:\n`)
526
547
  log(` ${colors.cyan}cd ${projectName}${colors.reset}`)
527
- log(` ${colors.cyan}npx uniweb add foundation${colors.reset}`)
528
- log(` ${colors.cyan}npx uniweb add site${colors.reset}`)
548
+ log(` ${colors.cyan}${prefix} add project${colors.reset}`)
529
549
  log(` ${colors.cyan}${installCmd(pm)}${colors.reset}`)
530
550
  log(` ${colors.cyan}${runCmd(pm, 'dev')}${colors.reset}`)
531
551
  } else {
@@ -553,11 +573,13 @@ ${colors.bright}Commands:${colors.reset}
553
573
  i18n <cmd> Internationalization (extract, sync, status)
554
574
 
555
575
  ${colors.bright}Create Options:${colors.reset}
556
- --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)
557
578
  --name <name> Project display name
558
579
  --no-git Skip git repository initialization
559
580
 
560
581
  ${colors.bright}Add Subcommands:${colors.reset}
582
+ add project [name] Add a co-located foundation + site pair
561
583
  add foundation [name] Add a foundation (--from, --path, --project)
562
584
  add site [name] Add a site (--from, --foundation, --path, --project)
563
585
  add extension <name> Add an extension (--from, --site, --path)
@@ -594,7 +616,7 @@ ${colors.bright}i18n Commands:${colors.reset}
594
616
 
595
617
  ${colors.bright}Template Types:${colors.reset}
596
618
  starter Foundation + site + sample content (default)
597
- blank Empty workspace (grow with 'add')
619
+ none Foundation + site with no content
598
620
  marketing Official marketing template
599
621
  ./path/to/template Local directory
600
622
  @scope/template-name npm package
@@ -602,17 +624,17 @@ ${colors.bright}Template Types:${colors.reset}
602
624
  https://github.com/user/repo GitHub URL
603
625
 
604
626
  ${colors.bright}Examples:${colors.reset}
605
- npx uniweb create my-project # Interactive (prompts for template)
606
- npx uniweb create my-project --template starter # Foundation + site + starter content
607
- 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
608
630
  npx uniweb create my-project --template marketing # Official template
609
631
  npx uniweb create my-project --template ./my-template # Local template
610
632
 
611
633
  cd my-project
612
- npx uniweb add foundation marketing # Add foundations/marketing/
613
- npx uniweb add foundation marketing --from marketing # Scaffold + marketing sections
614
- npx uniweb add site blog --foundation marketing # Add sites/blog/ wired to marketing
615
- 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
616
638
  npx uniweb add extension effects --site site # Add extensions/effects/
617
639
 
618
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']