uniweb 0.8.4 → 0.8.6

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.6",
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",
44
+ "@uniweb/build": "0.8.5",
45
+ "@uniweb/kit": "0.7.3",
45
46
  "@uniweb/core": "0.5.4",
46
- "@uniweb/runtime": "0.6.4",
47
- "@uniweb/kit": "0.7.3"
47
+ "@uniweb/runtime": "0.6.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
 
@@ -57,8 +67,11 @@ The `--project` flag co-locates foundation and site under `myname/`. The CLI nam
57
67
  pnpm install # Install dependencies
58
68
  pnpm dev # Start dev server
59
69
  pnpm build # Build for production
70
+ pnpm preview # Preview production build (SSG + SPA)
60
71
  ```
61
72
 
73
+ > **npm works too.** Projects include both `pnpm-workspace.yaml` and npm workspaces. Replace `pnpm` with `npm` in any command above.
74
+
62
75
  ## Content Authoring
63
76
 
64
77
  ### Section Format
@@ -102,7 +115,7 @@ content = {
102
115
  insets: [], // Inline @Component references — { refId }
103
116
  lists: [], // [[{ paragraphs, links, lists, ... }]] — each list item is an object, not a string
104
117
  quotes: [], // Blockquotes
105
- data: {}, // From tagged code blocks (```yaml:tagname)
118
+ data: {}, // From tagged code blocks (```yaml:tagname) and (```js:tagname)
106
119
  headings: [], // Overflow headings after subtitle2
107
120
  items: [], // Each has the same flat structure — from headings after body content
108
121
  sequence: [], // All elements in document order
@@ -117,12 +130,16 @@ content = {
117
130
  We built this for you. ← paragraph
118
131
 
119
132
  ### Fast ← items[0].title
133
+ ![](lu-zap) ← items[0].icons[0]
120
134
  Lightning quick. ← items[0].paragraphs[0]
121
135
 
122
136
  ### Secure ← items[1].title
137
+ ![](lu-shield) ← items[1].icons[0]
123
138
  Enterprise-grade. ← items[1].paragraphs[0]
124
139
  ```
125
140
 
141
+ Each item has the same content shape as the top level — `title`, `paragraphs`, `icons`, `links`, `lists`, etc. are all available per item.
142
+
126
143
  **Lists** contain bullet or ordered list items. Each list item is an object with the same content shape — not a plain string:
127
144
 
128
145
  ```markdown
@@ -176,6 +193,7 @@ The three parts carry distinct information:
176
193
  ![Architecture diagram](@NetworkDiagram){variant=compact}
177
194
  ![Cache metrics](@PerformanceChart){period=30d}
178
195
  ![](@GradientBlob){position=top-right}
196
+ ![npm create uniweb](@CommandBlock){note="Vite + React + Routing — ready to go"}
179
197
  ```
180
198
 
181
199
  Inset components must declare `inset: true` in their `meta.js`. They render at the exact position in the content flow where the author placed them. See meta.js section below for details.
@@ -188,6 +206,8 @@ Inset components must declare `inset: true` in their `meta.js`. They render at t
188
206
  ![alt](./img.jpg){role=banner} <!-- Role determines array: imgs, icons, or videos -->
189
207
  ```
190
208
 
209
+ **Quote values that contain spaces:** `{note="Ready to go"}` not `{note=Ready to go}`. Unquoted values end at the first space.
210
+
191
211
  Standalone links (alone on a line) become buttons. Inline links stay as text links.
192
212
 
193
213
  ### Structured Data
@@ -205,19 +225,33 @@ submitLabel: Send
205
225
 
206
226
  Access: `content.data?.form` → `{ fields: [...], submitLabel: "Send" }`
207
227
 
228
+ **Code blocks need tags too.** Untagged code blocks (plain ```js) are only visible to sequential-rendering components like Article or DocSection. If a component needs to access code blocks by name, tag them:
229
+
230
+ ````markdown
231
+ ```jsx:before
232
+ const old = fetch('/api')
233
+ ```
234
+
235
+ ```jsx:after
236
+ const data = useData()
237
+ ```
238
+ ````
239
+
240
+ Access: `content.data?.before`, `content.data?.after` → raw code strings.
241
+
208
242
  ### Section Backgrounds
209
243
 
210
- Set `background` in frontmatter — the runtime renders it automatically:
244
+ Set `background` in frontmatter — the runtime renders it automatically. The string form auto-detects the type:
211
245
 
212
246
  ```yaml
213
- ---
214
- type: Hero
215
- theme: dark
216
- background: /images/hero.jpg # Simple: URL (image or video auto-detected)
217
- ---
247
+ background: /images/hero.jpg # Image (by extension)
248
+ background: /videos/hero.mp4 # Video (by extension)
249
+ background: linear-gradient(135deg, #667eea, #764ba2) # CSS gradient
250
+ background: '#1a1a2e' # Color (hex quote in YAML)
251
+ background: var(--primary-900) # Color (CSS variable)
218
252
  ```
219
253
 
220
- Full syntax supports `image`, `video`, `gradient`, `color` modes plus overlays:
254
+ The object form gives more control:
221
255
 
222
256
  ```yaml
223
257
  background:
@@ -225,6 +259,8 @@ background:
225
259
  overlay: { enabled: true, type: dark, opacity: 0.5 }
226
260
  ```
227
261
 
262
+ Overlay shorthand — `overlay: 0.5` is equivalent to `{ enabled: true, type: dark, opacity: 0.5 }`.
263
+
228
264
  Components that render their own background declare `background: 'self'` in `meta.js`.
229
265
 
230
266
  ### Page Organization
@@ -407,7 +443,7 @@ The runtime does significant work that other frameworks push onto components. Un
407
443
  | `isDark ? 'text-white' : 'text-gray-900'` | Just write `text-heading` — it adapts |
408
444
  | Background rendering code | Declare `background:` in frontmatter instead |
409
445
  | Color constants / tokens files | Colors come from `theme.yml` |
410
- | Custom CSS variables for colors (`--ink`, `--paper`, `--accent`) in `styles.css` | Map source colors to `theme.yml` colors/neutral. The build generates `--primary-50` through `--primary-950`, `--neutral-50` through `--neutral-950`, etc. Components use semantic tokens (`text-heading`, `bg-section`) that resolve from these palettes per context. A parallel color system bypasses all of this. |
446
+ | Parallel color system (`--ink`, `--paper`) that duplicates what tokens already provide | Map source color roles to `theme.yml` colors/neutral. The build generates `--primary-50` through `--primary-950`, `--neutral-50` through `--neutral-950`, etc. Use palette shades directly (`var(--primary-300)`) for specific tones. Additive design classes that BUILD ON tokens are fine a parallel system that REPLACES them bypasses context adaptation. |
411
447
 
412
448
  **What to hardcode** (not semantic — same in every context): layout (`grid`, `flex`, `max-w-6xl`), spacing (`p-6`, `gap-8`), typography scale (`text-3xl`, `font-bold`), animations, border-radius.
413
449
 
@@ -420,6 +456,33 @@ theme: dark ← sets context-dark, all tokens resolve to dark values
420
456
  ---
421
457
  ```
422
458
 
459
+ Alternate between `light` (default), `medium`, and `dark` across sections for visual rhythm — no CSS needed. A typical marketing page:
460
+
461
+ ```markdown
462
+ <!-- 1-hero.md -->
463
+ theme: dark
464
+
465
+ <!-- 2-features.md -->
466
+ (no theme — defaults to light)
467
+
468
+ <!-- 3-testimonials.md -->
469
+ theme: medium
470
+
471
+ <!-- 4-cta.md -->
472
+ theme: dark
473
+ ```
474
+
475
+ **Per-section token overrides** — the object form lets authors fine-tune individual tokens for a specific section:
476
+
477
+ ```yaml
478
+ theme:
479
+ mode: light
480
+ primary: var(--neutral-900) # Dark buttons in a light section
481
+ primary-hover: var(--neutral-800)
482
+ ```
483
+
484
+ Any semantic token (`section`, `heading`, `body`, `primary`, `link`, etc.) can be overridden this way. The overrides are applied as inline CSS custom properties on the section wrapper — components don't need to know about them.
485
+
423
486
  **Site controls the palette** in `theme.yml`. The same foundation looks different across sites because tokens resolve from the site's color configuration, not from component code.
424
487
 
425
488
  ### theme.yml
@@ -427,18 +490,14 @@ theme: dark ← sets context-dark, all tokens resolve to dark values
427
490
  ```yaml
428
491
  # site/theme.yml
429
492
  colors:
430
- primary:
431
- base: '#3b82f6'
432
- exactMatch: true # Use this exact hex at the 500 shade
493
+ primary: '#3b82f6' # Your exact hex appears at shade 500
433
494
  secondary: '#64748b'
434
495
  accent: '#8b5cf6'
435
- neutral: stone # Named preset: stone, zinc, gray, slate, neutral
496
+ neutral: stone # Named preset: stone, zinc, gray, slate, neutral
436
497
 
437
498
  contexts:
438
499
  light:
439
- section: '#fafaf9' # Override individual tokens per context
440
- primary: var(--primary-500)
441
- primary-hover: var(--primary-600)
500
+ section: '#fafaf9' # Override individual tokens per context
442
501
 
443
502
  fonts:
444
503
  import:
@@ -457,23 +516,78 @@ vars: # Override foundation-declared variables
457
516
 
458
517
  Each color generates 11 OKLCH shades (50–950). The `neutral` palette is special — use a named preset (`stone` for warm) rather than a hex value. Three contexts are built-in: `light` (default), `medium`, `dark`. Context override keys match token names — `section:` not `bg:`, `primary:` not `btn-primary-bg:`.
459
518
 
519
+ ### How colors reach components
520
+
521
+ Your hex color → 11 shades (50–950) → semantic tokens → components.
522
+
523
+ **Shade 500 = your exact input color.** The build generates lighter shades (50–400) above it and darker shades (600–950) below it, redistributing lightness proportionally to maintain a smooth scale. Set `exactMatch: false` on a color to opt out and use fixed lightness values instead.
524
+
525
+ Semantic tokens map shades to roles. The defaults for light/medium contexts:
526
+
527
+ | Token | Shade | Purpose |
528
+ |-------|-------|---------|
529
+ | `--primary` | 600 | Button background |
530
+ | `--primary-hover` | 700 | Button hover |
531
+ | `--link` | 600 | Link color |
532
+ | `--ring` | 500 | Focus ring |
533
+
534
+ In dark contexts, `--primary` uses shade 500 and `--link` uses shade 400.
535
+
536
+ **Buttons and links use shade 600 — darker than your input.** This is an accessibility choice: shade 600 provides better contrast with white button text. For medium-bright brand colors like orange, buttons will be noticeably darker than the brand color.
537
+
538
+ **Recipe — brand-exact buttons:**
539
+
540
+ ```yaml
541
+ colors:
542
+ primary: "#E35D25"
543
+
544
+ contexts:
545
+ light:
546
+ primary: primary-500 # Your exact color on buttons
547
+ primary-hover: primary-600 # Darker on hover
548
+ ```
549
+
550
+ > **Contrast warning:** Bright brand colors (orange, yellow, light green) at shade 500 may not meet WCAG contrast (4.5:1) with white foreground text. Test buttons for readability — if contrast is insufficient, keep the default shade 600 mapping or darken your base color.
551
+
460
552
  ### Foundation variables
461
553
 
462
- Foundations declare customizable layout/spacing values in `foundation.js`:
554
+ Foundations declare customizable layout/spacing values in `foundation.js`. The starter includes:
463
555
 
464
556
  ```js
465
- export default {
466
- vars: {
467
- 'header-height': { default: '4rem' },
468
- 'sidebar-width': { default: '280px' },
469
- },
557
+ export const vars = {
558
+ 'header-height': { default: '4rem', description: 'Fixed header height' },
559
+ 'max-content-width': { default: '80rem', description: 'Maximum content width' },
560
+ 'section-padding-y': { default: 'clamp(4rem, 6vw, 7rem)', description: 'Vertical padding for sections' },
470
561
  }
471
562
  ```
472
563
 
473
- Sites override them in `theme.yml` under `vars:`. Components use them as `var(--header-height)`.
564
+ Sites override them in `theme.yml` under `vars:`. Components use them via Tailwind arbitrary values or CSS: `py-[var(--section-padding-y)]`, `h-[var(--header-height)]`, etc.
565
+
566
+ The `section-padding-y` default uses `clamp()` for fluid spacing — tighter on mobile, more breathing room on large screens. Use this variable for consistent section spacing instead of hardcoding padding in each component. Sites can override to a fixed value (`section-padding-y: 3rem`) or a different clamp in `theme.yml`.
474
567
 
475
568
  **When to break the rules:** Header/footer components that float over content may need direct color logic (reading the first section's theme). Decorative elements with fixed branding (logos) use literal colors.
476
569
 
570
+ ### Design richness beyond tokens
571
+
572
+ Semantic tokens handle context adaptation — the hard problem of making colors work in light, medium, and dark sections. **They are a floor, not a ceiling.** A great foundation adds its own design vocabulary on top.
573
+
574
+ The token set is deliberately small (24 tokens). It covers the dimensions that change per context. Everything that stays constant across contexts — border weights, shadow depth, radius scales, gradient angles, accent borders, glassmorphism, elevation layers — belongs in your foundation's `styles.css` or component code.
575
+
576
+ **Don't flatten a rich design to fit the token set.** If a source design has 4 border tones, create them:
577
+
578
+ ```css
579
+ /* foundation/src/styles.css */
580
+ .border-subtle { border-color: color-mix(in oklch, var(--border), transparent 50%); }
581
+ .border-strong { border-color: color-mix(in oklch, var(--border), var(--heading) 30%); }
582
+ .border-accent { border-color: var(--primary-300); }
583
+ ```
584
+
585
+ These compose with semantic tokens — they adapt per context because they reference `--border`, `--heading`, or palette shades. But they add design nuance the token set alone doesn't provide.
586
+
587
+ **The priority:** Design quality > portability > configurability. It's better to ship a foundation with beautiful, detailed design that's less configurable than to ship a generic one that looks flat. A foundation that looks great for one site is more valuable than one that looks mediocre for any site.
588
+
589
+ **When migrating from an existing design**, map every visual detail — not just the ones that have a semantic token. Shadow systems, border hierarchies, custom hover effects, accent tints: create CSS classes or Tailwind utilities in `styles.css` for anything the original has that tokens don't cover. Use palette shades directly (`var(--primary-300)`, `bg-neutral-200`) for fine-grained color control beyond the semantic tokens.
590
+
477
591
  ## Component Development
478
592
 
479
593
  ### Props Interface
@@ -499,13 +613,13 @@ function Hero({ content, params }) {
499
613
  )
500
614
  }
501
615
 
502
- Hero.className = 'pt-32 md:pt-48' // Classes on the <section> wrapper
616
+ Hero.className = 'pt-32 md:pt-48' // Override spacing for hero (more top padding)
503
617
  Hero.as = 'div' // Change wrapper element (default: 'section')
504
618
 
505
619
  export default Hero
506
620
  ```
507
621
 
508
- - `Component.className` — adds classes to the runtime's wrapper. Use for section-level padding, borders, overflow. The component's own JSX handles inner layout only (`max-w-7xl mx-auto px-6`).
622
+ - `Component.className` — adds classes to the runtime's wrapper. Use for section-level spacing, borders, overflow. Set `py-[var(--section-padding-y)]` for consistent spacing from the theme variable, or override for specific sections (e.g., hero needs extra top padding). The component's own JSX handles inner layout only (`max-w-7xl mx-auto px-6`).
509
623
  - `Component.as` — changes the wrapper element. Use `'nav'` for headers, `'footer'` for footers, `'div'` when `<section>` isn't semantically appropriate.
510
624
 
511
625
  ### meta.js Structure
@@ -567,6 +681,8 @@ import { H2, P, Span } from '@uniweb/kit'
567
681
  <Text text={content.title} as="h2" className="..." /> // explicit tag
568
682
  ```
569
683
 
684
+ These components render their own HTML tag — don't wrap them in a matching tag. `<h2><H2 text={...} /></h2>` creates a nested `<h2><h2>...</h2></h2>`, which is invalid HTML. Just use `<H2 text={...} />` directly.
685
+
570
686
  Don't render content strings with `{content.paragraphs[0]}` in JSX — that shows HTML tags as visible text. Use `<P>`, `<H2>`, `<Span>`, etc. for content strings.
571
687
 
572
688
  **Rendering full content** (`@uniweb/kit`):
@@ -805,7 +921,7 @@ Uniweb section types do more with less because the framework handles concerns th
805
921
 
806
922
  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
923
  ```bash
808
- pnpm create uniweb my-project --template blank
924
+ pnpm create uniweb my-project --template none
809
925
  ```
810
926
 
811
927
  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.
@@ -843,7 +959,7 @@ Foundation styles in `foundation/src/styles.css`:
843
959
 
844
960
  Semantic color tokens (`text-heading`, `bg-section`, `bg-primary`, etc.) come from `theme-tokens.css` — which the runtime populates from the site's `theme.yml`. Don't redefine colors here that belong in `theme.yml`. Use `@theme` only for values the token system doesn't cover (custom breakpoints, animations, shadows).
845
961
 
846
- **Custom CSS is fine alongside Tailwind.** Animations, keyframes, gradients with masks, always-dark code blocks, and other effects that aren't expressible as utility classes can go directly in `styles.css`. Tailwind handles layout and spacing; custom CSS handles visual effects.
962
+ **Custom CSS is expected alongside Tailwind.** Your foundation's `styles.css` is the design layer — shadow systems, border hierarchies, gradient effects, accent treatments, elevation scales, glassmorphism. If the source design has a visual detail, create a class for it. Tailwind handles layout and spacing; semantic tokens handle context adaptation; `styles.css` handles everything else that makes the design rich and distinctive.
847
963
 
848
964
  ## Troubleshooting
849
965
 
@@ -853,6 +969,8 @@ Semantic color tokens (`text-heading`, `bg-section`, `bg-primary`, etc.) come fr
853
969
 
854
970
  **Styles not applying** — Verify `@source` in `styles.css` includes your component paths. Check custom colors match `@theme` definitions.
855
971
 
972
+ **Prerender warnings about hooks/useState** — During `pnpm build`, you may see `Warning: Failed to render /: Cannot read properties of null (reading 'useState')` for pages. This is a known limitation of the SSG pipeline (dual React instances in development). The site works correctly client-side — these warnings only affect the static HTML preview, not functionality.
973
+
856
974
  ## Further Documentation
857
975
 
858
976
  Full Uniweb documentation is available at **https://github.com/uniweb/docs** — raw markdown files you can fetch directly.
@@ -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']
@@ -24,8 +24,8 @@ export const vars = {
24
24
  description: 'Maximum content width (1280px)',
25
25
  },
26
26
  'section-padding-y': {
27
- default: '5rem',
28
- description: 'Vertical padding for sections',
27
+ default: 'clamp(4rem, 6vw, 7rem)',
28
+ description: 'Vertical padding for sections (fluid: adapts to viewport)',
29
29
  },
30
30
  }
31
31