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 +14 -11
- package/package.json +4 -4
- package/partials/agents.md +150 -32
- package/src/commands/add.js +233 -86
- package/src/index.js +53 -31
- package/src/templates/resolver.js +1 -1
- package/starter/foundation/src/foundation.js +2 -2
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
|
-
|
|
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
|
|
238
|
-
npx uniweb add
|
|
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
|
|
241
|
-
npx uniweb add site
|
|
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
|
|
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
|
|
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 --
|
|
262
|
+
pnpm create uniweb acme --blank
|
|
259
263
|
cd acme
|
|
260
|
-
npx uniweb add
|
|
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
|
+
"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.
|
|
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
|
}
|
package/partials/agents.md
CHANGED
|
@@ -27,17 +27,27 @@ pnpm create uniweb my-project
|
|
|
27
27
|
cd my-project && pnpm install
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
-
|
|
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
|
|
32
|
+
### Adding a co-located project
|
|
33
33
|
|
|
34
34
|
```bash
|
|
35
|
-
pnpm uniweb add
|
|
36
|
-
pnpm uniweb add site myname --project myname
|
|
35
|
+
pnpm uniweb add project docs
|
|
37
36
|
pnpm install
|
|
38
37
|
```
|
|
39
38
|
|
|
40
|
-
|
|
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
|
+
 ← items[0].icons[0]
|
|
120
134
|
Lightning quick. ← items[0].paragraphs[0]
|
|
121
135
|
|
|
122
136
|
### Secure ← items[1].title
|
|
137
|
+
 ← 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
|
{variant=compact}
|
|
177
194
|
{period=30d}
|
|
178
195
|
{position=top-right}
|
|
196
|
+
{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
|
{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
|
-
|
|
215
|
-
|
|
216
|
-
background:
|
|
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
|
-
|
|
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
|
-
|
|
|
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
|
|
496
|
+
neutral: stone # Named preset: stone, zinc, gray, slate, neutral
|
|
436
497
|
|
|
437
498
|
contexts:
|
|
438
499
|
light:
|
|
439
|
-
section: '#fafaf9'
|
|
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
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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
|
|
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' //
|
|
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
|
|
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
|
|
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
|
|
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.
|
package/src/commands/add.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Add Command
|
|
3
3
|
*
|
|
4
|
-
* Adds foundations, sites, or
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
//
|
|
227
|
-
|
|
229
|
+
// Package name = name or 'foundation'
|
|
230
|
+
const packageName = name || 'foundation'
|
|
228
231
|
if (existingNames.has(packageName)) {
|
|
229
|
-
|
|
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
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
//
|
|
317
|
+
// Package name = name or 'site'
|
|
318
|
+
const siteName = name || 'site'
|
|
323
319
|
if (existingNames.has(siteName)) {
|
|
324
|
-
|
|
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
|
-
|
|
501
|
-
|
|
606
|
+
// Respect existing co-located layout
|
|
607
|
+
if (hasColocated && name) {
|
|
608
|
+
return `${name}/foundation`
|
|
502
609
|
}
|
|
503
610
|
|
|
504
|
-
//
|
|
505
|
-
if (
|
|
506
|
-
return 'foundation'
|
|
611
|
+
// Respect existing segregated layout
|
|
612
|
+
if (hasFoundationsGlob) {
|
|
613
|
+
return `foundations/${name || 'foundation'}`
|
|
507
614
|
}
|
|
508
615
|
|
|
509
|
-
//
|
|
510
|
-
|
|
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
|
-
|
|
529
|
-
|
|
654
|
+
// Respect existing co-located layout
|
|
655
|
+
if (hasColocated && name) {
|
|
656
|
+
return `${name}/site`
|
|
530
657
|
}
|
|
531
658
|
|
|
532
|
-
//
|
|
533
|
-
if (
|
|
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
|
-
//
|
|
538
|
-
return
|
|
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
|
|
763
|
-
uniweb add
|
|
764
|
-
uniweb add foundation
|
|
765
|
-
uniweb add
|
|
766
|
-
uniweb add site
|
|
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
|
-
|
|
112
|
-
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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 (
|
|
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 (
|
|
545
|
+
if (isBlank) {
|
|
525
546
|
log(`Next steps:\n`)
|
|
526
547
|
log(` ${colors.cyan}cd ${projectName}${colors.reset}`)
|
|
527
|
-
log(` ${colors.cyan}
|
|
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 (
|
|
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
|
-
|
|
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 #
|
|
606
|
-
npx uniweb create my-project --template
|
|
607
|
-
npx uniweb create my-project --
|
|
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
|
|
613
|
-
npx uniweb add
|
|
614
|
-
npx uniweb add
|
|
615
|
-
npx uniweb add site blog --
|
|
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: '
|
|
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
|
|