uniweb 0.12.3 → 0.12.5

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
@@ -14,8 +14,8 @@ The interactive prompt asks for a project name and template. Pick one, then:
14
14
 
15
15
  ```bash
16
16
  cd my-project
17
- npm install
18
- npm run dev
17
+ pnpm install # or npm install
18
+ pnpm dev # or npm run dev
19
19
  ```
20
20
 
21
21
  Edit files in `site/pages/` and `src/sections/` to see changes instantly.
@@ -46,17 +46,17 @@ Or skip the interactive prompt:
46
46
  pnpm create uniweb my-site --template docs
47
47
  ```
48
48
 
49
- ### Development Commands
49
+ ### Local Scripts
50
50
 
51
51
  Run these from the **project root**:
52
52
 
53
53
  ```bash
54
- npm run dev # Start development server
55
- npm run build # Build foundation + site for production
56
- npm run preview # Preview the production build
54
+ pnpm dev # Start development server
55
+ pnpm build # Build foundation + site for production
56
+ pnpm preview # Preview the production build
57
57
  ```
58
58
 
59
- The `build` command outputs to `site/dist/`. With pre-rendering enabled (the default for official templates), you get static HTML files ready to deploy anywhere.
59
+ The `build` command outputs to `site/dist/`. With pre-rendering enabled (the default for official templates), you get static HTML files ready to deploy anywhere. For the actual deploy step (and the `uniweb publish` / `uniweb deploy` commands), see [Deployment](#deployment) below.
60
60
 
61
61
  ## What You Get
62
62
 
@@ -243,14 +243,13 @@ The parser extracts semantic elements from markdown—`title` from the first hea
243
243
 
244
244
  ## Foundations Are Portable
245
245
 
246
- The `src/` folder (your project's foundation) ships with your project as a convenience, but a foundation is a self-contained artifact with no dependency on any specific site. Sites reference foundations by configuration, not by folder proximity.
246
+ The `src/` folder (your project's foundation) ships with your project as a convenience, but a foundation is a dynamically linked module (DML) with no dependency on any specific site. Sites reference foundations by configuration, not by folder proximity.
247
247
 
248
- **Three ways to use a foundation:**
248
+ **Two ways to use a foundation:**
249
249
 
250
250
  | Mode | How it works | Best for |
251
251
  | ---------------- | ---------------------------------- | -------------------------------------------------- |
252
252
  | **Local folder** | Foundation lives in your workspace | Developing site and components together |
253
- | **npm package** | `npm add @acme/foundation` | Distributing via standard package tooling |
254
253
  | **Runtime link** | Foundation loads from a URL | Independent release cycles, platform-managed sites |
255
254
 
256
255
  You can delete the `src/` folder entirely and point your site at a published foundation. Or develop a foundation locally, then publish it for other sites to consume. The site doesn't care where its components come from.
@@ -259,7 +258,7 @@ You can delete the `src/` folder entirely and point your site at a published fou
259
258
 
260
259
  _Site-first_ — You're building a website. The foundation is your component library, co-developed with the site. This is the common case.
261
260
 
262
- _Foundation-first_ — You're building a component system. The site is a test harness with sample content. The real sites live elsewhere—other repositories, other teams, or managed on [hub.uniweb.app](https://hub.uniweb.app). Use `uniweb add site` to add multiple test sites exercising a shared foundation.
261
+ _Foundation-first_ — You're building a component system. The site is a test harness with sample content. The real sites live elsewhere—other repositories, other teams, or managed on [uniweb.app](https://uniweb.app). Use `uniweb add site` to add multiple test sites exercising a shared foundation.
263
262
 
264
263
  ## Growing Your Project
265
264
 
@@ -301,14 +300,25 @@ The structure you start with scales without rewrites:
301
300
 
302
301
  1. **Single project** — One site, one foundation. Develop and deploy together. Most projects stay here.
303
302
 
304
- 2. **Published foundation** — Release your foundation as an npm package or to [hub.uniweb.app](https://hub.uniweb.app). Other sites can use it without copying code.
303
+ 2. **Published foundation** — Release your foundation as a dynamically linked module to [uniweb.app](https://uniweb.app). Other sites can use it without copying code.
305
304
 
306
305
  3. **Multiple sites** — Several sites share one foundation. Update components once, every site benefits.
307
306
 
308
- 4. **Platform-managed sites** — Sites built on [hub.uniweb.app](https://hub.uniweb.app) with visual editing tools can use your foundation. You develop components locally; content teams work in the browser.
307
+ 4. **Platform-managed sites** — Sites built on [uniweb.app](https://uniweb.app) with visual editing tools can use your foundation. You develop components locally; content teams work in the browser.
309
308
 
310
309
  Start with local files deployed anywhere. The same foundation works across all these scenarios.
311
310
 
311
+ ## Deployment
312
+
313
+ A Uniweb project produces two artifacts — a **site** (content) and a **foundation** (code) — and they don't have to ship together. That opens up deployment options other frameworks can't express:
314
+
315
+ - **Bundled mode** — site and foundation built into one self-contained `dist/`, deployed to any static host.
316
+ - **Linked mode** — the foundation lives in any host and the site in any other host; different sites can dynamically link with the same foundation. Update the foundation, every site picks it up — no site rebuilds.
317
+
318
+ Two verbs handle it: `uniweb publish` sends a foundation to a registry, `uniweb deploy` sends a site to a host. Most projects start bundled (one command, one destination) and grow into linked mode by changing one line in `site.yml`. Mix providers freely — foundation on GitHub Pages, site on Vercel; or use Uniweb's registry + hosting for propagation, gated rollouts, and edge SSR.
319
+
320
+ → **[Deploying](https://github.com/uniweb/docs/blob/main/development/deploying.md)** — the full menu: bundled vs linked, the two-verb model, one-foundation-many-sites, optimized hosting on the Uniweb platform, and recipes for other hosting services.
321
+
312
322
  ---
313
323
 
314
324
  ## Documentation
@@ -331,7 +341,7 @@ Full documentation is available at **[github.com/uniweb/docs](https://github.com
331
341
  | Site Configuration | [site.yml reference](https://github.com/uniweb/docs/blob/main/reference/site-configuration.md) |
332
342
  | CLI Commands | [create, add, build, docs, doctor, i18n](https://github.com/uniweb/docs/blob/main/reference/cli-commands.md) |
333
343
  | Templates | [Built-in, official, and external templates](https://github.com/uniweb/docs/blob/main/getting-started/templates.md) |
334
- | Deployment | [Vercel, Netlify, Cloudflare, and more](https://github.com/uniweb/docs/blob/main/reference/deployment.md) |
344
+ | Deployment | [Two artifacts, two verbs — bundled, linked, and per-host recipes](https://github.com/uniweb/docs/blob/main/development/deploying.md) |
335
345
 
336
346
  ---
337
347
 
@@ -387,7 +397,7 @@ A default project has two packages, listed in both `pnpm-workspace.yaml` and `pa
387
397
  ```yaml
388
398
  # pnpm-workspace.yaml
389
399
  packages:
390
- - foundation
400
+ - src
391
401
  - site
392
402
  ```
393
403
 
@@ -395,20 +405,20 @@ In `package.json` (for npm compatibility):
395
405
 
396
406
  ```json
397
407
  {
398
- "workspaces": ["foundation", "site"]
408
+ "workspaces": ["src", "site"]
399
409
  }
400
410
  ```
401
411
 
402
- When you `add` more packages, the CLI adds the appropriate globs to both files automatically:
412
+ When you `add` more packages, the CLI adds the appropriate workspaces automatically:
403
413
 
404
414
  ```yaml
405
- # After: uniweb add foundation blog → adds foundations/*
406
- # After: uniweb add extension effects → adds extensions/*
415
+ # After: uniweb add site marketing/blog
416
+ # After: uniweb add foundation marketing/blogger
407
417
  packages:
408
- - foundation
418
+ - src
409
419
  - site
410
- - foundations/*
411
- - extensions/*
420
+ - marketing/blog
421
+ - marketing/blogger
412
422
  ```
413
423
 
414
424
  ## FAQ
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uniweb",
3
- "version": "0.12.3",
3
+ "version": "0.12.5",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,12 +41,12 @@
41
41
  "js-yaml": "^4.1.0",
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.0.0",
44
+ "@uniweb/runtime": "0.8.10",
44
45
  "@uniweb/core": "0.7.9",
45
- "@uniweb/kit": "0.9.9",
46
- "@uniweb/runtime": "0.8.10"
46
+ "@uniweb/kit": "0.9.9"
47
47
  },
48
48
  "peerDependencies": {
49
- "@uniweb/build": "0.13.2",
49
+ "@uniweb/build": "0.13.3",
50
50
  "@uniweb/content-reader": "1.1.9",
51
51
  "@uniweb/semantic-parser": "1.1.16"
52
52
  },
@@ -196,7 +196,9 @@ async function addFoundation(rootDir, projectName, opts, pm = 'pnpm') {
196
196
  // both. Format validation runs on the derived package name below, not
197
197
  // on the raw input — slashes in the input are intentional path syntax.
198
198
  const FOUNDATION_KIND = { defaultDir: 'src', defaultPkg: 'src', projectSub: 'src' }
199
- const { relativePath, packageName } = resolvePlacement(rootDir, name, opts, FOUNDATION_KIND)
199
+ const placement = resolvePlacement(rootDir, name, opts, FOUNDATION_KIND)
200
+ const { relativePath } = placement
201
+ let { packageName } = placement
200
202
  const fullPath = join(rootDir, relativePath)
201
203
 
202
204
  // Validate the derived package name (format + reserved-name check). The
@@ -222,11 +224,33 @@ async function addFoundation(rootDir, projectName, opts, pm = 'pnpm') {
222
224
 
223
225
  // Collision check 2: a package with the same name already exists somewhere
224
226
  // in the workspace.
227
+ //
228
+ // Cross-role collisions auto-resolve. If a *site* already owns this name,
229
+ // suffix the foundation with `-src` (matching the `add project` and
230
+ // `add extension` precedents). The site keeps its name; the foundation
231
+ // gets a self-documenting suffix that says "this is the source code for
232
+ // the site that owns this name." Same-role collisions stay an error —
233
+ // two foundations with the same name is a real "be more specific"
234
+ // situation, not a disambiguation case.
225
235
  if (existingNames.has(packageName)) {
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}`)
229
- process.exit(1)
236
+ const sites = await discoverSites(rootDir)
237
+ const isSiteCollision = sites.some(s => s.name === packageName)
238
+ if (isSiteCollision) {
239
+ const suffixed = `${packageName}-src`
240
+ if (existingNames.has(suffixed)) {
241
+ error(`Cannot create foundation: both ${colors.bright}${packageName}${colors.reset} and ${colors.bright}${suffixed}${colors.reset} are taken in this workspace.`)
242
+ log(`Pick a different name:`)
243
+ log(` ${colors.cyan}${getCliPrefix()} add foundation <other-name>${colors.reset}`)
244
+ process.exit(1)
245
+ }
246
+ info(`Package "${packageName}" is taken by a site; using "${suffixed}" for this foundation.`)
247
+ packageName = suffixed
248
+ } else {
249
+ error(`Cannot create foundation: a foundation named ${colors.bright}${packageName}${colors.reset} already exists in this workspace.`)
250
+ log(`Pick a different name:`)
251
+ log(` ${colors.cyan}${getCliPrefix()} add foundation <other-name>${colors.reset}`)
252
+ process.exit(1)
253
+ }
230
254
  }
231
255
 
232
256
  // Scaffold
@@ -266,7 +290,9 @@ async function addSite(rootDir, projectName, opts, pm = 'pnpm') {
266
290
 
267
291
  // Resolve placement first (path + package name); see notes in addFoundation.
268
292
  const SITE_KIND = { defaultDir: 'site', defaultPkg: 'site', projectSub: 'site' }
269
- const { relativePath, packageName: siteName } = resolvePlacement(rootDir, name, opts, SITE_KIND)
293
+ const placement = resolvePlacement(rootDir, name, opts, SITE_KIND)
294
+ const { relativePath } = placement
295
+ let siteName = placement.packageName
270
296
  const fullPath = join(rootDir, relativePath)
271
297
 
272
298
  // Validate the package name (skip for the auto-derived 'site' default).
@@ -288,12 +314,28 @@ async function addSite(rootDir, projectName, opts, pm = 'pnpm') {
288
314
  process.exit(1)
289
315
  }
290
316
 
291
- // Collision check 2: package name already in workspace.
317
+ // Collision check 2: cross-role collisions auto-resolve with `-site`
318
+ // suffix; same-role collisions error. See the symmetric logic in
319
+ // addFoundation for the rationale.
292
320
  if (existingNames.has(siteName)) {
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}`)
296
- process.exit(1)
321
+ const foundations = await discoverFoundations(rootDir)
322
+ const isFoundationCollision = foundations.some(f => f.name === siteName)
323
+ if (isFoundationCollision) {
324
+ const suffixed = `${siteName}-site`
325
+ if (existingNames.has(suffixed)) {
326
+ error(`Cannot create site: both ${colors.bright}${siteName}${colors.reset} and ${colors.bright}${suffixed}${colors.reset} are taken in this workspace.`)
327
+ log(`Pick a different name:`)
328
+ log(` ${colors.cyan}${getCliPrefix()} add site <other-name>${colors.reset}`)
329
+ process.exit(1)
330
+ }
331
+ info(`Package "${siteName}" is taken by a foundation; using "${suffixed}" for this site.`)
332
+ siteName = suffixed
333
+ } else {
334
+ error(`Cannot create site: a site named ${colors.bright}${siteName}${colors.reset} already exists in this workspace.`)
335
+ log(`Pick a different name:`)
336
+ log(` ${colors.cyan}${getCliPrefix()} add site <other-name>${colors.reset}`)
337
+ process.exit(1)
338
+ }
297
339
  }
298
340
 
299
341
  // Resolve foundation
@@ -430,14 +472,45 @@ async function addExtension(rootDir, projectName, opts, pm = 'pnpm') {
430
472
  // Update workspace globs
431
473
  await addWorkspaceGlob(rootDir, 'extensions/*')
432
474
 
433
- // Wire extension to site if specified (or only one site exists)
475
+ // Wire extension to site:
476
+ // - --site <name>: explicit, wire it.
477
+ // - exactly one site: silent auto-wire (intent is unambiguous).
478
+ // - multiple sites, interactive: single-select prompt (extensions are
479
+ // typically per-site specialization — pick which site).
480
+ // - multiple sites, non-interactive: don't wire silently. Print a
481
+ // warning so the user/agent knows wiring is pending, and exit 0
482
+ // (the extension itself is fine).
483
+ // - no sites: print a note and exit 0.
434
484
  let wiredSite = null
485
+ let unwiredReason = null
435
486
  if (opts.site) {
436
487
  wiredSite = await wireExtensionToSite(rootDir, opts.site, name, target)
437
488
  } else {
438
489
  const sites = await discoverSites(rootDir)
439
490
  if (sites.length === 1) {
440
491
  wiredSite = await wireExtensionToSite(rootDir, sites[0].name, name, target)
492
+ } else if (sites.length > 1) {
493
+ if (isNonInteractive(process.argv)) {
494
+ unwiredReason = `Multiple sites in workspace; extension not wired. Re-run with --site <name>, or edit <site>/site.yml::extensions: manually.`
495
+ } else {
496
+ const sortedSites = [...sites].sort((a, b) => a.name.localeCompare(b.name))
497
+ const response = await prompts({
498
+ type: 'select',
499
+ name: 'site',
500
+ message: 'Which site is this extension for?',
501
+ choices: sortedSites.map(s => ({ title: s.name, description: s.path, value: s.name })),
502
+ }, {
503
+ onCancel: () => {
504
+ log('\nCancelled.')
505
+ process.exit(0)
506
+ },
507
+ })
508
+ if (response.site) {
509
+ wiredSite = await wireExtensionToSite(rootDir, response.site, name, target)
510
+ }
511
+ }
512
+ } else {
513
+ unwiredReason = `No site in this workspace yet. Wire this extension into a site's site.yml::extensions: once you create one.`
441
514
  }
442
515
  }
443
516
 
@@ -450,6 +523,9 @@ async function addExtension(rootDir, projectName, opts, pm = 'pnpm') {
450
523
  msg += ` → wired to site '${wiredSite}'`
451
524
  }
452
525
  success(msg)
526
+ if (unwiredReason) {
527
+ log(` ${colors.yellow}⚠ ${unwiredReason}${colors.reset}`)
528
+ }
453
529
  log('')
454
530
  log(`Next: ${colors.cyan}${installCmd(pm)}${colors.reset}`)
455
531
  }
@@ -1,13 +1,33 @@
1
1
  /**
2
2
  * Build Command
3
3
  *
4
- * Builds foundations with schema generation.
4
+ * Builds foundations with schema generation, or sites in either link or
5
+ * bundle mode.
6
+ *
7
+ * Site build modes:
8
+ * --bundle (default for sites)
9
+ * Full vite + post-vite pipeline. Produces a static-host JS bundle
10
+ * (`dist/index.html`, `dist/entry.js`, `_importmap/*`, `_pages/*` for
11
+ * split mode, sitemap/robots/search-index, prerendered HTML when
12
+ * configured). Targets non-Uniweb hosts (Netlify, Vercel, GitHub
13
+ * Pages) and the future Uniweb bundled-mode hosting.
14
+ *
15
+ * --link
16
+ * Data-only pipeline. No vite. Emits ONLY what the Uniweb-edge
17
+ * deploy needs: `dist/site-content.json` (with full sections),
18
+ * `dist/<lang>/site-content.json` per non-default locale,
19
+ * `dist/data/*.json` (collections), and `dist/assets/<media>` (images,
20
+ * fonts, video posters). Worker generates HTML at request time using
21
+ * its own runtime + the foundation served from the registry — the
22
+ * site's JS bundle is dead weight on this path.
5
23
  *
6
24
  * Usage:
7
- * uniweb build # Build current directory
25
+ * uniweb build # Build current directory (sites default to --bundle)
8
26
  * uniweb build --target foundation # Explicitly build as foundation
9
27
  * uniweb build --target site # Explicitly build as site
10
- * uniweb build --prerender # Build site + pre-render to static HTML (SSG)
28
+ * uniweb build --link # Site: link-mode (data only, no vite)
29
+ * uniweb build --bundle # Site: bundle-mode (full pipeline, today's behavior)
30
+ * uniweb build --prerender # Bundle-mode site + SSG (static HTML)
11
31
  */
12
32
 
13
33
  import { existsSync, readFileSync } from 'node:fs'
@@ -375,6 +395,136 @@ function resolveFoundationDir(projectDir, siteConfig) {
375
395
  return singleFoundationDir
376
396
  }
377
397
 
398
+ /**
399
+ * Build a site in link mode — data only, no vite.
400
+ *
401
+ * Emits exactly what `uniweb deploy` ships to Uniweb-edge:
402
+ * dist/site-content.json (full sections inlined)
403
+ * dist/<lang>/site-content.json per non-default locale
404
+ * dist/data/<collection>.json (+ per-record files for `deferred:`)
405
+ * dist/assets/<media> (processed images, video posters, PDF thumbnails)
406
+ *
407
+ * Does NOT emit HTML, JS, CSS, source maps, _importmap chunks, or
408
+ * static-host extras (sitemap, robots, search-index, _pages/*) — none
409
+ * of these are consumed on the link-mode deploy path. The worker
410
+ * generates HTML at request time and re-derives split-content per-page
411
+ * files from the full payload it receives.
412
+ *
413
+ * The `buildLocalizedContent` step is the same call bundle mode makes
414
+ * post-vite, so multi-locale sites get identical per-locale outputs in
415
+ * either mode. Collection translation (`buildLocalizedCollections`)
416
+ * also runs here so deploy ships translated collection JSONs.
417
+ *
418
+ * Bug surfaced + fixed by routing deploy through this path: the bundle
419
+ * pipeline's prerender step rewrites `dist/site-content.json` into a
420
+ * lightweight manifest (sections stripped) when split-content is active,
421
+ * and deploy was reading that stripped version, causing the worker to
422
+ * mis-detect split and serve blank pages. Link mode skips prerender
423
+ * entirely; `dist/site-content.json` keeps full sections; the worker
424
+ * splits correctly. See `kb/framework/build/workspace-ergonomics.md`.
425
+ */
426
+ async function buildSiteLink(projectDir, options = {}) {
427
+ const { siteConfig = null } = options
428
+
429
+ info('Building site (link mode)...')
430
+
431
+ const { buildSiteData } = await import('@uniweb/build/site')
432
+ const distDir = join(projectDir, 'dist')
433
+
434
+ // Resolve the local foundation path so collectSiteContent can pick up
435
+ // theme variable defaults from `foundation.js::theme.vars`. When the
436
+ // foundation is purely a registry ref (no local sibling), this stays
437
+ // null and theme defaults come from theme.yml only.
438
+ const foundationDir = await resolveFoundationDirForSite(projectDir, siteConfig).catch(() => null)
439
+
440
+ await buildSiteData({
441
+ siteRoot: projectDir,
442
+ distDir,
443
+ foundationPath: foundationDir,
444
+ assets: siteConfig?.build?.assets || {},
445
+ })
446
+ success(`Wrote ${join('dist', 'site-content.json')}`)
447
+
448
+ // Per-locale variants — same call bundle mode makes post-vite. Both
449
+ // modes produce identical `dist/<lang>/site-content.json` outputs so
450
+ // the deploy CLI walks the same path shape regardless of mode.
451
+ const i18nConfig = await loadI18nConfig(projectDir, siteConfig)
452
+ if (i18nConfig && i18nConfig.locales.length > 0) {
453
+ log('')
454
+ info(`Building localized content for: ${i18nConfig.locales.join(', ')}`)
455
+ try {
456
+ const outputs = await buildLocalizedContent(projectDir, i18nConfig)
457
+ success(`Generated ${Object.keys(outputs).length} locale(s)`)
458
+ for (const [locale] of Object.entries(outputs)) {
459
+ log(` ${colors.dim}dist/${locale}/site-content.json${colors.reset}`)
460
+ }
461
+
462
+ // Collection translations — optional; don't fail the build if
463
+ // missing. Bundle mode does the same.
464
+ try {
465
+ const { buildLocalizedCollections } = await import('@uniweb/build/i18n')
466
+ const collectionOutputs = await buildLocalizedCollections(projectDir, {
467
+ locales: i18nConfig.locales,
468
+ outputDir: distDir,
469
+ collectionsLocalesDir: join(projectDir, i18nConfig.localesDir, 'collections'),
470
+ })
471
+ const collectionCount = Object.values(collectionOutputs).reduce(
472
+ (sum, localeOutputs) => sum + Object.keys(localeOutputs).length,
473
+ 0
474
+ )
475
+ if (collectionCount > 0) {
476
+ success(`Translated collections for ${Object.keys(collectionOutputs).length} locale(s)`)
477
+ }
478
+ } catch (err) {
479
+ if (process.env.DEBUG) console.error('Collection translation:', err.message)
480
+ }
481
+ } catch (err) {
482
+ error(`i18n build failed: ${err.message}`)
483
+ if (process.env.DEBUG) console.error(err.stack)
484
+ log(`${colors.yellow}Continuing without localized content${colors.reset}`)
485
+ }
486
+ }
487
+
488
+ log('')
489
+ log(`${colors.green}${colors.bright}Build complete (link mode)${colors.reset}`)
490
+ }
491
+
492
+ /**
493
+ * Best-effort resolution of the local foundation directory for a site,
494
+ * used by `buildSiteLink` to pass `foundationPath` to the data pipeline.
495
+ *
496
+ * Mirrors a subset of `@uniweb/build`'s `detectFoundationType` semantics:
497
+ * when the site declares `foundation: <name>` and a sibling/file: dep
498
+ * resolves to a local foundation, return its path. When the foundation
499
+ * is a registry ref or URL, return null (data pipeline still works
500
+ * without a local foundation; theme defaults just come from theme.yml).
501
+ */
502
+ async function resolveFoundationDirForSite(siteDir, siteConfig) {
503
+ const cfg = siteConfig || readSiteConfig(siteDir)
504
+ const foundation = cfg?.foundation
505
+ if (!foundation || typeof foundation !== 'string') return null
506
+ // Registry ref or URL — no local foundation.
507
+ if (/^@[a-z0-9_-]+\/[a-z0-9_-]+@/.test(foundation)) return null
508
+ if (/^~[A-Za-z0-9_-]+\/[a-z0-9_-]+@/.test(foundation)) return null
509
+ if (foundation.startsWith('http://') || foundation.startsWith('https://')) return null
510
+
511
+ // Workspace sibling.
512
+ const sibling = resolve(siteDir, '..', foundation)
513
+ if (existsSync(sibling)) return sibling
514
+
515
+ // file: dep declared in package.json.
516
+ try {
517
+ const pkg = JSON.parse(readFileSync(join(siteDir, 'package.json'), 'utf-8'))
518
+ const dep = pkg.dependencies?.[foundation]
519
+ if (typeof dep === 'string' && dep.startsWith('file:')) {
520
+ const filePath = resolve(siteDir, dep.slice(5))
521
+ if (existsSync(filePath)) return filePath
522
+ }
523
+ } catch { /* no package.json or malformed — fall through */ }
524
+
525
+ return null
526
+ }
527
+
378
528
  /**
379
529
  * Build a site
380
530
  */
@@ -650,13 +800,24 @@ export async function build(args = []) {
650
800
  }
651
801
  }
652
802
 
653
- // Check for --shell flag (shell mode: no embedded content, for dynamic backend)
654
- const shellFlag = args.includes('--shell')
655
-
656
803
  // Check for --prerender / --no-prerender flags
657
804
  const prerenderFlag = args.includes('--prerender')
658
805
  const noPrerenderFlag = args.includes('--no-prerender')
659
806
 
807
+ // Check for --link / --bundle flags. These select between two
808
+ // mutually exclusive site build pipelines:
809
+ // --link: data only, no vite (for Uniweb-edge hosting)
810
+ // --bundle: full vite pipeline (for static hosts; today's behavior)
811
+ // Bare `uniweb build` for a site defaults to --bundle for back-compat
812
+ // with users targeting static hosts. `uniweb deploy` always passes one
813
+ // of the two explicitly based on the resolved deploy mode.
814
+ const linkFlag = args.includes('--link')
815
+ const bundleFlag = args.includes('--bundle')
816
+ if (linkFlag && bundleFlag) {
817
+ error('Cannot pass both --link and --bundle (they select different build pipelines)')
818
+ process.exit(1)
819
+ }
820
+
660
821
  // Check for --foundation-dir flag (for prerendering)
661
822
  let foundationDir = null
662
823
  const foundationDirIndex = args.indexOf('--foundation-dir')
@@ -685,9 +846,12 @@ export async function build(args = []) {
685
846
  process.exit(1)
686
847
  }
687
848
 
688
- // Validate --shell is only used with site target
689
- if (shellFlag && targetType !== 'site') {
690
- error('--shell can only be used with site builds')
849
+ // Validate --link / --bundle are only used with site target.
850
+ // (Foundation builds have no equivalent split — they always produce
851
+ // dist/foundation.js + schema.json regardless of how a downstream
852
+ // site consumes the result.)
853
+ if ((linkFlag || bundleFlag) && targetType === 'foundation') {
854
+ error('--link and --bundle apply to site builds only')
691
855
  process.exit(1)
692
856
  }
693
857
 
@@ -700,6 +864,23 @@ export async function build(args = []) {
700
864
  } else {
701
865
  // For sites, read config to determine prerender default
702
866
  const siteConfig = readSiteConfig(projectDir)
867
+
868
+ // Link mode: data-only pipeline, no vite. The deployed Uniweb-edge
869
+ // site never consumes the JS bundle, so we skip producing it.
870
+ // Worker generates HTML at request time using its own runtime +
871
+ // the foundation served from the registry. See
872
+ // `framework/build/src/site/build-site-data.js` for what gets
873
+ // emitted (and what doesn't).
874
+ if (linkFlag) {
875
+ if (prerenderFlag) {
876
+ error('--prerender does not apply to link mode (no static HTML is produced)')
877
+ process.exit(1)
878
+ }
879
+ await buildSiteLink(projectDir, { siteConfig })
880
+ return
881
+ }
882
+
883
+ // Bundle mode (default for sites, or explicit --bundle).
703
884
  const configPrerender = siteConfig.build?.prerender === true
704
885
 
705
886
  // CLI flags override config: --prerender forces on, --no-prerender forces off
@@ -707,21 +888,7 @@ export async function build(args = []) {
707
888
  if (prerenderFlag) prerender = true
708
889
  if (noPrerenderFlag) prerender = false
709
890
 
710
- // Shell mode: set env var for Vite config, force no prerender
711
- if (shellFlag) {
712
- process.env.UNIWEB_SHELL = 'true'
713
- prerender = false
714
- info('Building in shell mode (no embedded content)')
715
- }
716
-
717
891
  await buildSite(projectDir, { prerender, foundationDir, siteConfig })
718
-
719
- if (shellFlag) {
720
- log('')
721
- log(`${colors.green}${colors.bright}Shell build complete!${colors.reset}`)
722
- log(` The shell contains no embedded content.`)
723
- log(` Use ${colors.cyan}node scripts/platform/serve.js${colors.reset} to serve with dynamic content.`)
724
- }
725
892
  }
726
893
  } catch (err) {
727
894
  error(err.message)