uniweb 0.10.13 → 0.12.0

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.
@@ -16,6 +16,7 @@ import { readFile, writeFile, mkdir } from 'node:fs/promises'
16
16
  import { join, relative } from 'node:path'
17
17
  import prompts from 'prompts'
18
18
  import yaml from 'js-yaml'
19
+ import { resolveFoundationSrcPath } from '@uniweb/build'
19
20
  import { scaffoldFoundation, scaffoldSite, applyContent, applyStarter, mergeTemplateDependencies } from '../utils/scaffold.js'
20
21
  import {
21
22
  readWorkspaceConfig,
@@ -186,57 +187,45 @@ export async function add(rawArgs) {
186
187
  * Add a foundation to the workspace
187
188
  */
188
189
  async function addFoundation(rootDir, projectName, opts, pm = 'pnpm') {
189
- let name = opts.name
190
+ const name = opts.name
190
191
  const existingNames = await getExistingPackageNames(rootDir)
191
192
 
192
- // Reject reserved names (format + reserved check only collisions handled at package name level)
193
- if (name) {
194
- const valid = validatePackageName(name)
193
+ // Resolve placement first (path + package name) so we have everything we
194
+ // need to validate before scaffolding. Note: `name` here may be a bare
195
+ // name (`ui`) or a path (`foundations/ui`); resolvePlacement handles
196
+ // both. Format validation runs on the derived package name below, not
197
+ // on the raw input — slashes in the input are intentional path syntax.
198
+ const FOUNDATION_KIND = { defaultDir: 'src', defaultPkg: 'src', projectSub: 'src' }
199
+ const { relativePath, packageName } = resolvePlacement(rootDir, name, opts, FOUNDATION_KIND)
200
+ const fullPath = join(rootDir, relativePath)
201
+
202
+ // Validate the derived package name (format + reserved-name check). The
203
+ // auto-derived `src` default is grandfathered in (`src` IS reserved
204
+ // but `src` is the convention for "the package that lives in src/").
205
+ if (packageName !== 'src') {
206
+ const valid = validatePackageName(packageName)
195
207
  if (valid !== true) {
196
208
  error(valid)
197
209
  process.exit(1)
198
210
  }
199
211
  }
200
212
 
201
- // Interactive name prompt when name not provided and no --path
202
- if (!name && !opts.path) {
203
- if (!isNonInteractive(process.argv)) {
204
- const foundations = await discoverFoundations(rootDir)
205
- const hasDefault = foundations.length === 0 && !existsSync(join(rootDir, 'foundation'))
206
- const response = await prompts({
207
- type: 'text',
208
- name: 'name',
209
- message: 'Foundation name:',
210
- initial: hasDefault ? 'foundation' : undefined,
211
- validate: (value) => validatePackageName(value),
212
- }, {
213
- onCancel: () => {
214
- log('\nCancelled.')
215
- process.exit(0)
216
- },
217
- })
218
- // Only set name if user chose something other than the default —
219
- // null name tells resolveFoundationTarget to use default placement (./foundation/)
220
- if (!hasDefault || response.name !== 'foundation') {
221
- name = response.name
222
- }
223
- }
224
- // Non-interactive without name: defaults to 'foundation' — resolveFoundationTarget handles it
225
- }
226
-
227
- const target = await resolveFoundationTarget(rootDir, name, opts)
228
- const fullPath = join(rootDir, target)
229
-
213
+ // Collision check 1: target folder already exists.
230
214
  if (existsSync(fullPath)) {
231
- error(`Directory already exists: ${target}`)
215
+ error(`Cannot create foundation: ${colors.bright}${relativePath}/${colors.reset} already exists.`)
216
+ log('')
217
+ log(`Pick a different name, or pass --path to choose a different folder:`)
218
+ log(` ${colors.cyan}${getCliPrefix()} add foundation <name>${colors.reset}`)
219
+ log(` ${colors.cyan}${getCliPrefix()} add foundation <name> --path <parent-dir>${colors.reset}`)
232
220
  process.exit(1)
233
221
  }
234
222
 
235
- // Package name = name or 'foundation'
236
- const packageName = name || 'foundation'
223
+ // Collision check 2: a package with the same name already exists somewhere
224
+ // in the workspace.
237
225
  if (existingNames.has(packageName)) {
238
- error(`Package name '${packageName}' already exists in this workspace.`)
239
- log(`Choose a different name: ${getCliPrefix()} add foundation <name>`)
226
+ error(`Cannot create foundation: a package named ${colors.bright}${packageName}${colors.reset} already exists in this workspace.`)
227
+ log(`Pick a different name:`)
228
+ log(` ${colors.cyan}${getCliPrefix()} add foundation <other-name>${colors.reset}`)
240
229
  process.exit(1)
241
230
  }
242
231
 
@@ -254,15 +243,16 @@ async function addFoundation(rootDir, projectName, opts, pm = 'pnpm') {
254
243
  await applyFromTemplate(opts.from, 'foundation', fullPath, projectName)
255
244
  }
256
245
 
257
- // Update workspace globs
258
- const glob = computeGlob(target, 'foundation')
259
- await addWorkspaceGlob(rootDir, glob)
246
+ // Register the package in pnpm-workspace.yaml — by exact path, not by glob.
247
+ // No glob inference: if the user wants `foundations/*` they can edit the
248
+ // workspace file themselves. This matches the "no assumptions" rule.
249
+ await addWorkspaceGlob(rootDir, relativePath)
260
250
 
261
251
  // Update root scripts
262
252
  const sites = await discoverSites(rootDir)
263
253
  await updateRootScripts(rootDir, sites, pm)
264
254
 
265
- success(`Created foundation '${packageName}' at ${target}/`)
255
+ success(`Created foundation ${colors.bright}${packageName}${colors.reset} at ${relativePath}/`)
266
256
  log('')
267
257
  log(`Next: ${colors.cyan}${installCmd(pm)}${colors.reset}`)
268
258
  }
@@ -271,66 +261,47 @@ async function addFoundation(rootDir, projectName, opts, pm = 'pnpm') {
271
261
  * Add a site to the workspace
272
262
  */
273
263
  async function addSite(rootDir, projectName, opts, pm = 'pnpm') {
274
- let name = opts.name
264
+ const name = opts.name
275
265
  const existingNames = await getExistingPackageNames(rootDir)
276
266
 
277
- // Reject reserved names (format + reserved check only collisions handled at package name level)
278
- if (name) {
279
- const valid = validatePackageName(name)
267
+ // Resolve placement first (path + package name); see notes in addFoundation.
268
+ const SITE_KIND = { defaultDir: 'site', defaultPkg: 'site', projectSub: 'site' }
269
+ const { relativePath, packageName: siteName } = resolvePlacement(rootDir, name, opts, SITE_KIND)
270
+ const fullPath = join(rootDir, relativePath)
271
+
272
+ // Validate the package name (skip for the auto-derived 'site' default).
273
+ if (siteName !== 'site') {
274
+ const valid = validatePackageName(siteName)
280
275
  if (valid !== true) {
281
276
  error(valid)
282
277
  process.exit(1)
283
278
  }
284
279
  }
285
280
 
286
- // Interactive name prompt when name not provided and no --path
287
- if (!name && !opts.path) {
288
- if (!isNonInteractive(process.argv)) {
289
- const existingSites = await discoverSites(rootDir)
290
- const hasDefault = existingSites.length === 0 && !existsSync(join(rootDir, 'site'))
291
- const response = await prompts({
292
- type: 'text',
293
- name: 'name',
294
- message: 'Site name:',
295
- initial: hasDefault ? 'site' : undefined,
296
- validate: (value) => validatePackageName(value),
297
- }, {
298
- onCancel: () => {
299
- log('\nCancelled.')
300
- process.exit(0)
301
- },
302
- })
303
- // Only set name if user chose something other than the default —
304
- // null name tells resolveSiteTarget to use default placement (./site/)
305
- if (!hasDefault || response.name !== 'site') {
306
- name = response.name
307
- }
308
- }
309
- // Non-interactive without name: defaults to 'site' — resolveSiteTarget handles it
310
- }
311
-
312
- const target = await resolveSiteTarget(rootDir, name, opts)
313
- const fullPath = join(rootDir, target)
314
-
281
+ // Collision check 1: target folder exists.
315
282
  if (existsSync(fullPath)) {
316
- error(`Directory already exists: ${target}`)
283
+ error(`Cannot create site: ${colors.bright}${relativePath}/${colors.reset} already exists.`)
284
+ log('')
285
+ log(`Pick a different name, or pass --path to choose a different folder:`)
286
+ log(` ${colors.cyan}${getCliPrefix()} add site <name>${colors.reset}`)
287
+ log(` ${colors.cyan}${getCliPrefix()} add site <name> --path <parent-dir>${colors.reset}`)
317
288
  process.exit(1)
318
289
  }
319
290
 
320
- // Resolve foundation
321
- const foundation = await resolveFoundation(rootDir, opts.foundation)
322
-
323
- // Package name = name or 'site'
324
- const siteName = name || 'site'
291
+ // Collision check 2: package name already in workspace.
325
292
  if (existingNames.has(siteName)) {
326
- error(`Package name '${siteName}' already exists in this workspace.`)
327
- log(`Choose a different name: ${getCliPrefix()} add site <name>`)
293
+ error(`Cannot create site: a package named ${colors.bright}${siteName}${colors.reset} already exists in this workspace.`)
294
+ log(`Pick a different name:`)
295
+ log(` ${colors.cyan}${getCliPrefix()} add site <other-name>${colors.reset}`)
328
296
  process.exit(1)
329
297
  }
330
298
 
299
+ // Resolve foundation
300
+ const foundation = await resolveFoundation(rootDir, opts.foundation)
301
+
331
302
  if (foundation) {
332
303
  // Compute relative path from site to foundation
333
- const foundationPath = computeFoundationPath(target, foundation.path)
304
+ const foundationPath = computeFoundationPath(relativePath, foundation.path)
334
305
 
335
306
  // Scaffold
336
307
  await scaffoldSite(fullPath, {
@@ -360,28 +331,26 @@ async function addSite(rootDir, projectName, opts, pm = 'pnpm') {
360
331
  await applyFromTemplate(opts.from, 'site', fullPath, projectName)
361
332
  }
362
333
 
363
- // Update workspace globs
364
- const glob = computeGlob(target, 'site')
365
- await addWorkspaceGlob(rootDir, glob)
334
+ // Register the package by exact path. No glob inference.
335
+ await addWorkspaceGlob(rootDir, relativePath)
366
336
 
367
- // Update root scripts (discover sites after glob is added — includes the new one)
337
+ // Update root scripts (discover sites after registration — includes the new one)
368
338
  const sites = await discoverSites(rootDir)
369
- // If the new site wasn't discovered (glob may not match yet), add it
370
- if (!sites.find(s => s.path === target)) {
371
- sites.push({ name: siteName, path: target })
339
+ if (!sites.find(s => s.path === relativePath)) {
340
+ sites.push({ name: siteName, path: relativePath })
372
341
  }
373
342
  await updateRootScripts(rootDir, sites, pm)
374
343
 
375
344
  if (foundation) {
376
- success(`Created site '${siteName}' at ${target}/ → foundation '${foundation.name}'`)
345
+ success(`Created site ${colors.bright}${siteName}${colors.reset} at ${relativePath}/ → foundation '${foundation.name}'`)
377
346
  } else {
378
- success(`Created site '${siteName}' at ${target}/`)
347
+ success(`Created site ${colors.bright}${siteName}${colors.reset} at ${relativePath}/`)
379
348
  }
380
349
  log('')
381
350
  log(`Next: ${colors.cyan}${installCmd(pm)} && ${filterCmd(pm, siteName, 'dev')}${colors.reset}`)
382
351
  if (!opts.from) {
383
352
  log('')
384
- log(`${colors.dim}To add your first page, create ${target}/pages/home/page.yml and a .md file.${colors.reset}`)
353
+ log(`${colors.dim}To add your first page, create ${relativePath}/pages/home/page.yml and a .md file.${colors.reset}`)
385
354
  log(`${colors.dim}Or use --from to start with template content: uniweb add site --from starter${colors.reset}`)
386
355
  }
387
356
  }
@@ -530,8 +499,11 @@ async function addProject(rootDir, projectName, opts, pm = 'pnpm') {
530
499
  process.exit(1)
531
500
  }
532
501
 
533
- // Compute package names
534
- const foundationPkgName = `${name}-foundation`
502
+ // Compute package names. Co-located projects use the `-src` / `-site`
503
+ // suffix convention so package names are unique within the workspace.
504
+ // The folder structure inside the project is `src/` + `site/`, mirroring
505
+ // the single-project default layout.
506
+ const foundationPkgName = `${name}-src`
535
507
  const sitePkgName = `${name}-site`
536
508
 
537
509
  // Check package name collisions
@@ -544,9 +516,9 @@ async function addProject(rootDir, projectName, opts, pm = 'pnpm') {
544
516
 
545
517
  const progressCb = (msg) => info(` ${msg}`)
546
518
 
547
- // Scaffold foundation
519
+ // Scaffold foundation (folder: src/, package name: <project>-src)
548
520
  info(`Creating foundation: ${foundationPkgName}...`)
549
- await scaffoldFoundation(join(projectDir, 'foundation'), {
521
+ await scaffoldFoundation(join(projectDir, 'src'), {
550
522
  name: foundationPkgName,
551
523
  projectName,
552
524
  isExtension: false,
@@ -558,18 +530,18 @@ async function addProject(rootDir, projectName, opts, pm = 'pnpm') {
558
530
  name: sitePkgName,
559
531
  projectName,
560
532
  foundationName: foundationPkgName,
561
- foundationPath: 'file:../foundation',
533
+ foundationPath: 'file:../src',
562
534
  foundationRef: foundationPkgName,
563
535
  }, { onProgress: progressCb })
564
536
 
565
537
  // Apply template content if --from specified
566
538
  if (opts.from) {
567
- await applyFromTemplate(opts.from, 'foundation', join(projectDir, 'foundation'), projectName)
539
+ await applyFromTemplate(opts.from, 'foundation', join(projectDir, 'src'), projectName)
568
540
  await applyFromTemplate(opts.from, 'site', join(projectDir, 'site'), projectName)
569
541
  }
570
542
 
571
543
  // Update workspace globs for co-located layout
572
- await addWorkspaceGlob(rootDir, '*/foundation')
544
+ await addWorkspaceGlob(rootDir, '*/src')
573
545
  await addWorkspaceGlob(rootDir, '*/site')
574
546
 
575
547
  // Update root scripts
@@ -580,124 +552,96 @@ async function addProject(rootDir, projectName, opts, pm = 'pnpm') {
580
552
  await updateRootScripts(rootDir, sites, pm)
581
553
 
582
554
  success(`Created project '${name}' at ${name}/`)
583
- log(` ${colors.dim}Foundation: ${name}/foundation/ (${foundationPkgName})${colors.reset}`)
555
+ log(` ${colors.dim}Foundation: ${name}/src/ (${foundationPkgName})${colors.reset}`)
584
556
  log(` ${colors.dim}Site: ${name}/site/ (${sitePkgName})${colors.reset}`)
585
557
  log('')
586
558
  log(`Next: ${colors.cyan}${installCmd(pm)} && ${filterCmd(pm, sitePkgName, 'dev')}${colors.reset}`)
587
559
  }
588
560
 
589
561
  /**
590
- * Resolve placement for a foundation
562
+ * Resolve where a foundation or site should be placed, given the user's input.
591
563
  *
592
- * Rules:
593
- * - --path: use it directly
594
- * - --project: {project}/foundation (co-located)
595
- * - Existing co-located glob: follow pattern
596
- * - Existing segregated glob: follow pattern
597
- * - Named (e.g., "marketing"): segregated at foundations/{name}
598
- * - Unnamed: root-level 'foundation'
599
- * - Already have one: error in non-interactive, ask in interactive
600
- */
601
- async function resolveFoundationTarget(rootDir, name, opts) {
602
- if (opts.path) return opts.path
603
-
604
- if (opts.project) {
605
- return `${opts.project}/foundation`
606
- }
607
-
608
- // Check existing layout
609
- const { packages } = await readWorkspaceConfig(rootDir)
610
- const hasColocated = packages.some(p => p.includes('*/foundation'))
611
- const hasFoundationsGlob = packages.some(p => p.startsWith('foundations/'))
612
-
613
- // Respect existing co-located layout
614
- if (hasColocated && name) {
615
- return `${name}/foundation`
616
- }
617
-
618
- // Respect existing segregated layout
619
- if (hasFoundationsGlob) {
620
- return `foundations/${name || 'foundation'}`
621
- }
622
-
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'
630
-
631
- // Check if target already exists
632
- if (!existsSync(join(rootDir, dirName))) {
633
- return dirName
634
- }
635
-
636
- // Already have one at the target path — error with guidance
637
- if (isNonInteractive(process.argv)) {
638
- error(`Directory '${dirName}' already exists.`)
639
- log(`\nTo add another foundation, specify a name:`)
640
- log(` ${getCliPrefix()} add foundation <name>`)
641
- log(`\nOr use --path for explicit placement:`)
642
- log(` ${getCliPrefix()} add foundation --path <dir>`)
643
- process.exit(1)
644
- }
645
-
646
- // Interactive: the existsSync check in addFoundation will catch it
647
- return dirName
648
- }
649
-
650
- /**
651
- * Resolve placement for a site
564
+ * The rule: **the user names a folder, and we create exactly that folder.**
565
+ * No silent nesting under `foundations/` / `sites/`, no inferring layout from
566
+ * pre-existing globs. The framework doesn't require any particular folder
567
+ * structure (the build classifies packages by their contents, not their
568
+ * location), so the CLI shouldn't impose one.
569
+ *
570
+ * Resolution priority (foundation example, same shape for site):
571
+ *
572
+ * 1. `--path <dir>` → explicit folder. Name is the path's
573
+ * last segment (used as the package
574
+ * name unless `name` was also given).
575
+ * 2. `name` contains `/` → treat as a path (e.g., `foundations/ui`).
576
+ * Folder = the path, package name =
577
+ * the last segment.
578
+ * 3. `name` (no slash) → folder = `<name>/`, package name = `<name>`.
579
+ * 4. `--project <project>` → folder = `<project>/<defaultSub>` and
580
+ * package name = `<project>-<defaultSub>`
581
+ * (the co-located convention; only this
582
+ * one uses the `-src` / `-site` suffix).
583
+ * 5. (no input) → folder = `<defaultDir>/`, package name
584
+ * = `<defaultPkg>` (`src/` + `src`
585
+ * for foundations; `site/` + `site` for
586
+ * sites).
652
587
  *
653
- * Same rules as resolveFoundationTarget, adapted for sites.
588
+ * @param {string} rootDir
589
+ * @param {string|null} name - Either a bare name or a path-with-slash.
590
+ * @param {{ path?: string, project?: string }} opts
591
+ * @param {{ defaultDir: string, defaultPkg: string, projectSub: string }} kind
592
+ * @returns {{ relativePath: string, packageName: string }}
654
593
  */
655
- async function resolveSiteTarget(rootDir, name, opts) {
656
- if (opts.path) return opts.path
657
-
658
- if (opts.project) {
659
- return `${opts.project}/site`
660
- }
661
-
662
- const { packages } = await readWorkspaceConfig(rootDir)
663
- const hasColocated = packages.some(p => p.includes('*/site'))
664
- const hasSitesGlob = packages.some(p => p.startsWith('sites/'))
665
-
666
- // Respect existing co-located layout
667
- if (hasColocated && name) {
668
- return `${name}/site`
594
+ function resolvePlacement(rootDir, name, opts, kind) {
595
+ // 1. --path is a PARENT directory. The folder is `<path>/<name>` if a
596
+ // name was given, or `<path>` itself if not (the path's last segment
597
+ // is then taken as the package name).
598
+ if (opts.path) {
599
+ const parent = opts.path.replace(/\/+$/, '')
600
+ if (name) {
601
+ const last = name.split('/').filter(Boolean).pop()
602
+ return {
603
+ relativePath: `${parent}/${name}`.replace(/\/+/g, '/'),
604
+ packageName: last,
605
+ }
606
+ }
607
+ const lastSegment = parent.split('/').filter(Boolean).pop() || parent
608
+ return {
609
+ relativePath: parent,
610
+ packageName: lastSegment,
611
+ }
669
612
  }
670
613
 
671
- // Respect existing segregated layout
672
- if (hasSitesGlob) {
673
- return `sites/${name || 'site'}`
614
+ // 2. name contains a slash → treat as a path.
615
+ if (name && name.includes('/')) {
616
+ const relativePath = name.replace(/\/+$/, '')
617
+ const lastSegment = relativePath.split('/').filter(Boolean).pop()
618
+ return {
619
+ relativePath,
620
+ packageName: lastSegment,
621
+ }
674
622
  }
675
623
 
676
- // Named site → segregated layout (sites/{name})
624
+ // 3. Bare name.
677
625
  if (name) {
678
- return `sites/${name}`
626
+ return {
627
+ relativePath: name,
628
+ packageName: name,
629
+ }
679
630
  }
680
631
 
681
- // Unnamed root-level 'site'
682
- const dirName = 'site'
683
-
684
- // Check if target already exists
685
- if (!existsSync(join(rootDir, dirName))) {
686
- return dirName
632
+ // 4. --project (co-located convention with -src / -site suffix).
633
+ if (opts.project) {
634
+ return {
635
+ relativePath: `${opts.project}/${kind.projectSub}`,
636
+ packageName: `${opts.project}-${kind.projectSub}`,
637
+ }
687
638
  }
688
639
 
689
- // Already have one at the target path — error with guidance
690
- if (isNonInteractive(process.argv)) {
691
- error(`Directory '${dirName}' already exists.`)
692
- log(`\nTo add another site, specify a name:`)
693
- log(` ${getCliPrefix()} add site <name>`)
694
- log(`\nOr use --path for explicit placement:`)
695
- log(` ${getCliPrefix()} add site --path <dir>`)
696
- process.exit(1)
640
+ // 5. Default placement.
641
+ return {
642
+ relativePath: kind.defaultDir,
643
+ packageName: kind.defaultPkg,
697
644
  }
698
-
699
- // Interactive: the existsSync check in addSite will catch it
700
- return dirName
701
645
  }
702
646
 
703
647
  /**
@@ -766,35 +710,6 @@ function computeFoundationPath(sitePath, foundationPath) {
766
710
  return `file:${rel}`
767
711
  }
768
712
 
769
- /**
770
- * Compute the appropriate glob pattern for a target directory
771
- */
772
- function computeGlob(target, type) {
773
- // e.g., "foundation" → "foundation"
774
- // e.g., "foundations/marketing" → "foundations/*"
775
- // e.g., "docs/foundation" → "*/foundation"
776
- // e.g., "lib/mktg" → "lib/mktg"
777
-
778
- const parts = target.split('/')
779
-
780
- if (parts.length === 1) {
781
- // Direct: "foundation", "site"
782
- return target
783
- }
784
-
785
- if (parts.length === 2) {
786
- // Could be "foundations/marketing" or "docs/foundation"
787
- if (parts[1] === type) {
788
- // Co-located: "docs/foundation" → "*/foundation"
789
- return `*/${type}`
790
- }
791
- // Plural container: "foundations/marketing" → "foundations/*"
792
- return `${parts[0]}/*`
793
- }
794
-
795
- // Custom path — return as-is
796
- return target
797
- }
798
713
 
799
714
  /**
800
715
  * Apply content from a template to a scaffolded package
@@ -965,12 +880,16 @@ async function addSection(rootDir, opts) {
965
880
  foundation = response.foundation
966
881
  }
967
882
 
968
- // Resolve sections directory
969
- const sectionsDir = join(rootDir, foundation.path, 'src', 'sections')
883
+ // Resolve sections directory — source root comes from package.json::main
884
+ // (works for both nested `src/` layouts and flat layouts).
885
+ const foundationDir = join(rootDir, foundation.path)
886
+ const foundationSrc = resolveFoundationSrcPath(foundationDir)
887
+ const sectionsDir = join(foundationSrc, 'sections')
970
888
  const sectionDir = join(sectionsDir, name)
889
+ const relSectionPath = relative(foundationDir, sectionDir)
971
890
 
972
891
  if (existsSync(sectionDir)) {
973
- error(`Section '${name}' already exists at ${foundation.path}/src/sections/${name}/`)
892
+ error(`Section '${name}' already exists at ${foundation.path}/${relSectionPath}/`)
974
893
  process.exit(1)
975
894
  }
976
895
 
@@ -1015,7 +934,7 @@ export default function ${name}({ content, params }) {
1015
934
  await writeFile(join(sectionDir, 'index.jsx'), componentContent)
1016
935
  await writeFile(join(sectionDir, 'meta.js'), metaContent)
1017
936
 
1018
- success(`Created section '${name}' at ${foundation.path}/src/sections/${name}/`)
937
+ success(`Created section '${name}' at ${foundation.path}/${relSectionPath}/`)
1019
938
  log(` ${colors.dim}index.jsx${colors.reset} — component (customize the JSX)`)
1020
939
  log(` ${colors.dim}meta.js${colors.reset} — metadata (add content expectations, params, presets)`)
1021
940
  if (foundations.length === 1) {
@@ -20,6 +20,9 @@ import { createRequire } from 'node:module'
20
20
  import {
21
21
  generateEntryPoint,
22
22
  discoverComponents,
23
+ resolveFoundationSrcPath,
24
+ classifyPackage,
25
+ isExtensionPackage,
23
26
  } from '@uniweb/build'
24
27
  import { readSiteConfig } from '@uniweb/build/site'
25
28
  import { readWorkspaceConfig, resolveGlob } from '../utils/config.js'
@@ -62,30 +65,15 @@ function info(message) {
62
65
  * 5. pnpm-workspace.yaml or package.json workspaces → workspace
63
66
  */
64
67
  function detectProjectType(projectDir) {
65
- // Primary detection: config files
66
- if (existsSync(join(projectDir, 'src', 'foundation.js'))) {
67
- return 'foundation'
68
- }
69
-
70
- if (existsSync(join(projectDir, 'site.yml'))) {
71
- return 'site'
72
- }
73
-
74
- // Fallback detection: directory structure
75
- if (existsSync(join(projectDir, 'src', 'components'))) {
76
- return 'foundation'
77
- }
68
+ // Foundation vs site: delegate to the canonical classifier in @uniweb/build.
69
+ const kind = classifyPackage(projectDir)
70
+ if (kind) return kind
78
71
 
79
- if (existsSync(join(projectDir, 'pages'))) {
80
- return 'site'
81
- }
82
-
83
- // Workspace: has pnpm-workspace.yaml or package.json with workspaces
72
+ // Workspace: has pnpm-workspace.yaml or package.json with workspaces field.
84
73
  if (existsSync(join(projectDir, 'pnpm-workspace.yaml'))) {
85
74
  return 'workspace'
86
75
  }
87
76
 
88
- // Check package.json for workspaces field
89
77
  const packageJsonPath = join(projectDir, 'package.json')
90
78
  if (existsSync(packageJsonPath)) {
91
79
  try {
@@ -168,7 +156,7 @@ async function runLocalVite(projectDir, args) {
168
156
  * Build a foundation
169
157
  */
170
158
  async function buildFoundation(projectDir, options = {}) {
171
- const srcDir = join(projectDir, 'src')
159
+ const srcDir = resolveFoundationSrcPath(projectDir)
172
160
 
173
161
  info('Building foundation...')
174
162
 
@@ -180,7 +168,7 @@ async function buildFoundation(projectDir, options = {}) {
180
168
 
181
169
  if (componentNames.length === 0) {
182
170
  error('No components found with meta.js files')
183
- error('Make sure components are in src/components/[Name]/ with a meta.js file')
171
+ error(`Make sure components are in ${srcDir}/components/[Name]/ with a meta.js file`)
184
172
  process.exit(1)
185
173
  }
186
174
 
@@ -504,38 +492,22 @@ async function buildSite(projectDir, options = {}) {
504
492
  * Check if a directory is a foundation
505
493
  */
506
494
  function isFoundation(dir) {
507
- // Primary: has foundation.js config
508
- if (existsSync(join(dir, 'src', 'foundation.js'))) return true
509
- // Fallback: has src/sections/
510
- if (existsSync(join(dir, 'src', 'sections'))) return true
511
- // Legacy fallback: has src/components/
512
- if (existsSync(join(dir, 'src', 'components'))) return true
513
- return false
495
+ return classifyPackage(dir) === 'foundation'
514
496
  }
515
497
 
516
498
  /**
517
- * Check if a foundation directory declares extension: true in foundation.js
499
+ * Check if a foundation directory declares extension: true in its
500
+ * authored declarations file (main.js or legacy foundation.js).
518
501
  */
519
502
  function isExtensionDir(dir) {
520
- const filePath = join(dir, 'src', 'foundation.js')
521
- if (!existsSync(filePath)) return false
522
- try {
523
- const content = readFileSync(filePath, 'utf8')
524
- return /extension\s*:\s*true/.test(content)
525
- } catch {
526
- return false
527
- }
503
+ return isExtensionPackage(dir)
528
504
  }
529
505
 
530
506
  /**
531
507
  * Check if a directory is a site
532
508
  */
533
509
  function isSite(dir) {
534
- // Primary: has site.yml config
535
- if (existsSync(join(dir, 'site.yml'))) return true
536
- // Fallback: has pages/
537
- if (existsSync(join(dir, 'pages'))) return true
538
- return false
510
+ return classifyPackage(dir) === 'site'
539
511
  }
540
512
 
541
513
  /**