uniweb 0.8.13 → 0.8.15

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
@@ -36,10 +36,10 @@ Edit files in `site/pages/` and `foundation/src/sections/` to see changes instan
36
36
  | **Store** | E-commerce with product grid |
37
37
  | **Extensions** | Multi-foundation with visual effects extension |
38
38
 
39
- Use `--blank` for an empty workspace (no packages) — grow with `uniweb add`.
40
-
41
39
  **See them live:** [View all template demos](https://uniweb.github.io/templates/)
42
40
 
41
+ Use `--blank` for an empty workspace (no packages) — grow with `uniweb add`.
42
+
43
43
  Or skip the interactive prompt:
44
44
 
45
45
  ```bash
@@ -164,6 +164,36 @@ export default function Hero({ content, params }) {
164
164
 
165
165
  Standard React. Standard Tailwind. The `{ content, params }` interface is only for _section types_ — components that content creators select in markdown frontmatter. Everything else uses regular React props.
166
166
 
167
+ ### Composition
168
+
169
+ Sections aren't limited to flat content. Content authors can embed interactive React components using markdown image syntax:
170
+
171
+ ```markdown
172
+ ---
173
+ type: SplitContent
174
+ ---
175
+
176
+ # See it in action
177
+
178
+ The architecture handles the hard parts so you can focus on what matters.
179
+
180
+ ![Live metrics](@PerformanceChart){period=30d}
181
+ ```
182
+
183
+ `@PerformanceChart` is a full React component — a ThreeJS animation, an interactive diagram, a live data visualization — placed by the content author, rendered in the component's visual slot via `<Visual>`. It looks like an image reference, but it can be anything.
184
+
185
+ Authors can also compose layouts from reusable section types using child sections:
186
+
187
+ ```
188
+ pages/home/
189
+ ├── highlights.md # type: Grid, columns: 3
190
+ ├── @stats.md # type: StatCard
191
+ ├── @testimonial.md # type: Testimonial
192
+ └── @demo.md # type: SplitContent (with an embedded @LiveDemo inset)
193
+ ```
194
+
195
+ Three different section types, arranged in a grid, one with an interactive component inside it — all authored in markdown. The developer builds reusable pieces; the content author composes them. See the [Component Patterns guide](https://github.com/uniweb/docs/blob/main/development/component-patterns.md) for the full composition model.
196
+
167
197
  ## Next Steps
168
198
 
169
199
  After creating your project:
@@ -386,7 +416,7 @@ packages:
386
416
 
387
417
  **How is this different from MDX?**
388
418
 
389
- MDX blends markdown and JSX—content authors write code. Uniweb keeps them separate: content stays in markdown, components stay in React. Content authors can't break components, and component updates don't require content changes.
419
+ MDX blends markdown and JSX—content authors write code. Uniweb keeps them separate: content stays in markdown, components stay in React. Authors can still embed interactive components (via `![](@Component)` syntax), but they never see JSX. Component updates don't require content changes, and content changes can't break components.
390
420
 
391
421
  **How is this different from Astro?**
392
422
 
@@ -420,4 +450,4 @@ Yes — documentation is a natural fit. Content stays in markdown (easy to versi
420
450
 
421
451
  ## License
422
452
 
423
- Apache 2.0
453
+ Apache 2.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uniweb",
3
- "version": "0.8.13",
3
+ "version": "0.8.15",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,11 +41,11 @@
41
41
  "js-yaml": "^4.1.0",
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.0.0",
44
- "@uniweb/build": "0.8.12",
44
+ "@uniweb/build": "0.8.14",
45
45
  "@uniweb/content-reader": "1.1.4",
46
- "@uniweb/core": "0.5.9",
47
- "@uniweb/kit": "0.7.9",
48
- "@uniweb/runtime": "0.6.9",
46
+ "@uniweb/core": "0.5.10",
47
+ "@uniweb/runtime": "0.6.11",
48
+ "@uniweb/kit": "0.7.10",
49
49
  "@uniweb/semantic-parser": "1.1.6"
50
50
  }
51
51
  }
@@ -121,7 +121,7 @@ export async function add(rawArgs) {
121
121
  error(`Missing subcommand.\n`)
122
122
  log(formatOptions([
123
123
  { label: 'project', description: 'Co-located foundation + site pair' },
124
- { label: 'foundation', description: 'Component library' },
124
+ { label: 'foundation', description: 'Component system for content authors' },
125
125
  { label: 'site', description: 'Content site' },
126
126
  { label: 'extension', description: 'Additional component package' },
127
127
  { label: 'section', description: 'Section type in a foundation' },
@@ -137,7 +137,7 @@ export async function add(rawArgs) {
137
137
  message: 'What would you like to add?',
138
138
  choices: [
139
139
  { title: 'Project', value: 'project', description: 'Co-located foundation + site pair' },
140
- { title: 'Foundation', value: 'foundation', description: 'Component library' },
140
+ { title: 'Foundation', value: 'foundation', description: 'Component system for content authors' },
141
141
  { title: 'Site', value: 'site', description: 'Content site' },
142
142
  { title: 'Extension', value: 'extension', description: 'Additional component package' },
143
143
  { title: 'Section', value: 'section', description: 'Section type in a foundation' },
@@ -594,7 +594,8 @@ async function addProject(rootDir, projectName, opts, pm = 'pnpm') {
594
594
  * - --project: {project}/foundation (co-located)
595
595
  * - Existing co-located glob: follow pattern
596
596
  * - Existing segregated glob: follow pattern
597
- * - First foundation: dir name is the name (default: 'foundation')
597
+ * - Named (e.g., "marketing"): segregated at foundations/{name}
598
+ * - Unnamed: root-level 'foundation'
598
599
  * - Already have one: error in non-interactive, ask in interactive
599
600
  */
600
601
  async function resolveFoundationTarget(rootDir, name, opts) {
@@ -619,8 +620,13 @@ async function resolveFoundationTarget(rootDir, name, opts) {
619
620
  return `foundations/${name || 'foundation'}`
620
621
  }
621
622
 
622
- // dir name = name or 'foundation'
623
- const dirName = name || 'foundation'
623
+ // Named foundation segregated layout (foundations/{name})
624
+ if (name) {
625
+ return `foundations/${name}`
626
+ }
627
+
628
+ // Unnamed → root-level 'foundation'
629
+ const dirName = 'foundation'
624
630
 
625
631
  // Check if target already exists
626
632
  if (!existsSync(join(rootDir, dirName))) {
@@ -667,8 +673,13 @@ async function resolveSiteTarget(rootDir, name, opts) {
667
673
  return `sites/${name || 'site'}`
668
674
  }
669
675
 
670
- // dir name = name or 'site'
671
- const dirName = name || 'site'
676
+ // Named site segregated layout (sites/{name})
677
+ if (name) {
678
+ return `sites/${name}`
679
+ }
680
+
681
+ // Unnamed → root-level 'site'
682
+ const dirName = 'site'
672
683
 
673
684
  // Check if target already exists
674
685
  if (!existsSync(join(rootDir, dirName))) {
@@ -1050,9 +1061,9 @@ ${colors.bright}Examples:${colors.reset}
1050
1061
  uniweb add project docs # Create docs/foundation/ + docs/site/
1051
1062
  uniweb add project docs --from academic # Co-located pair + academic content
1052
1063
  uniweb add foundation # Create ./foundation/ at root
1053
- uniweb add foundation ui # Create ./ui/ at root
1064
+ uniweb add foundation ui # Create ./foundations/ui/
1054
1065
  uniweb add site # Create ./site/ at root
1055
- uniweb add site blog --foundation marketing # Create ./blog/ wired to marketing
1066
+ uniweb add site blog --foundation marketing # Create ./sites/blog/ wired to marketing
1056
1067
  uniweb add extension effects --site site # Create ./extensions/effects/
1057
1068
  uniweb add section Hero # Create Hero section type
1058
1069
  uniweb add section Hero --foundation ui # Target specific foundation
@@ -18,9 +18,7 @@ import { writeFile, mkdir } from 'node:fs/promises'
18
18
  // Import build utilities from @uniweb/build
19
19
  import {
20
20
  generateEntryPoint,
21
- buildSchema,
22
21
  discoverComponents,
23
- processAllPreviews,
24
22
  } from '@uniweb/build'
25
23
  import { readSiteConfig } from '@uniweb/build/site'
26
24
  import { readWorkspaceConfig, resolveGlob } from '../utils/config.js'
@@ -130,7 +128,6 @@ function runCommand(command, args, cwd) {
130
128
  */
131
129
  async function buildFoundation(projectDir, options = {}) {
132
130
  const srcDir = join(projectDir, 'src')
133
- const distDir = join(projectDir, 'dist')
134
131
 
135
132
  info('Building foundation...')
136
133
 
@@ -163,50 +160,19 @@ async function buildFoundation(projectDir, options = {}) {
163
160
  // For now, just run the standard vite build
164
161
  await runCommand('npx', ['vite', 'build'], projectDir)
165
162
 
166
- success('Vite build complete')
167
-
168
- // 4. Generate schema.json
169
- log('')
170
- info('Generating schema.json...')
171
- let schema = await buildSchema(srcDir)
172
-
173
- await mkdir(distDir, { recursive: true })
163
+ // Vite's foundation plugin generates dist/meta/schema.json
164
+ // and processes preview images during the build.
174
165
 
175
- // 5. Process preview images
176
- log('')
177
- info('Processing preview images...')
178
- const isProduction = process.env.NODE_ENV === 'production' || !process.env.NODE_ENV
179
- const { schema: updatedSchema, totalImages } = await processAllPreviews(
180
- srcDir,
181
- distDir,
182
- schema,
183
- isProduction
184
- )
185
- schema = updatedSchema
186
-
187
- if (totalImages > 0) {
188
- success(`Processed ${totalImages} preview image${totalImages > 1 ? 's' : ''} (converted to webp)`)
189
- } else {
190
- log(` ${colors.dim}No preview images found${colors.reset}`)
191
- }
192
-
193
- // 6. Write schema.json
194
- const schemaPath = join(distDir, 'schema.json')
195
- await writeFile(schemaPath, JSON.stringify(schema, null, 2), 'utf-8')
196
-
197
- success(`Generated schema.json with ${componentNames.length} components`)
166
+ success('Vite build complete')
198
167
 
199
168
  // Summary
200
169
  log('')
201
170
  log(`${colors.green}${colors.bright}Build complete!${colors.reset}`)
171
+
202
172
  log('')
203
- log(`Output:`)
204
- log(` ${colors.dim}dist/foundation.js${colors.reset} - Bundled components`)
205
- log(` ${colors.dim}dist/assets/style.css${colors.reset} - Compiled CSS`)
206
- log(` ${colors.dim}dist/schema.json${colors.reset} - Component schemas`)
207
- if (totalImages > 0) {
208
- log(` ${colors.dim}dist/assets/[component]/${colors.reset} - Preview images`)
209
- }
173
+ log(`${colors.bright}Share with clients:${colors.reset}`)
174
+ log(` ${colors.bright}uniweb publish${colors.reset} Register your foundation (one-time setup)`)
175
+ log(` ${colors.bright}uniweb handoff <email>${colors.reset} Hand off a site to a client`)
210
176
  }
211
177
 
212
178
  /**
@@ -225,7 +191,7 @@ async function loadI18nConfig(projectDir, siteConfig = null) {
225
191
 
226
192
  // Resolve locales (undefined/'*' → all available, array → specific)
227
193
  const { resolveLocales } = await import('@uniweb/build/i18n')
228
- const locales = await resolveLocales(config.i18n?.locales, localesPath)
194
+ const locales = await resolveLocales(config.languages, localesPath)
229
195
 
230
196
  if (locales.length === 0) return null
231
197
 
@@ -489,6 +455,8 @@ async function buildSite(projectDir, options = {}) {
489
455
  process.exit(1)
490
456
  }
491
457
  }
458
+
459
+ showNextSteps(false, true)
492
460
  }
493
461
 
494
462
  /**
@@ -625,6 +593,26 @@ async function buildWorkspace(workspaceDir, options = {}) {
625
593
  log(`${colors.green}${colors.bright}Workspace build complete!${colors.reset}`)
626
594
  log('')
627
595
  log(`Built ${parts.join(', ')}`)
596
+
597
+ showNextSteps(foundations.length > 0, sites.length > 0)
598
+ }
599
+
600
+ /**
601
+ * Show next-step hints after workspace build
602
+ */
603
+ function showNextSteps(hasFoundations, hasSites) {
604
+ if (hasFoundations) {
605
+ log('')
606
+ log(`${colors.bright}Share with clients:${colors.reset}`)
607
+ log(` ${colors.bright}uniweb publish${colors.reset} Register your foundation (one-time setup)`)
608
+ log(` ${colors.bright}uniweb handoff <email>${colors.reset} Hand off a site to a client`)
609
+ }
610
+ if (hasSites) {
611
+ log('')
612
+ log(`${colors.bright}Deploy:${colors.reset}`)
613
+ log(` ${colors.bright}uniweb deploy${colors.reset} Uniweb hosting`)
614
+ log(` Or upload ${colors.cyan}dist/${colors.reset} to any static host`)
615
+ }
628
616
  }
629
617
 
630
618
  /**
@@ -0,0 +1,272 @@
1
+ /**
2
+ * Deploy Command
3
+ *
4
+ * Deploys a built site to Uniweb hosting.
5
+ *
6
+ * Usage:
7
+ * uniweb deploy # Deploy to Uniweb hosting
8
+ * uniweb deploy --local # Deploy to local server (no auth)
9
+ * uniweb deploy --registry <url> # Deploy to a specific server URL
10
+ * uniweb deploy --dry-run # Show what would be deployed
11
+ * uniweb deploy --prod # Deploy to production (future)
12
+ */
13
+
14
+ import { existsSync } from 'node:fs'
15
+ import { readFile, readdir } from 'node:fs/promises'
16
+ import { resolve, join, basename, relative } from 'node:path'
17
+ import { execSync } from 'node:child_process'
18
+
19
+ import { ensureAuth } from '../utils/auth.js'
20
+ import { findWorkspaceRoot, findSites, findFoundations, classifyPackage, promptSelect } from '../utils/workspace.js'
21
+ import { isNonInteractive, getCliPrefix } from '../utils/interactive.js'
22
+
23
+ // Colors for terminal output
24
+ const colors = {
25
+ reset: '\x1b[0m',
26
+ bright: '\x1b[1m',
27
+ dim: '\x1b[2m',
28
+ cyan: '\x1b[36m',
29
+ green: '\x1b[32m',
30
+ yellow: '\x1b[33m',
31
+ red: '\x1b[31m',
32
+ }
33
+
34
+ function success(message) {
35
+ console.log(`${colors.green}✓${colors.reset} ${message}`)
36
+ }
37
+
38
+ function error(message) {
39
+ console.error(`${colors.red}✗${colors.reset} ${message}`)
40
+ }
41
+
42
+ function info(message) {
43
+ console.log(`${colors.cyan}→${colors.reset} ${message}`)
44
+ }
45
+
46
+ /**
47
+ * Resolve the site directory to deploy.
48
+ *
49
+ * Priority:
50
+ * 1. In a site directory → use it
51
+ * 2. At workspace root, one site → use it
52
+ * 3. At workspace root, multiple → prompt (or error if non-interactive)
53
+ * 4. No site → educational error with alternatives
54
+ *
55
+ * @param {string[]} args
56
+ * @returns {Promise<string>} Absolute path to the site directory
57
+ */
58
+ async function resolveSiteDir(args) {
59
+ const cwd = process.cwd()
60
+ const prefix = getCliPrefix()
61
+
62
+ // Check if current directory is a site
63
+ const type = await classifyPackage(cwd)
64
+ if (type === 'site') {
65
+ return cwd
66
+ }
67
+
68
+ // Check workspace
69
+ const workspaceRoot = findWorkspaceRoot(cwd)
70
+ if (workspaceRoot) {
71
+ const sites = await findSites(workspaceRoot)
72
+
73
+ if (sites.length === 1) {
74
+ return resolve(workspaceRoot, sites[0])
75
+ }
76
+
77
+ if (sites.length > 1) {
78
+ if (isNonInteractive(args)) {
79
+ error('Multiple sites found. Specify which one to deploy.')
80
+ console.log('')
81
+ for (const s of sites) {
82
+ console.log(` ${colors.cyan}cd ${s} && ${prefix} deploy${colors.reset}`)
83
+ }
84
+ process.exit(1)
85
+ }
86
+
87
+ const choice = await promptSelect('Which site?', sites)
88
+ if (!choice) {
89
+ console.log('\nDeploy cancelled.')
90
+ process.exit(0)
91
+ }
92
+ return resolve(workspaceRoot, choice)
93
+ }
94
+ }
95
+
96
+ // No site found — educational error
97
+ error('No site found in this workspace.')
98
+ console.log('')
99
+ console.log(` ${colors.dim}\`deploy\` uploads your built site to Uniweb hosting.${colors.reset}`)
100
+ console.log('')
101
+ console.log(` ${colors.dim}The site is a standard Vite build — you can also upload dist/${colors.reset}`)
102
+ console.log(` ${colors.dim}to any static host.${colors.reset}`)
103
+ process.exit(1)
104
+ }
105
+
106
+ /**
107
+ * Parse --registry <url> from args.
108
+ * @param {string[]} args
109
+ * @returns {string|null}
110
+ */
111
+ function parseRegistryUrl(args) {
112
+ const idx = args.indexOf('--registry')
113
+ if (idx === -1 || !args[idx + 1]) return null
114
+ return args[idx + 1]
115
+ }
116
+
117
+ /**
118
+ * Derive a siteId from the site's package.json or directory name.
119
+ * @param {string} siteDir
120
+ * @returns {Promise<string>}
121
+ */
122
+ async function deriveSiteId(siteDir) {
123
+ const pkgPath = join(siteDir, 'package.json')
124
+ if (existsSync(pkgPath)) {
125
+ try {
126
+ const pkg = JSON.parse(await readFile(pkgPath, 'utf8'))
127
+ if (pkg.name) return pkg.name
128
+ } catch {
129
+ // Fall through
130
+ }
131
+ }
132
+ return basename(siteDir)
133
+ }
134
+
135
+ /**
136
+ * Walk a directory recursively and collect all files as base64.
137
+ * @param {string} dir
138
+ * @returns {Promise<Object<string, string>>} Map of relative paths to base64 content
139
+ */
140
+ async function collectFiles(dir) {
141
+ const files = {}
142
+ const entries = await readdir(dir, { withFileTypes: true, recursive: true })
143
+
144
+ for (const entry of entries) {
145
+ if (!entry.isFile()) continue
146
+ const fullPath = join(entry.parentPath || entry.path, entry.name)
147
+ const relPath = relative(dir, fullPath)
148
+ const content = await readFile(fullPath)
149
+ files[relPath] = content.toString('base64')
150
+ }
151
+
152
+ return files
153
+ }
154
+
155
+ /**
156
+ * Main deploy command handler
157
+ */
158
+ export async function deploy(args = []) {
159
+ const isLocal = args.includes('--local')
160
+ const isDryRun = args.includes('--dry-run')
161
+ const registryUrl = parseRegistryUrl(args)
162
+ const prefix = getCliPrefix()
163
+
164
+ // 1. Resolve site directory
165
+ const siteDir = await resolveSiteDir(args)
166
+
167
+ // 2. Check auth (unless --local)
168
+ let token = null
169
+ if (!isLocal) {
170
+ token = await ensureAuth({ command: 'Deploying' })
171
+ }
172
+
173
+ // 3. Auto-build if dist/ is missing
174
+ const distDir = join(siteDir, 'dist')
175
+ const indexHtml = join(distDir, 'index.html')
176
+
177
+ if (!existsSync(indexHtml)) {
178
+ console.log(`${colors.yellow}⚠${colors.reset} No build found. Building site...`)
179
+ console.log('')
180
+ execSync('npx uniweb build', {
181
+ cwd: siteDir,
182
+ stdio: 'inherit',
183
+ })
184
+ console.log('')
185
+
186
+ if (!existsSync(indexHtml)) {
187
+ error('Build did not produce dist/index.html')
188
+ process.exit(1)
189
+ }
190
+ }
191
+
192
+ // 4. Derive siteId
193
+ const siteId = await deriveSiteId(siteDir)
194
+
195
+ // 5. Collect files from dist/
196
+ const files = await collectFiles(distDir)
197
+ const filesCount = Object.keys(files).length
198
+
199
+ // 6. Dry-run check
200
+ if (isDryRun) {
201
+ console.log('')
202
+ info(`Would deploy ${colors.bright}${siteId}${colors.reset} (${filesCount} files)`)
203
+ console.log(` ${colors.dim}Source: ${distDir}${colors.reset}`)
204
+ const serverUrl = registryUrl || process.env.UNIWEB_REGISTRY_URL || 'http://localhost:4001'
205
+ console.log(` ${colors.dim}Target: ${serverUrl}/sites/${siteId}/${colors.reset}`)
206
+ return
207
+ }
208
+
209
+ // 7. Deploy
210
+ const serverUrl = registryUrl || process.env.UNIWEB_REGISTRY_URL || 'http://localhost:4001'
211
+ info(`Deploying ${colors.bright}${siteId}${colors.reset} (${filesCount} files)...`)
212
+
213
+ const headers = { 'Content-Type': 'application/json' }
214
+ if (token) {
215
+ headers['Authorization'] = `Bearer ${token}`
216
+ }
217
+
218
+ const payload = {
219
+ siteId,
220
+ files,
221
+ metadata: {
222
+ deployedBy: isLocal ? 'local' : 'cli',
223
+ },
224
+ }
225
+
226
+ let res
227
+ try {
228
+ res = await fetch(`${serverUrl}/deploy`, {
229
+ method: 'POST',
230
+ headers,
231
+ body: JSON.stringify(payload),
232
+ })
233
+ } catch (err) {
234
+ error(`Could not connect to ${serverUrl}`)
235
+ console.log('')
236
+ console.log(` ${colors.dim}Make sure the cloud server is running:${colors.reset}`)
237
+ console.log(` ${colors.cyan}cd packages/cloud && pnpm dev${colors.reset}`)
238
+ process.exit(1)
239
+ }
240
+
241
+ const body = await res.json()
242
+
243
+ if (!res.ok) {
244
+ if (res.status === 401) {
245
+ error('Authentication failed.')
246
+ console.log(` Run ${colors.cyan}${prefix} login${colors.reset} to refresh your credentials.`)
247
+ process.exit(1)
248
+ }
249
+ error(body.error || `Deploy failed (${res.status})`)
250
+ process.exit(1)
251
+ }
252
+
253
+ console.log('')
254
+ success(`Deployed ${colors.bright}${siteId}${colors.reset}`)
255
+
256
+ const siteUrl = body.siteUrl
257
+ ? `${serverUrl}${body.siteUrl}`
258
+ : `${serverUrl}/sites/${siteId}/`
259
+ console.log(` ${colors.cyan}${siteUrl}${colors.reset}`)
260
+
261
+ // Cross-promotion: if workspace has a foundation, tip about publish
262
+ const workspaceRoot = findWorkspaceRoot(siteDir)
263
+ if (workspaceRoot) {
264
+ const foundations = await findFoundations(workspaceRoot)
265
+ if (foundations.length > 0) {
266
+ console.log('')
267
+ console.log(` ${colors.dim}Tip: Run \`${prefix} publish\` to register your foundation and invite clients.${colors.reset}`)
268
+ }
269
+ }
270
+ }
271
+
272
+ export default deploy
@@ -80,7 +80,7 @@ ${colors.bright}Page Ordering:${colors.reset}
80
80
  Rest are auto-discovered
81
81
 
82
82
  ${colors.bright}Internationalization:${colors.reset}
83
- ${colors.cyan}i18n.locales${colors.reset} Locales to build (array or "*" for all)
83
+ ${colors.cyan}languages${colors.reset} Languages to build (array or "*" for all)
84
84
  Example: [es, fr, de]
85
85
  ${colors.cyan}i18n.localesDir${colors.reset} Translation files directory (default: "locales")
86
86