uniweb 0.7.1 → 0.7.2

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.
Files changed (59) hide show
  1. package/README.md +64 -17
  2. package/package.json +4 -3
  3. package/src/commands/add.js +563 -0
  4. package/src/commands/build.js +49 -6
  5. package/src/commands/doctor.js +181 -2
  6. package/src/index.js +273 -131
  7. package/src/templates/index.js +0 -94
  8. package/src/templates/processor.js +10 -87
  9. package/src/templates/resolver.js +3 -3
  10. package/src/templates/validator.js +59 -17
  11. package/src/utils/config.js +229 -0
  12. package/src/utils/scaffold.js +175 -0
  13. package/templates/{single/foundation → foundation}/package.json.hbs +2 -2
  14. package/templates/foundation/src/foundation.js.hbs +7 -0
  15. package/templates/foundation/src/sections/.gitkeep +0 -0
  16. package/templates/{multi/sites/main → site}/package.json.hbs +2 -2
  17. package/templates/site/site.yml.hbs +10 -0
  18. package/templates/site/theme.yml +1 -0
  19. package/templates/{_shared → workspace}/package.json.hbs +3 -9
  20. package/templates/workspace/pnpm-workspace.yaml.hbs +4 -0
  21. package/templates/_shared/pnpm-workspace.yaml +0 -5
  22. package/templates/multi/README.md.hbs +0 -85
  23. package/templates/multi/foundations/default/package.json.hbs +0 -38
  24. package/templates/multi/foundations/default/src/foundation.js +0 -41
  25. package/templates/multi/package.json.hbs +0 -26
  26. package/templates/multi/sites/main/pages/home/1-welcome.md.hbs +0 -14
  27. package/templates/multi/sites/main/site.yml.hbs +0 -12
  28. package/templates/multi/sites/main/vite.config.js +0 -7
  29. package/templates/multi/template/.vscode/settings.json +0 -6
  30. package/templates/multi/template.json +0 -5
  31. package/templates/single/foundation/src/sections/Section/index.jsx +0 -121
  32. package/templates/single/foundation/src/sections/Section/meta.js +0 -61
  33. package/templates/single/foundation/src/styles.css +0 -5
  34. package/templates/single/foundation/vite.config.js +0 -3
  35. package/templates/single/site/index.html.hbs +0 -13
  36. package/templates/single/site/main.js +0 -7
  37. package/templates/single/site/package.json.hbs +0 -27
  38. package/templates/single/site/pages/about/1-about.md.hbs +0 -13
  39. package/templates/single/site/pages/about/page.yml +0 -2
  40. package/templates/single/site/pages/home/page.yml +0 -2
  41. package/templates/single/site/public/favicon.svg +0 -7
  42. package/templates/single/site/site.yml.hbs +0 -10
  43. package/templates/single/template.json +0 -10
  44. /package/{templates/single → starter}/foundation/src/foundation.js +0 -0
  45. /package/{templates/multi/foundations/default → starter/foundation}/src/sections/Section/index.jsx +0 -0
  46. /package/{templates/multi/foundations/default → starter/foundation}/src/sections/Section/meta.js +0 -0
  47. /package/{templates/multi/sites/main → starter/site}/pages/about/1-about.md.hbs +0 -0
  48. /package/{templates/multi/sites/main → starter/site}/pages/about/page.yml +0 -0
  49. /package/{templates/single → starter}/site/pages/home/1-welcome.md.hbs +0 -0
  50. /package/{templates/multi/sites/main → starter/site}/pages/home/page.yml +0 -0
  51. /package/templates/{multi/foundations/default → foundation}/src/styles.css +0 -0
  52. /package/templates/{multi/foundations/default → foundation}/vite.config.js +0 -0
  53. /package/templates/{multi/sites/main → site}/index.html.hbs +0 -0
  54. /package/templates/{multi/sites/main → site}/main.js +0 -0
  55. /package/templates/{multi/sites/main → site}/public/favicon.svg +0 -0
  56. /package/templates/{single/site → site}/vite.config.js +0 -0
  57. /package/templates/{_shared → workspace}/AGENTS.md.hbs +0 -0
  58. /package/templates/{single → workspace}/README.md.hbs +0 -0
  59. /package/templates/{single/template/.vscode → workspace/_vscode}/settings.json +0 -0
@@ -0,0 +1,563 @@
1
+ /**
2
+ * Add Command
3
+ *
4
+ * Adds foundations, sites, or extensions to an existing workspace.
5
+ *
6
+ * Usage:
7
+ * uniweb add foundation [name] [--from <template>] [--path <dir>] [--project <name>]
8
+ * uniweb add site [name] [--from <template>] [--foundation <name>] [--path <dir>] [--project <name>]
9
+ * uniweb add extension [name] [--from <template>] [--site <name>] [--path <dir>]
10
+ */
11
+
12
+ import { existsSync } from 'node:fs'
13
+ import { readFile, writeFile } from 'node:fs/promises'
14
+ import { join, relative } from 'node:path'
15
+ import prompts from 'prompts'
16
+ import yaml from 'js-yaml'
17
+ import { scaffoldFoundation, scaffoldSite, applyContent } from '../utils/scaffold.js'
18
+ import {
19
+ readWorkspaceConfig,
20
+ addWorkspaceGlob,
21
+ discoverFoundations,
22
+ discoverSites,
23
+ updateRootScripts,
24
+ } from '../utils/config.js'
25
+ import { findWorkspaceRoot } from '../utils/workspace.js'
26
+ import { resolveTemplate } from '../templates/index.js'
27
+ import { validateTemplate } from '../templates/validator.js'
28
+ import { getVersionsForTemplates } from '../versions.js'
29
+
30
+ // Colors for terminal output
31
+ const colors = {
32
+ reset: '\x1b[0m',
33
+ bright: '\x1b[1m',
34
+ dim: '\x1b[2m',
35
+ cyan: '\x1b[36m',
36
+ green: '\x1b[32m',
37
+ yellow: '\x1b[33m',
38
+ red: '\x1b[31m',
39
+ }
40
+
41
+ function log(message) { console.log(message) }
42
+ function success(message) { console.log(`${colors.green}✓${colors.reset} ${message}`) }
43
+ function error(message) { console.error(`${colors.red}✗${colors.reset} ${message}`) }
44
+ function info(message) { console.log(`${colors.dim}${message}${colors.reset}`) }
45
+
46
+ /**
47
+ * Parse add command arguments
48
+ */
49
+ function parseArgs(args) {
50
+ const result = {
51
+ subcommand: args[0], // foundation, site, extension
52
+ name: null,
53
+ path: null,
54
+ project: null,
55
+ foundation: null,
56
+ site: null,
57
+ from: null,
58
+ }
59
+
60
+ // Find positional name (first arg after subcommand that's not a flag)
61
+ for (let i = 1; i < args.length; i++) {
62
+ if (args[i].startsWith('--')) {
63
+ i++ // skip flag value
64
+ continue
65
+ }
66
+ if (!result.name) {
67
+ result.name = args[i]
68
+ }
69
+ }
70
+
71
+ // Parse flags
72
+ for (let i = 1; i < args.length; i++) {
73
+ if (args[i] === '--path' && args[i + 1]) {
74
+ result.path = args[++i]
75
+ } else if (args[i] === '--project' && args[i + 1]) {
76
+ result.project = args[++i]
77
+ } else if (args[i] === '--foundation' && args[i + 1]) {
78
+ result.foundation = args[++i]
79
+ } else if (args[i] === '--site' && args[i + 1]) {
80
+ result.site = args[++i]
81
+ } else if (args[i] === '--from' && args[i + 1]) {
82
+ result.from = args[++i]
83
+ }
84
+ }
85
+
86
+ return result
87
+ }
88
+
89
+ /**
90
+ * Main add command handler
91
+ */
92
+ export async function add(args) {
93
+ if (!args.length || args[0] === '--help' || args[0] === '-h') {
94
+ showAddHelp()
95
+ return
96
+ }
97
+
98
+ const parsed = parseArgs(args)
99
+
100
+ // Find workspace root
101
+ const rootDir = findWorkspaceRoot()
102
+ if (!rootDir) {
103
+ error('Not in a Uniweb workspace. Run this command from a project directory.')
104
+ error('Use "uniweb create" to create a new project first.')
105
+ process.exit(1)
106
+ }
107
+
108
+ // Read root package.json for project name
109
+ const rootPkg = JSON.parse(
110
+ await readFile(join(rootDir, 'package.json'), 'utf-8').catch(() => '{}')
111
+ )
112
+ const projectName = rootPkg.name || 'my-project'
113
+
114
+ switch (parsed.subcommand) {
115
+ case 'foundation':
116
+ await addFoundation(rootDir, projectName, parsed)
117
+ break
118
+ case 'site':
119
+ await addSite(rootDir, projectName, parsed)
120
+ break
121
+ case 'extension':
122
+ await addExtension(rootDir, projectName, parsed)
123
+ break
124
+ default:
125
+ error(`Unknown subcommand: ${parsed.subcommand}`)
126
+ log(`Valid subcommands: foundation, site, extension`)
127
+ process.exit(1)
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Add a foundation to the workspace
133
+ */
134
+ async function addFoundation(rootDir, projectName, opts) {
135
+ const name = opts.name
136
+ const target = await resolveFoundationTarget(rootDir, name, opts)
137
+ const fullPath = join(rootDir, target)
138
+
139
+ if (existsSync(fullPath)) {
140
+ error(`Directory already exists: ${target}`)
141
+ process.exit(1)
142
+ }
143
+
144
+ // Scaffold
145
+ await scaffoldFoundation(fullPath, {
146
+ name: name || 'foundation',
147
+ projectName,
148
+ isExtension: false,
149
+ }, {
150
+ onProgress: (msg) => info(` ${msg}`),
151
+ })
152
+
153
+ // Apply template content if --from specified
154
+ if (opts.from) {
155
+ await applyFromTemplate(opts.from, 'foundation', fullPath, projectName)
156
+ }
157
+
158
+ // Update workspace globs
159
+ const glob = computeGlob(target, 'foundation')
160
+ await addWorkspaceGlob(rootDir, glob)
161
+
162
+ // Update root scripts
163
+ const sites = await discoverSites(rootDir)
164
+ await updateRootScripts(rootDir, sites)
165
+
166
+ success(`Created foundation '${name || 'foundation'}' at ${target}/`)
167
+ log('')
168
+ log(`Next: ${colors.cyan}pnpm install${colors.reset}`)
169
+ }
170
+
171
+ /**
172
+ * Add a site to the workspace
173
+ */
174
+ async function addSite(rootDir, projectName, opts) {
175
+ const name = opts.name
176
+ const target = await resolveSiteTarget(rootDir, name, opts)
177
+ const fullPath = join(rootDir, target)
178
+
179
+ if (existsSync(fullPath)) {
180
+ error(`Directory already exists: ${target}`)
181
+ process.exit(1)
182
+ }
183
+
184
+ // Resolve foundation
185
+ const foundation = await resolveFoundation(rootDir, opts.foundation)
186
+ if (!foundation) {
187
+ error('No foundation found. Add a foundation first: uniweb add foundation')
188
+ process.exit(1)
189
+ }
190
+
191
+ // Compute relative path from site to foundation
192
+ const foundationPath = computeFoundationPath(target, foundation.path)
193
+ const siteName = name || 'site'
194
+
195
+ // Scaffold
196
+ await scaffoldSite(fullPath, {
197
+ name: siteName,
198
+ projectName,
199
+ foundationName: foundation.name,
200
+ foundationPath,
201
+ foundationRef: foundation.name,
202
+ }, {
203
+ onProgress: (msg) => info(` ${msg}`),
204
+ })
205
+
206
+ // Apply template content if --from specified
207
+ if (opts.from) {
208
+ await applyFromTemplate(opts.from, 'site', fullPath, projectName)
209
+ }
210
+
211
+ // Update workspace globs
212
+ const glob = computeGlob(target, 'site')
213
+ await addWorkspaceGlob(rootDir, glob)
214
+
215
+ // Update root scripts (discover sites after glob is added — includes the new one)
216
+ const sites = await discoverSites(rootDir)
217
+ // If the new site wasn't discovered (glob may not match yet), add it
218
+ if (!sites.find(s => s.path === target)) {
219
+ sites.push({ name: siteName, path: target })
220
+ }
221
+ await updateRootScripts(rootDir, sites)
222
+
223
+ success(`Created site '${siteName}' at ${target}/ → foundation '${foundation.name}'`)
224
+ log('')
225
+ log(`Next: ${colors.cyan}pnpm install && pnpm --filter ${siteName} dev${colors.reset}`)
226
+ }
227
+
228
+ /**
229
+ * Add an extension to the workspace
230
+ */
231
+ async function addExtension(rootDir, projectName, opts) {
232
+ const name = opts.name
233
+
234
+ if (!name) {
235
+ error('Extension name is required: uniweb add extension <name>')
236
+ process.exit(1)
237
+ }
238
+
239
+ // Determine target
240
+ let target
241
+ if (opts.path) {
242
+ target = opts.path
243
+ } else {
244
+ target = `extensions/${name}`
245
+ }
246
+
247
+ const fullPath = join(rootDir, target)
248
+
249
+ if (existsSync(fullPath)) {
250
+ error(`Directory already exists: ${target}`)
251
+ process.exit(1)
252
+ }
253
+
254
+ // Scaffold foundation with extension flag
255
+ await scaffoldFoundation(fullPath, {
256
+ name,
257
+ projectName,
258
+ isExtension: true,
259
+ }, {
260
+ onProgress: (msg) => info(` ${msg}`),
261
+ })
262
+
263
+ // Apply template content if --from specified
264
+ if (opts.from) {
265
+ await applyFromTemplate(opts.from, 'extension', fullPath, projectName)
266
+ }
267
+
268
+ // Update workspace globs
269
+ await addWorkspaceGlob(rootDir, 'extensions/*')
270
+
271
+ // Wire extension to site if specified (or only one site exists)
272
+ let wiredSite = null
273
+ if (opts.site) {
274
+ wiredSite = await wireExtensionToSite(rootDir, opts.site, name, target)
275
+ } else {
276
+ const sites = await discoverSites(rootDir)
277
+ if (sites.length === 1) {
278
+ wiredSite = await wireExtensionToSite(rootDir, sites[0].name, name, target)
279
+ }
280
+ }
281
+
282
+ // Update root scripts
283
+ const sites = await discoverSites(rootDir)
284
+ await updateRootScripts(rootDir, sites)
285
+
286
+ let msg = `Created extension '${name}' at ${target}/`
287
+ if (wiredSite) {
288
+ msg += ` → wired to site '${wiredSite}'`
289
+ }
290
+ success(msg)
291
+ log('')
292
+ log(`Next: ${colors.cyan}pnpm install${colors.reset}`)
293
+ }
294
+
295
+ /**
296
+ * Resolve placement for a foundation
297
+ */
298
+ async function resolveFoundationTarget(rootDir, name, opts) {
299
+ if (opts.path) return opts.path
300
+
301
+ if (opts.project) {
302
+ return `${opts.project}/foundation`
303
+ }
304
+
305
+ // Check existing layout
306
+ const { packages } = await readWorkspaceConfig(rootDir)
307
+ const hasColocated = packages.some(p => p.includes('*/foundation'))
308
+ const hasFoundationsGlob = packages.some(p => p.startsWith('foundations/'))
309
+ const hasSingleFoundation = existsSync(join(rootDir, 'foundation'))
310
+
311
+ if (hasColocated && opts.project) {
312
+ return `${opts.project}/foundation`
313
+ }
314
+
315
+ // No name and no foundations exist → ./foundation/
316
+ if (!name && !hasSingleFoundation && !hasFoundationsGlob) {
317
+ return 'foundation'
318
+ }
319
+
320
+ // Named foundation or existing foundation → ./foundations/{name}/
321
+ return `foundations/${name || 'foundation'}`
322
+ }
323
+
324
+ /**
325
+ * Resolve placement for a site
326
+ */
327
+ async function resolveSiteTarget(rootDir, name, opts) {
328
+ if (opts.path) return opts.path
329
+
330
+ if (opts.project) {
331
+ return `${opts.project}/site`
332
+ }
333
+
334
+ const { packages } = await readWorkspaceConfig(rootDir)
335
+ const hasColocated = packages.some(p => p.includes('*/site'))
336
+ const hasSitesGlob = packages.some(p => p.startsWith('sites/'))
337
+ const hasSingleSite = existsSync(join(rootDir, 'site'))
338
+
339
+ if (hasColocated && opts.project) {
340
+ return `${opts.project}/site`
341
+ }
342
+
343
+ // No name and no sites exist → ./site/
344
+ if (!name && !hasSingleSite && !hasSitesGlob) {
345
+ return 'site'
346
+ }
347
+
348
+ // Named site or existing site → ./sites/{name}/
349
+ return `sites/${name || 'site'}`
350
+ }
351
+
352
+ /**
353
+ * Resolve which foundation to wire a site to
354
+ */
355
+ async function resolveFoundation(rootDir, foundationFlag) {
356
+ const foundations = await discoverFoundations(rootDir)
357
+
358
+ if (foundationFlag) {
359
+ // Find by name
360
+ const found = foundations.find(f => f.name === foundationFlag)
361
+ if (found) return found
362
+
363
+ // Not found — could be a URL or new foundation
364
+ error(`Foundation '${foundationFlag}' not found in workspace.`)
365
+ log(`Available foundations: ${foundations.map(f => f.name).join(', ') || 'none'}`)
366
+ process.exit(1)
367
+ }
368
+
369
+ if (foundations.length === 0) {
370
+ return null
371
+ }
372
+
373
+ if (foundations.length === 1) {
374
+ info(`Using foundation: ${foundations[0].name}`)
375
+ return foundations[0]
376
+ }
377
+
378
+ // Multiple foundations — prompt
379
+ const response = await prompts({
380
+ type: 'select',
381
+ name: 'foundation',
382
+ message: 'Which foundation should this site use?',
383
+ choices: foundations.map(f => ({
384
+ title: f.name,
385
+ description: f.path,
386
+ value: f,
387
+ })),
388
+ }, {
389
+ onCancel: () => {
390
+ log('\nCancelled.')
391
+ process.exit(0)
392
+ },
393
+ })
394
+
395
+ return response.foundation
396
+ }
397
+
398
+ /**
399
+ * Compute the file: path from site to foundation
400
+ */
401
+ function computeFoundationPath(sitePath, foundationPath) {
402
+ // Compute relative path from site dir to foundation dir
403
+ const rel = relative(sitePath, foundationPath)
404
+ return `file:${rel}`
405
+ }
406
+
407
+ /**
408
+ * Compute the appropriate glob pattern for a target directory
409
+ */
410
+ function computeGlob(target, type) {
411
+ // e.g., "foundation" → "foundation"
412
+ // e.g., "foundations/marketing" → "foundations/*"
413
+ // e.g., "docs/foundation" → "*/foundation"
414
+ // e.g., "lib/mktg" → "lib/mktg"
415
+
416
+ const parts = target.split('/')
417
+
418
+ if (parts.length === 1) {
419
+ // Direct: "foundation", "site"
420
+ return target
421
+ }
422
+
423
+ if (parts.length === 2) {
424
+ // Could be "foundations/marketing" or "docs/foundation"
425
+ if (parts[1] === type) {
426
+ // Co-located: "docs/foundation" → "*/foundation"
427
+ return `*/${type}`
428
+ }
429
+ // Plural container: "foundations/marketing" → "foundations/*"
430
+ return `${parts[0]}/*`
431
+ }
432
+
433
+ // Custom path — return as-is
434
+ return target
435
+ }
436
+
437
+ /**
438
+ * Apply content from a template to a scaffolded package
439
+ *
440
+ * @param {string} templateId - Template identifier (official name, local path, npm, github)
441
+ * @param {string} packageType - 'foundation', 'site', or 'extension'
442
+ * @param {string} targetDir - Absolute path to the scaffolded package
443
+ * @param {string} projectName - Project name for template context
444
+ */
445
+ async function applyFromTemplate(templateId, packageType, targetDir, projectName) {
446
+ info(`Resolving template: ${templateId}...`)
447
+
448
+ const resolved = await resolveTemplate(templateId, {
449
+ onProgress: (msg) => info(` ${msg}`),
450
+ })
451
+
452
+ try {
453
+ const metadata = await validateTemplate(resolved.path, {})
454
+
455
+ // Look in contentDirs for matching package type
456
+ let contentDir = null
457
+ const match = metadata.contentDirs.find(d => d.type === packageType) ||
458
+ metadata.contentDirs.find(d => d.name === packageType)
459
+ if (match) {
460
+ contentDir = match.dir
461
+ }
462
+
463
+ if (contentDir) {
464
+ info(`Applying ${metadata.name} content...`)
465
+ await applyContent(contentDir, targetDir, {
466
+ projectName,
467
+ versions: getVersionsForTemplates(),
468
+ }, {
469
+ onProgress: (msg) => info(` ${msg}`),
470
+ })
471
+
472
+ // If site content applied, inform about expected section types
473
+ if (packageType === 'site' && metadata.components) {
474
+ log('')
475
+ info(`This template expects section types: ${metadata.components.join(', ')}`)
476
+ info(`Make sure your foundation provides them.`)
477
+ }
478
+ } else {
479
+ info(`Template '${metadata.name}' has no ${packageType} content to apply.`)
480
+ }
481
+ } finally {
482
+ if (resolved.cleanup) await resolved.cleanup()
483
+ }
484
+ }
485
+
486
+ /**
487
+ * Wire an extension URL to a site's site.yml
488
+ */
489
+ async function wireExtensionToSite(rootDir, siteName, extensionName, extensionPath) {
490
+ // Find the site directory
491
+ const sites = await discoverSites(rootDir)
492
+ const site = sites.find(s => s.name === siteName)
493
+ if (!site) {
494
+ info(`Could not find site '${siteName}' to wire extension.`)
495
+ return null
496
+ }
497
+
498
+ const siteYmlPath = join(rootDir, site.path, 'site.yml')
499
+ if (!existsSync(siteYmlPath)) {
500
+ info(`No site.yml found at ${site.path}`)
501
+ return null
502
+ }
503
+
504
+ try {
505
+ const content = await readFile(siteYmlPath, 'utf-8')
506
+ const config = yaml.load(content) || {}
507
+
508
+ // Add extension URL
509
+ const extensionUrl = `/${extensionPath}/dist/foundation.js`
510
+ if (!config.extensions) {
511
+ config.extensions = []
512
+ }
513
+ if (!config.extensions.includes(extensionUrl)) {
514
+ config.extensions.push(extensionUrl)
515
+ }
516
+
517
+ await writeFile(siteYmlPath, yaml.dump(config, { flowLevel: -1, quotingType: "'" }))
518
+ return siteName
519
+ } catch (err) {
520
+ info(`Warning: Could not update site.yml: ${err.message}`)
521
+ return null
522
+ }
523
+ }
524
+
525
+ /**
526
+ * Show help for the add command
527
+ */
528
+ function showAddHelp() {
529
+ log(`
530
+ ${colors.cyan}${colors.bright}Uniweb Add${colors.reset}
531
+
532
+ Add foundations, sites, or extensions to your workspace.
533
+
534
+ ${colors.bright}Usage:${colors.reset}
535
+ uniweb add foundation [name] [options]
536
+ uniweb add site [name] [options]
537
+ uniweb add extension <name> [options]
538
+
539
+ ${colors.bright}Common Options:${colors.reset}
540
+ --from <template> Apply content from a template after scaffolding
541
+ --path <dir> Custom directory for the package
542
+
543
+ ${colors.bright}Foundation Options:${colors.reset}
544
+ --project <name> Group under a project directory (co-located layout)
545
+
546
+ ${colors.bright}Site Options:${colors.reset}
547
+ --foundation <n> Foundation to wire to (prompted if multiple exist)
548
+ --project <name> Group under a project directory (co-located layout)
549
+
550
+ ${colors.bright}Extension Options:${colors.reset}
551
+ --site <name> Site to wire extension URL into
552
+
553
+ ${colors.bright}Examples:${colors.reset}
554
+ uniweb add foundation # Create ./foundation/
555
+ uniweb add foundation marketing # Create ./foundations/marketing/
556
+ uniweb add foundation marketing --from marketing # Scaffold + marketing sections
557
+ uniweb add site blog --foundation marketing # Create ./sites/blog/ wired to marketing
558
+ uniweb add site blog --from docs --foundation blog # Scaffold + docs pages
559
+ uniweb add extension effects --site site # Create ./extensions/effects/
560
+ uniweb add foundation --project docs # Create ./docs/foundation/ (co-located)
561
+ uniweb add site --project docs # Create ./docs/site/ (co-located)
562
+ `)
563
+ }
@@ -496,11 +496,27 @@ async function buildSite(projectDir, options = {}) {
496
496
  function isFoundation(dir) {
497
497
  // Primary: has foundation.js config
498
498
  if (existsSync(join(dir, 'src', 'foundation.js'))) return true
499
- // Fallback: has src/components/
499
+ // Fallback: has src/sections/
500
+ if (existsSync(join(dir, 'src', 'sections'))) return true
501
+ // Legacy fallback: has src/components/
500
502
  if (existsSync(join(dir, 'src', 'components'))) return true
501
503
  return false
502
504
  }
503
505
 
506
+ /**
507
+ * Check if a foundation directory declares extension: true in foundation.js
508
+ */
509
+ function isExtensionDir(dir) {
510
+ const filePath = join(dir, 'src', 'foundation.js')
511
+ if (!existsSync(filePath)) return false
512
+ try {
513
+ const content = readFileSync(filePath, 'utf8')
514
+ return /extension\s*:\s*true/.test(content)
515
+ } catch {
516
+ return false
517
+ }
518
+ }
519
+
504
520
  /**
505
521
  * Check if a directory is a site
506
522
  */
@@ -517,6 +533,7 @@ function isSite(dir) {
517
533
  */
518
534
  function discoverWorkspacePackages(workspaceDir) {
519
535
  const foundations = []
536
+ const extensions = []
520
537
  const sites = []
521
538
 
522
539
  // Check standard locations
@@ -553,7 +570,18 @@ function discoverWorkspacePackages(workspaceDir) {
553
570
  }
554
571
  }
555
572
 
556
- return { foundations, sites }
573
+ // Check extensions/*
574
+ const extensionsDir = join(workspaceDir, 'extensions')
575
+ if (existsSync(extensionsDir)) {
576
+ for (const name of readdirSync(extensionsDir)) {
577
+ const path = join(extensionsDir, name)
578
+ if (isFoundation(path)) {
579
+ extensions.push({ name, path })
580
+ }
581
+ }
582
+ }
583
+
584
+ return { foundations, extensions, sites }
557
585
  }
558
586
 
559
587
  /**
@@ -565,13 +593,14 @@ async function buildWorkspace(workspaceDir, options = {}) {
565
593
  log(`${colors.cyan}${colors.bright}Building workspace...${colors.reset}`)
566
594
  log('')
567
595
 
568
- const { foundations, sites } = discoverWorkspacePackages(workspaceDir)
596
+ const { foundations, extensions, sites } = discoverWorkspacePackages(workspaceDir)
569
597
 
570
- if (foundations.length === 0 && sites.length === 0) {
571
- error('No foundations or sites found in workspace')
598
+ if (foundations.length === 0 && extensions.length === 0 && sites.length === 0) {
599
+ error('No foundations, extensions, or sites found in workspace')
572
600
  log('')
573
601
  log('Expected structure:')
574
602
  log(' foundation/ or foundations/*/')
603
+ log(' extensions/*/')
575
604
  log(' site/ or sites/*/')
576
605
  process.exit(1)
577
606
  }
@@ -583,6 +612,14 @@ async function buildWorkspace(workspaceDir, options = {}) {
583
612
  log('')
584
613
  }
585
614
 
615
+ // Build extensions (they are foundations, but logged distinctly)
616
+ for (const extension of extensions) {
617
+ const label = isExtensionDir(extension.path) ? 'extension' : 'foundation'
618
+ log(`${colors.bright}[${extension.name}]${colors.reset} ${colors.dim}(${label})${colors.reset}`)
619
+ await buildFoundation(extension.path)
620
+ log('')
621
+ }
622
+
586
623
  // Build sites
587
624
  for (const site of sites) {
588
625
  log(`${colors.bright}[${site.name}]${colors.reset}`)
@@ -602,9 +639,15 @@ async function buildWorkspace(workspaceDir, options = {}) {
602
639
  }
603
640
 
604
641
  // Summary
642
+ const totalBuilt = foundations.length + extensions.length + sites.length
643
+ const parts = []
644
+ if (foundations.length > 0) parts.push(`${foundations.length} foundation(s)`)
645
+ if (extensions.length > 0) parts.push(`${extensions.length} extension(s)`)
646
+ if (sites.length > 0) parts.push(`${sites.length} site(s)`)
647
+
605
648
  log(`${colors.green}${colors.bright}Workspace build complete!${colors.reset}`)
606
649
  log('')
607
- log(`Built ${foundations.length} foundation(s) and ${sites.length} site(s)`)
650
+ log(`Built ${parts.join(', ')}`)
608
651
  }
609
652
 
610
653
  /**