uniweb 0.12.7 → 0.12.9

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.
@@ -1,12 +1,23 @@
1
1
  /**
2
2
  * Deploy Command
3
3
  *
4
- * Deploys a built site to Uniweb hosting. Phase 1 link-mode, content + theme
5
- * + locales only (no binary assets yet).
4
+ * Deploys a site. Host is determined by `deploy.host` in site.yml (or
5
+ * `--host <name>` flag). The default is `uniweb`:
6
6
  *
7
- * Flow:
7
+ * - `uniweb` (default): Uniweb hosting — link-mode + edge JIT prerender.
8
+ * Foundation loaded by URL from the registry. Requires `uniweb login`
9
+ * and a `foundation:` declaration in site.yml.
10
+ *
11
+ * - Static-host adapters (`s3-cloudfront`, `cloudflare-pages`,
12
+ * `github-pages`, `generic-static`, …): build dist/ in bundle-mode
13
+ * and hand it to a host adapter for upload + invalidation. No login,
14
+ * no edge. See kb/framework/plans/static-host-deploy-adapters.md.
15
+ *
16
+ * For static-host artifacts WITHOUT upload, see `uniweb export`.
17
+ *
18
+ * Default-flow steps:
8
19
  * 1. Read site.yml → { site.id?, site.handle?, foundation, runtime? }.
9
- * 2. Resolve runtime (default: GET /api/runtime/latest from the Worker).
20
+ * 2. Resolve runtime (default: GET /runtime/latest from the Worker).
10
21
  * 3. ensureAuth() → bearer CLI JWT from ~/.uniweb/auth.json.
11
22
  * 4. Build `dist/` if missing.
12
23
  * 5. Load dist/site-content.json → extract `languages` for the capability
@@ -18,19 +29,25 @@
18
29
  * - publishToken returned → fast path.
19
30
  * - needsReview:true + reviewUrl → open browser, wait for callback,
20
31
  * consume { publishToken, siteId, handle }.
21
- * 9. POST Worker /api/publish/validate to confirm foundation + runtime
32
+ * 9. POST Worker /publish/check to confirm foundation + runtime
22
33
  * exist and the token's namespace claim matches.
23
- * 10. POST Worker /api/publish/process with the full payload.
34
+ * 10. POST Worker /publish with the full payload.
24
35
  * 11. On first-deploy create flow: write site.id + site.handle back into
25
36
  * site.yml so subsequent deploys fast-path.
26
37
  *
27
38
  * Usage:
28
39
  * uniweb deploy Normal deploy (browser may open on first deploy)
29
- * uniweb deploy --skip-build Don't rebuild even if dist/ is stale
30
40
  * uniweb deploy --dry-run Resolve everything but skip the Worker POST
31
- * uniweb deploy --skip-billing Admin-only: bypass billing gate (dev/testing)
32
- * uniweb deploy --review Force the browser review path even when
33
- * there's no drift (e.g., to change features)
41
+ * uniweb deploy --no-auto-publish Don't auto-publish workspace-local foundation
42
+ * uniweb deploy --host <name> Static-host flow (e.g., s3-cloudfront,
43
+ * generic-static). Overrides site.yml deploy.host.
44
+ *
45
+ * Internal escape hatches (UNIWEB_* env vars — see framework/cli/docs/env-vars.md):
46
+ * UNIWEB_SKIP_BUILD=1 Reuse existing dist/ instead of rebuilding
47
+ * UNIWEB_SKIP_ASSETS=1 Skip the asset upload step
48
+ * UNIWEB_SKIP_BILLING=1 Admin-only: bypass billing gate
49
+ * UNIWEB_FORCE_REVIEW=1 Force the browser review flow
50
+ * UNIWEB_ALLOW_DIRTY_FOUNDATION=1 Don't treat a dirty workspace as stale
34
51
  *
35
52
  * See kb/platform/plans/cli-site-deploy-decisions.md for the full design.
36
53
  */
@@ -46,8 +63,20 @@ import { detectFoundationType } from '@uniweb/build'
46
63
 
47
64
  import { ensureAuth, readAuth, decodeJwtPayload } from '../utils/auth.js'
48
65
  import { getBackendUrl, getRegistryUrl } from '../utils/config.js'
66
+ import { parseBoolEnv } from '../utils/env.js'
49
67
  import { RemoteRegistry } from '../utils/registry.js'
50
- import { receiptFromRegistryEntry, splitRegistryRef } from '../utils/receipt.js'
68
+
69
+ /**
70
+ * Split `@ns/name@ver`, `~user/name@ver`, or `name@ver` into name + version.
71
+ * Returns null on any shape we don't recognize. Inlined here after the
72
+ * receipt-cache utility module was removed in Phase 4b — the only
73
+ * remaining caller is the staleness check below.
74
+ */
75
+ function splitRegistryRef(ref) {
76
+ if (typeof ref !== 'string') return null
77
+ const m = /^(@[^/]+\/[^@]+|~[^/]+\/[^@]+|[^@]+)@(.+)$/.exec(ref)
78
+ return m ? { name: m[1], version: m[2] } : null
79
+ }
51
80
  import {
52
81
  findWorkspaceRoot,
53
82
  findSites,
@@ -215,46 +244,41 @@ function composeFoundationUrl(ref, registryBase) {
215
244
  }
216
245
 
217
246
  /**
218
- * Inspect a workspace-local foundation's `dist/publish.json` (Phase 1 receipt)
219
- * and decide whether it's stale relative to the current source tree.
247
+ * Decide whether a workspace-local foundation is stale relative to the
248
+ * registry's record, by comparing per-directory git provenance against
249
+ * the registry entry's `publishedFromGitSha`. No local cache file —
250
+ * `dist/publish.json` was deleted in Phase 4b of the CLI ergonomics
251
+ * overhaul because every fresh clone / CI run / collaborator paid the
252
+ * registry round-trip anyway, and the local cache only added confusing
253
+ * "stale receipt" warnings when collaborators had different `dist/`
254
+ * state.
220
255
  *
221
- * When the receipt file is missing and a `registry` is provided, attempt
222
- * to refill it from the registry's index entry for `<name>@<version>`.
223
- * That makes the receipt pure cache: a fresh clone with a matching
224
- * upstream artifact resolves without an unnecessary republish. If the
225
- * registry has no record (or the stored entry lacks git provenance), the
226
- * inspector returns `stale: true` and the caller's auto-publish path
227
- * runs as before.
228
- *
229
- * Returns `{ stale, reason, receipt, refilled? }`. The caller decides
230
- * whether to auto-publish (Phase 2 default) or fail (`--no-auto-publish`).
256
+ * Returns `{ stale, reason }`. The caller decides whether to auto-publish
257
+ * (Phase 2 default) or fail (`--no-auto-publish`).
231
258
  */
232
- async function inspectLocalFoundationReceipt(localPath, { dirtyAsStale, registry }) {
233
- const receiptPath = join(localPath, 'dist', 'publish.json')
234
- let receipt = null
235
- let refilled = false
259
+ async function inspectFoundationStaleness(localPath, { dirtyAsStale, registry, ref }) {
260
+ const { gitSha, gitDirty } = readGitState(localPath)
261
+ if (!gitSha) {
262
+ return { stale: true, reason: 'foundation directory is not in a git repo or has no commits' }
263
+ }
264
+
265
+ const split = splitRegistryRef(ref)
266
+ if (!split) {
267
+ return { stale: true, reason: 'cannot derive registry ref from package.json' }
268
+ }
269
+
270
+ let existingEntry
236
271
  try {
237
- receipt = JSON.parse(await readFile(receiptPath, 'utf8'))
272
+ existingEntry = await registry.getVersionEntry(split.name, split.version)
238
273
  } catch {
239
- if (registry) {
240
- const refill = await tryRefillReceiptFromRegistry({ localPath, registry })
241
- if (refill) {
242
- await writeFile(receiptPath, JSON.stringify(refill, null, 2) + '\n')
243
- receipt = refill
244
- refilled = true
245
- }
246
- }
247
- if (!receipt) {
248
- return { stale: true, reason: 'no dist/publish.json (foundation has not been published from this checkout)' }
249
- }
274
+ return { stale: true, reason: 'registry lookup failed' }
250
275
  }
251
-
252
- const { gitSha, gitDirty } = readGitState(localPath)
253
- if (!gitSha) {
254
- return { stale: true, reason: 'foundation directory is not in a git repo or has no commits', receipt }
276
+ if (!existingEntry) {
277
+ return { stale: true, reason: `${split.name}@${split.version} not yet published` }
255
278
  }
256
- if (receipt.publishedFromGitSha && receipt.publishedFromGitSha !== gitSha) {
257
- // Receipt's recorded sha differs from the foundation's per-directory
279
+
280
+ if (existingEntry.publishedFromGitSha && existingEntry.publishedFromGitSha !== gitSha) {
281
+ // Recorded sha differs from the foundation's per-directory
258
282
  // last-touched commit. Normally that's "real" staleness — somebody
259
283
  // committed changes to src/ that haven't been republished.
260
284
  //
@@ -268,67 +292,17 @@ async function inspectLocalFoundationReceipt(localPath, { dirtyAsStale, registry
268
292
  // hasn't materially changed. Don't fire staleness on the sha
269
293
  // alone in that case; let the dirty-tree check below do its job
270
294
  // if the tree IS still dirty, and otherwise treat as fresh.
271
- //
272
- // Edge: if the user committed real source changes ON TOP of the
273
- // auto-derive in the same commit, we won't detect that as stale
274
- // here — the next publish would 409 against the registry though,
275
- // surfacing the issue with a clear "bump the version" message.
276
- if (!receipt.publishedFromGitDirty) {
295
+ if (!existingEntry.publishedFromGitDirty) {
277
296
  return {
278
297
  stale: true,
279
- reason: `foundation has new commits since last publish (${receipt.publishedFromGitSha.slice(0, 7)} → ${gitSha.slice(0, 7)})`,
280
- receipt,
298
+ reason: `foundation has new commits since last publish (${existingEntry.publishedFromGitSha.slice(0, 7)} → ${gitSha.slice(0, 7)})`,
281
299
  }
282
300
  }
283
301
  }
284
302
  if (gitDirty && dirtyAsStale) {
285
- return { stale: true, reason: 'foundation working tree is dirty', receipt }
303
+ return { stale: true, reason: 'foundation working tree is dirty' }
286
304
  }
287
- return { stale: false, receipt, refilled }
288
- }
289
-
290
- /**
291
- * When a receipt is missing, try to reconstruct it from the registry's
292
- * own record of the publish. We resolve `name@version` from the same
293
- * package metadata `deriveLocalFoundationRef` uses, then ask the registry
294
- * for its stored version entry. The entry's `publishedFromGitSha` is the
295
- * load-bearing field — without it (entries written before git provenance
296
- * was added) we can't compare against HEAD, so we don't synthesize a
297
- * misleading "fresh" receipt and let the caller fall through to the
298
- * republish path.
299
- *
300
- * Returns the receipt body, or null when refill is not possible.
301
- */
302
- async function tryRefillReceiptFromRegistry({ localPath, registry }) {
303
- // Two ways to derive the canonical `<name>@<version>`:
304
- // 1. `deriveLocalFoundationRef` — works for org-scope foundations
305
- // (where the namespace is in package.json) and for any foundation
306
- // where a previous receipt already exists. On the wipe-and-deploy
307
- // path that fired this code, the receipt is gone, so this only
308
- // works for org-scope.
309
- // 2. Empty-scope fallback — if package.json has `uniweb.id` and the
310
- // user's auth.json carries a `memberUuid` claim, we can synthesize
311
- // `~<memberUuid>/<id>@<version>` directly. Same shape the server
312
- // stores under.
313
- let ref = await deriveLocalFoundationRef(localPath)
314
- if (!ref) ref = await refFromAuthAndPkg(localPath)
315
- const split = splitRegistryRef(ref)
316
- if (!split) return null
317
- let existingEntry
318
- try {
319
- existingEntry = await registry.getVersionEntry(split.name, split.version)
320
- } catch {
321
- return null
322
- }
323
- if (!existingEntry) return null
324
- return receiptFromRegistryEntry({
325
- existingEntry,
326
- registry,
327
- name: split.name,
328
- version: split.version,
329
- isLocal: false,
330
- isPropagateDefault: false,
331
- })
305
+ return { stale: false }
332
306
  }
333
307
 
334
308
  /**
@@ -365,11 +339,10 @@ async function refFromAuthAndPkg(localPath) {
365
339
  *
366
340
  * Resolution order:
367
341
  * 1. Org scope from `pkg.uniweb.namespace` or `pkg.name`'s `@org/...` prefix.
368
- * 2. The receipt at `dist/publish.json`. After a successful publish, the
369
- * receipt's `url` carries the canonical server-rewritten name —
370
- * including empty-scope publishes, which the server rewrites to
371
- * `~<memberUuid>/<name>`. The CLI can't synthesize that locally
372
- * because the memberUuid lives in the JWT, not in the workspace.
342
+ * 2. Empty-scope synthesis from `pkg.uniweb.id` + the user's auth claim
343
+ * (`~<memberUuid>/<id>@<version>`). Same canonical shape the server
344
+ * stores under for empty-scope publishes. Phase 4d will replace this
345
+ * with `~{siteId}/...` derived from authorize.
373
346
  * 3. null — caller falls through to the helpful "set uniweb.namespace"
374
347
  * error message.
375
348
  */
@@ -403,154 +376,37 @@ async function deriveLocalFoundationRef(localPath) {
403
376
  return `@${namespace}/${bareName}@${version}`
404
377
  }
405
378
 
406
- // Empty-scope path read the canonical name from the receipt's URL.
407
- // The server rewrites bare names to `~<memberUuid>/<name>`; the URL it
408
- // returns carries that canonical form (e.g.
409
- // `/foundations/~<uuid>/<name>@<ver>/foundation.js`). Parse it back.
410
- const fromReceipt = await refFromReceiptUrl(localPath)
411
- if (fromReceipt) return fromReceipt
379
+ // Empty-scope fallback: synthesize `~<memberUuid>/<id>@<version>` from
380
+ // the user's auth + package.json::uniweb.id. Same canonical shape the
381
+ // server stores under for empty-scope publishes. After Phase 4d this
382
+ // path is replaced by `~{siteId}/...` derived from authorize.
383
+ const fromAuth = await refFromAuthAndPkg(localPath)
384
+ if (fromAuth) return fromAuth
412
385
 
413
386
  return null
414
387
  }
415
388
 
416
- // Personal-scope namespaces are base58 memberUuids (mixed case; the
417
- // alphabet is `[A-HJ-NP-Za-km-z1-9]`). Org-scope handles are
418
- // lowercase-only. Allow both shapes here so the regex captures personal
419
- // scopes correctly. The bare-name portion (after the `/`) stays
420
- // lowercase per BARE_NAME_RE.
421
- const RECEIPT_URL_REF_RE = /\/foundations\/((?:@[a-z0-9_-]+|~[A-Za-z0-9_-]+)\/[a-z0-9_-]+)@([^/]+)\//
422
-
423
- async function refFromReceiptUrl(localPath) {
424
- try {
425
- const receipt = JSON.parse(await readFile(join(localPath, 'dist', 'publish.json'), 'utf8'))
426
- const m = RECEIPT_URL_REF_RE.exec(receipt?.url || '')
427
- if (m) return `${m[1]}@${m[2]}`
428
- } catch {
429
- // No receipt, malformed JSON, or URL doesn't carry the canonical
430
- // shape — fall through to null.
431
- }
432
- return null
433
- }
434
-
435
- /**
436
- * Resolve the deploy mode for this site.
437
- *
438
- * Returns `'link'` or `'bundle'`, optionally prompting the user when
439
- * neither inference nor an explicit flag yields an answer. Mode choice
440
- * is persisted server-side after the first successful deploy; switching
441
- * modes later requires deleting the site and redeploying fresh.
442
- *
443
- * Ladder (first match wins):
444
- * 1. `--link` or `--bundle` flag passed explicitly.
445
- * 2. `site.yml::foundation` is a registry ref (`@org/x@ver` or
446
- * `~uuid/x@ver`) or full HTTPS URL → infer link. The user already
447
- * declared "load this foundation by URL at runtime."
448
- * 3. `site.yml::foundation` is a workspace-local sibling AND that
449
- * foundation has a publish receipt (`dist/publish.json` with a
450
- * registry URL) → infer link. The user has already done the work
451
- * to make the foundation loadable from the registry.
452
- * 4. Otherwise → ask (TTY prompt) or error (CI).
453
- *
454
- * @param {Object} ctx
455
- * @param {string} ctx.foundationRef - the raw value of site.yml::foundation (string or normalized object).
456
- * @param {string} ctx.siteDir - absolute path to the site dir (for resolving local foundation siblings).
457
- * @param {boolean} ctx.linkFlag
458
- * @param {boolean} ctx.bundleFlag
459
- * @returns {Promise<{ mode: 'link'|'bundle', source: 'flag'|'inferred-registry-ref'|'inferred-published-local'|'asked' }>}
460
- */
461
- async function resolveDeployMode({ foundationRef, siteDir, linkFlag, bundleFlag }) {
462
- // 1. Explicit flag wins.
463
- if (linkFlag) return { mode: 'link', source: 'flag' }
464
- if (bundleFlag) return { mode: 'bundle', source: 'flag' }
465
-
466
- // 2. Registry ref or URL in site.yml → link mode is the only sensible choice.
467
- if (typeof foundationRef === 'string') {
468
- if (foundationRef.startsWith('http://') || foundationRef.startsWith('https://')) {
469
- return { mode: 'link', source: 'inferred-registry-ref' }
470
- }
471
- if (/^@[a-z0-9_-]+\/[a-z0-9_-]+@/.test(foundationRef)) {
472
- return { mode: 'link', source: 'inferred-registry-ref' }
473
- }
474
- if (/^~[A-Za-z0-9_-]+\/[a-z0-9_-]+@/.test(foundationRef)) {
475
- return { mode: 'link', source: 'inferred-registry-ref' }
476
- }
477
- }
478
-
479
- // 3. Workspace-local foundation with an existing publish receipt → infer link.
480
- // The receipt being there means the user has already published this
481
- // foundation at least once, so they've shown intent to ship via the
482
- // registry. We don't validate the receipt's freshness here — that's
483
- // the existing inspectLocalFoundationReceipt path's job, which runs
484
- // later in the deploy flow.
485
- if (typeof foundationRef === 'string') {
486
- const detected = detectFoundationType(foundationRef, siteDir)
487
- if (detected.type === 'local') {
488
- const receiptPath = join(detected.path, 'dist', 'publish.json')
489
- if (existsSync(receiptPath)) {
490
- return { mode: 'link', source: 'inferred-published-local' }
491
- }
492
- }
493
- }
494
-
495
- // 4. Ambiguous — local foundation never published, or unknown shape. Ask.
496
- if (isNonInteractive(process.argv)) {
497
- say.err('First deploy of this site needs an explicit mode.')
498
- console.log('')
499
- console.log(' Pick one and re-run:')
500
- console.log(` ${c.cyan}uniweb deploy --link${c.reset} ${c.dim}Uniweb-edge hosting (data only; worker generates HTML)${c.reset}`)
501
- console.log(` ${c.cyan}uniweb deploy --bundle${c.reset} ${c.dim}Static-host artifact (vite build; for non-Uniweb hosts)${c.reset}`)
502
- console.log('')
503
- console.log(` ${c.dim}Mode is persisted after the first deploy and can't be changed in place.${c.reset}`)
504
- process.exit(1)
505
- }
506
-
507
- const prompts = (await import('prompts')).default
508
- console.log('')
509
- console.log(`${c.dim}First deploy of this site. Pick a deployment mode:${c.reset}`)
510
- const resp = await prompts({
511
- type: 'select',
512
- name: 'mode',
513
- message: 'Deployment mode',
514
- choices: [
515
- { title: 'Link mode (Uniweb-edge hosting)', description: 'Data only; worker generates HTML at request time', value: 'link' },
516
- { title: 'Bundle mode (static-host artifact)', description: 'vite-built; deploy to Netlify, Vercel, GitHub Pages, etc.', value: 'bundle' },
517
- ],
518
- initial: 0,
519
- }, {
520
- onCancel: () => { console.log(''); console.log('Deploy cancelled.'); process.exit(0) },
521
- })
522
- if (!resp.mode) process.exit(0)
523
- return { mode: resp.mode, source: 'asked' }
524
- }
525
-
526
389
  // ─── Main ───────────────────────────────────────────────────
527
390
 
528
391
  export async function deploy(args = []) {
529
- const skipBuild = args.includes('--skip-build')
530
392
  const dryRun = args.includes('--dry-run')
531
- const skipAssets = args.includes('--skip-assets')
532
- const skipBilling = args.includes('--skip-billing')
533
- // --review forces the browser review path even when there's no drift.
534
- // Useful for "I want to look at / change my features" without first
535
- // having to edit site.yml. The toggles in the review page persist live
536
- // to DB, so any change the user makes there ends up in site.yml after
537
- // the loopback finalize roundtrip.
538
- const forceReview = args.includes('--review')
539
- // Phase 2 (deploy-ux-v4): when `foundation:` in site.yml points at a
540
- // workspace-local file: ref, deploy auto-publishes the foundation when
541
- // its `dist/publish.json` receipt is missing/stale. These flags opt out.
393
+ // When `foundation:` in site.yml points at a workspace-local file: ref,
394
+ // deploy auto-publishes the foundation when the registry has no record
395
+ // of the current source's git sha. This flag opts out.
542
396
  const autoPublishFoundation = !args.includes('--no-auto-publish')
543
- const treatDirtyAsStale = !args.includes('--no-dirty-as-stale')
544
- // Site mode`--link` ships data only (Uniweb-edge hosting); `--bundle`
545
- // ships a vite-built static-host artifact. Mutually exclusive. Resolution
546
- // ladder runs further below: explicit flag registry-ref / URL infer →
547
- // published-local-foundation infer ask-or-error.
548
- const linkFlag = args.includes('--link')
549
- const bundleFlag = args.includes('--bundle')
550
- if (linkFlag && bundleFlag) {
551
- say.err('Cannot pass both --link and --bundle.')
552
- process.exit(1)
553
- }
397
+
398
+ // Internal escape hatches see framework/cli/docs/env-vars.md. These
399
+ // are not user-facing flags; they exist for the platform test team,
400
+ // CI scripts, and dev-loop unblockers. The bare `deploy` command should
401
+ // do the right thing for normal users without any of them set.
402
+ const skipBuild = parseBoolEnv('UNIWEB_SKIP_BUILD')
403
+ const skipAssets = parseBoolEnv('UNIWEB_SKIP_ASSETS')
404
+ const skipBilling = parseBoolEnv('UNIWEB_SKIP_BILLING')
405
+ const forceReview = parseBoolEnv('UNIWEB_FORCE_REVIEW')
406
+ // Inverse of the (now-removed) --no-dirty-as-stale flag. When true, a
407
+ // dirty workspace will NOT be treated as stale (won't trigger auto-publish
408
+ // of the foundation). Default: dirty IS stale.
409
+ const treatDirtyAsStale = !parseBoolEnv('UNIWEB_ALLOW_DIRTY_FOUNDATION')
554
410
 
555
411
  const siteDir = await resolveSiteDir(args)
556
412
  const backendUrl = getBackendUrl()
@@ -560,6 +416,22 @@ export async function deploy(args = []) {
560
416
  // site.id / site.handle from prior deploys.
561
417
  const siteYmlPath = join(siteDir, 'site.yml')
562
418
  const siteYml = await readSiteYml(siteYmlPath)
419
+
420
+ // Host dispatch. The default host is `uniweb` — Uniweb hosting
421
+ // (link-mode + edge JIT prerender), which is the rest of this
422
+ // function. Any other named host is a static-host adapter; hand off
423
+ // to it and return. The default flow requires a `foundation:`
424
+ // declaration; static-host deploys don't, so this branch comes BEFORE
425
+ // the foundation check.
426
+ // See kb/framework/plans/static-host-deploy-adapters.md.
427
+ const hostFlagIndex = args.indexOf('--host')
428
+ const hostFromFlag = hostFlagIndex !== -1 ? args[hostFlagIndex + 1] : null
429
+ const host = hostFromFlag || siteYml.deploy?.host || 'uniweb'
430
+ if (host !== 'uniweb') {
431
+ await deployStaticHost(siteDir, siteYml, host, { dryRun })
432
+ return
433
+ }
434
+
563
435
  if (!siteYml.foundation) {
564
436
  say.err('site.yml is missing `foundation`.')
565
437
  say.dim('Add a line like: foundation: \'@uniweb/docs-foundation@0.1.20\'')
@@ -583,29 +455,29 @@ export async function deploy(args = []) {
583
455
  say.dim('Foundation policy: exact (pinned)')
584
456
  }
585
457
 
586
- // Resolve deploy mode (link vs bundle) BEFORE the foundation
587
- // staleness check, because mode selection feeds into how we run
588
- // the site build later. The resolver may prompt the user on first
589
- // deploy; subsequent deploys infer or use the explicit flag.
590
- // TODO: when PHP authorize starts returning a persisted mode for
591
- // this site, reconcile it with the resolved mode here and reject
592
- // any mismatch ("delete site and redeploy fresh to change modes").
593
- const { mode: deployMode, source: modeSource } = await resolveDeployMode({
594
- foundationRef: foundation,
595
- siteDir,
596
- linkFlag,
597
- bundleFlag,
598
- })
599
- if (modeSource === 'inferred-registry-ref') {
600
- say.dim('Deploy mode: link (inferred — foundation is a registry ref)')
601
- } else if (modeSource === 'inferred-published-local') {
602
- say.dim('Deploy mode: link (inferred — local foundation has a publish receipt)')
603
- } else if (modeSource === 'asked') {
604
- say.dim(`Deploy mode: ${deployMode} (selected)`)
605
- } else {
606
- say.dim(`Deploy mode: ${deployMode}`)
458
+ // --dry-run gate. Must come BEFORE auto-publish (which writes to the
459
+ // registry) and BEFORE the site build (which writes to dist/). Earlier
460
+ // versions of this command had the dry-run check after both, which
461
+ // violated the contract that --dry-run performs zero writes. Languages
462
+ // and the default locale are unavailable here (they live in
463
+ // dist/site-content.json, which a dry-run won't build); the trade-off
464
+ // is intentional. Run `uniweb build` directly if you need that detail.
465
+ if (dryRun) {
466
+ say.info('Dry run — would deploy:')
467
+ say.dim(`Site dir : ${siteDir}`)
468
+ say.dim(`site.id : ${siteYml.site?.id || '(none — would use create flow)'}`)
469
+ say.dim(`Foundation : ${typeof foundation === 'string' ? foundation : foundation.ref}`)
470
+ say.dim(`Runtime : ${siteYml.runtime || '(latest, resolved at authorize)'}`)
471
+ say.dim(`Backend (PHP) : ${backendUrl}`)
472
+ say.dim(`Worker : ${workerUrl}`)
473
+ return
607
474
  }
608
475
 
476
+ // `uniweb deploy` always runtime-links: the edge serves a runtime
477
+ // template + per-site base.html, with the foundation loaded by URL.
478
+ // The historical --link / --bundle flags are gone (Phase 2 of the CLI
479
+ // ergonomics overhaul). For static-host artifacts, see `uniweb export`.
480
+
609
481
  // Phase 2: resolve workspace-local `file:` foundation refs.
610
482
  //
611
483
  // The object form of `foundation:` already requires a registry ref
@@ -616,69 +488,59 @@ export async function deploy(args = []) {
616
488
  // build runs in runtime mode against the just-published artifact instead
617
489
  // of bundling the local foundation source. site.yml on disk is never
618
490
  // modified.
619
- let foundationBuildOverride = null
491
+ // Phase 4d: detect a workspace-local foundation. The actual upload happens
492
+ // AFTER authorize (which mints siteId), so the canonical site-bound ref
493
+ // `~{siteId}/{name}@{ver}` is known by the time we publish. For now we
494
+ // just record what we'll need at upload time and pass a `~self/...`
495
+ // placeholder to authorize — the server rewrites it.
496
+ let localFoundation = null
620
497
  if (typeof foundation === 'string') {
621
498
  const detected = detectFoundationType(foundation, siteDir)
622
499
  if (detected.type === 'local') {
623
500
  const localPath = detected.path
624
501
  const relPath = relative(siteDir, localPath) || localPath
625
502
 
626
- // Pass a RemoteRegistry into the inspector so it can refill a missing
627
- // `dist/publish.json` from the registry's index (fresh-clone case).
628
- // No auth needed `getVersionEntry` reads the public listing.
629
- const refillRegistry = new RemoteRegistry(workerUrl)
630
- const inspection = await inspectLocalFoundationReceipt(localPath, {
631
- dirtyAsStale: treatDirtyAsStale,
632
- registry: refillRegistry,
633
- })
634
- if (inspection.refilled) {
635
- say.dim(`Foundation receipt at ${relPath} refilled from registry.`)
636
- }
637
-
638
- if (inspection.stale && !autoPublishFoundation) {
639
- say.err(`Local foundation at ${relPath} is stale: ${inspection.reason}.`)
640
- say.dim(`Run \`${getCliPrefix()} publish\` from ${relPath}, or drop --no-auto-publish to let deploy publish it for you.`)
503
+ let pkg
504
+ try {
505
+ pkg = JSON.parse(await readFile(join(localPath, 'package.json'), 'utf8'))
506
+ } catch {
507
+ say.err(`Could not read ${relPath}/package.json.`)
641
508
  process.exit(1)
642
509
  }
643
- if (inspection.stale) {
644
- say.info(`Foundation at ${relPath} is stale (${inspection.reason}). Auto-publishing…`)
645
- console.log('')
646
- try {
647
- // Spawn the SAME CLI binary that's currently running, not via
648
- // `npx uniweb` — npx resolves through node_modules and could
649
- // pick up a stale npm-published version that doesn't share
650
- // this CLI's behavior (e.g. doesn't recognize new flags).
651
- // Using `process.argv[1]` keeps the outer/inner CLI version
652
- // identical, eliminating the skew.
653
- execSync(`node ${JSON.stringify(process.argv[1])} publish`, {
654
- cwd: localPath,
655
- stdio: 'inherit',
656
- })
657
- } catch {
658
- say.err(`Auto-publish of foundation at ${relPath} failed. See output above.`)
659
- process.exit(1)
660
- }
661
- console.log('')
510
+ const foundationName = pkg.uniweb?.id || pkg.name?.replace(/^[@~][^/]+\//, '') || pkg.name
511
+ const foundationVersion = pkg.version
512
+ if (!foundationName || !foundationVersion) {
513
+ say.err(`Foundation at ${relPath} needs both a name and a version in package.json.`)
514
+ process.exit(1)
662
515
  }
663
516
 
664
- const resolved = await deriveLocalFoundationRef(localPath)
665
- if (!resolved) {
666
- say.err(`Could not derive a registry ref for foundation at ${relPath}.`)
667
- say.dim(`Make sure its package.json has a name + version and a namespace (uniweb.namespace, scoped name, or run \`uniweb publish --namespace <handle>\` once).`)
668
- process.exit(1)
517
+ localFoundation = {
518
+ path: localPath,
519
+ relPath,
520
+ name: foundationName,
521
+ version: foundationVersion,
669
522
  }
670
- say.dim(`Foundation: ${foundation} → ${resolved} (resolved from ${relPath})`)
671
- foundation = resolved
672
- foundationBuildOverride = resolved
523
+
524
+ // Send `~self/{name}@{ver}` as a placeholder. The server will rewrite
525
+ // to `~{siteId}/{name}@{ver}` once siteId is minted. The CLI uses the
526
+ // returned canonical ref for both the upload and the publish payload.
527
+ foundation = `~self/${foundationName}@${foundationVersion}`
673
528
  }
674
529
  }
530
+ // Honor --no-auto-publish for local foundations: surface the gate before
531
+ // we do any work.
532
+ if (localFoundation && !autoPublishFoundation) {
533
+ say.err(`Local foundation at ${localFoundation.relPath} would be auto-published as part of deploy.`)
534
+ say.dim('Drop --no-auto-publish to let deploy publish it, or change site.yml to reference a registry-published foundation.')
535
+ process.exit(1)
536
+ }
675
537
 
676
538
  // Runtime defaults to "latest" resolved at authorize time.
677
539
  let runtimeVersion = siteYml.runtime
678
540
  if (!runtimeVersion) {
679
541
  runtimeVersion = await fetchLatestRuntime(workerUrl)
680
542
  if (!runtimeVersion) {
681
- say.err('Could not resolve a runtime version (no runtime: in site.yml, /api/runtime/latest failed).')
543
+ say.err('Could not resolve a runtime version (no runtime: in site.yml, /runtime/latest failed).')
682
544
  process.exit(1)
683
545
  }
684
546
  say.dim(`Runtime: ${runtimeVersion} (latest; pin via \`runtime:\` in site.yml)`)
@@ -705,32 +567,29 @@ export async function deploy(args = []) {
705
567
  // detectFoundationType recognizes `@ns/name@version` refs as
706
568
  // link-mode URLs, which auto-enters runtime mode. Prerender also
707
569
  // auto-skips for link-mode foundations (HTML is rendered on the
708
- // serving edge, not here).
570
+ // serving edge, not here). Always --link: the edge serves a runtime
571
+ // template + per-site base.html, never a self-contained vite bundle.
709
572
  //
710
- // For workspace-local foundations (Phase 2 resolution above),
711
- // UNIWEB_FOUNDATION_REF tells defineSiteConfig to use the resolved
712
- // registry ref instead of site.yml's literal value. In bundle mode
713
- // the build produces a runtime-mode bundle pointing at the just-
714
- // published foundation rather than embedding the local source.
715
- // Link mode doesn't run vite at all, so the env var is harmless
716
- // there but still passed through for consistency.
573
+ // Phase 4d: workspace-local foundations carry the `~self/{name}@{ver}`
574
+ // placeholder at this point; the canonical `~{siteId}/...` ref isn't
575
+ // known until authorize returns. Link mode doesn't run vite or fetch
576
+ // the foundation, so site-content.json's foundation field reflects
577
+ // whatever's in site.yml that's fine because the publish payload
578
+ // overrides it with the canonical form post-authorize.
717
579
  //
718
580
  // Spawn the SAME CLI binary that's currently running rather than
719
581
  // `npx uniweb build` — npx walks node_modules and would resolve to
720
582
  // whatever version is installed there (which might be older than
721
583
  // the deploy CLI and silently ignore --link). `process.argv[1]`
722
584
  // pins the inner build to the outer's exact version.
723
- const buildModeFlag = deployMode === 'link' ? '--link' : '--bundle'
724
- execSync(`node ${JSON.stringify(process.argv[1])} build ${buildModeFlag}`, {
585
+ execSync(`node ${JSON.stringify(process.argv[1])} build --link`, {
725
586
  cwd: siteDir,
726
587
  stdio: 'inherit',
727
- env: foundationBuildOverride
728
- ? { ...process.env, UNIWEB_FOUNDATION_REF: foundationBuildOverride }
729
- : process.env,
588
+ env: process.env,
730
589
  })
731
590
  console.log('')
732
591
  } else if (!existsSync(contentPath)) {
733
- say.err('No build found and --skip-build passed. Run `uniweb build` first.')
592
+ say.err('No build found and UNIWEB_SKIP_BUILD set. Run `uniweb build` first.')
734
593
  process.exit(1)
735
594
  }
736
595
  if (!existsSync(contentPath)) {
@@ -762,25 +621,13 @@ export async function deploy(args = []) {
762
621
  }
763
622
  }
764
623
 
765
- if (dryRun) {
766
- say.info('Dry run — showing what would be deployed:')
767
- say.dim(`Site dir : ${siteDir}`)
768
- say.dim(`site.id : ${siteYml.site?.id || '(none — would use create flow)'}`)
769
- say.dim(`Foundation : ${typeof foundation === 'string' ? foundation : foundation.ref}`)
770
- say.dim(`Runtime : ${runtimeVersion}`)
771
- say.dim(`Languages : ${languages.join(', ')}`)
772
- say.dim(`Default locale : ${defaultLanguage}`)
773
- say.dim(`Backend (PHP) : ${backendUrl}`)
774
- say.dim(`Worker : ${workerUrl}`)
775
- return
776
- }
777
-
778
624
  // Spin up the loopback listener eagerly — we need its callback URL for the
779
625
  // authorize request even on the fast path (PHP may always return
780
626
  // needsReview=true on first deploy / billing drift in future phases).
781
627
  const loopback = await startLoopback()
782
628
 
783
629
  let publishToken, siteIdResolved, handleResolved, publishUrl, validateUrl, mintedFeatures
630
+ let foundationUploadUrl // Phase 4d: returned by authorize for site-bound foundation uploads
784
631
  try {
785
632
  say.info('Requesting deploy authorization…')
786
633
  const authorizeBody = {
@@ -805,16 +652,9 @@ export async function deploy(args = []) {
805
652
  // Always sent as an array; missing/empty `features:` in site.yml
806
653
  // is normalized to `[]`, meaning "no paid features".
807
654
  desiredFeatures,
808
- // User-forced review (`uniweb deploy --review`). PHP refuses to
655
+ // User-forced review (UNIWEB_FORCE_REVIEW=1). PHP refuses to
809
656
  // fast-path even when nothing else has drifted.
810
657
  forceReview: forceReview || undefined,
811
- // Deploy mode for this site. On first deploy PHP should persist
812
- // it to the site row; on subsequent deploys PHP should return the
813
- // persisted value back so the CLI can detect mode mismatches and
814
- // refuse with "delete and redeploy fresh" rather than silently
815
- // reshape the storage layout. PHP versions that pre-date mode
816
- // persistence ignore this field — back-compat is built in.
817
- mode: deployMode,
818
658
  }
819
659
  let authRes
820
660
  try {
@@ -829,27 +669,18 @@ export async function deploy(args = []) {
829
669
  say.dim('Treating as a new site — the create flow will run in your browser.')
830
670
  authorizeBody.siteId = ''
831
671
  authRes = await callAuthorize({ backendUrl, cliToken, body: authorizeBody })
672
+ } else if (err.status === 403 && authorizeBody.siteId) {
673
+ // Collaborator ACL — the user has the repo (and thus site.id in
674
+ // site.yml) but isn't owner or editor on this site. The server's
675
+ // 403 message names the owner; surface it verbatim.
676
+ say.err(err.message)
677
+ process.exit(1)
832
678
  } else {
833
679
  say.err(`Authorize failed: ${err.message}`)
834
680
  process.exit(1)
835
681
  }
836
682
  }
837
683
 
838
- // Mode lock — once a site has a persisted mode on the server,
839
- // deploying with a different mode would silently reshape its R2
840
- // storage layout. Refuse and direct the user to start fresh.
841
- // Until PHP starts returning `persistedMode`, this check is a
842
- // no-op (forward-compatible).
843
- if (authRes.persistedMode && authRes.persistedMode !== deployMode) {
844
- say.err(`Deploy mode mismatch: this site is configured for ${authRes.persistedMode}, but this deploy resolved to ${deployMode}.`)
845
- console.log('')
846
- console.log(` ${c.dim}Mode is locked after the first deploy. To switch:${c.reset}`)
847
- console.log(` 1. Delete the site (manage.uniweb.app or the dashboard)`)
848
- console.log(` 2. Remove ${c.cyan}site.id${c.reset} and ${c.cyan}site.handle${c.reset} from site.yml`)
849
- console.log(` 3. Re-run ${c.cyan}uniweb deploy --${deployMode}${c.reset}`)
850
- process.exit(1)
851
- }
852
-
853
684
  if (authRes.needsReview) {
854
685
  const flowLabel = authRes.intent === 'create' ? 'site creation' : 'review'
855
686
  // openBrowser returns a hint about whether a GUI was available. On
@@ -889,16 +720,29 @@ export async function deploy(args = []) {
889
720
  // CLI can write `features:` back into site.yml accurately. Older
890
721
  // PHP that doesn't include this field is a no-op.
891
722
  mintedFeatures = Array.isArray(cb.features) ? cb.features : null
723
+ // Phase 4d: workspace-local foundation deploys on the create flow
724
+ // need the rewritten `~{siteId}/{name}@{ver}` ref + upload endpoint.
725
+ // PHP/unicloud put them in the finalize response; the web app
726
+ // forwards them to the loopback. Catalog-ref deploys leave them
727
+ // undefined and we fall back to the placeholder/derived URL below.
728
+ if (cb.foundationRef) foundation = cb.foundationRef
729
+ if (cb.foundationUploadUrl) foundationUploadUrl = cb.foundationUploadUrl
892
730
  // Review path: Worker URLs are implicit (we derive them from config).
893
- publishUrl = `${workerUrl}/api/publish/process`
894
- validateUrl = `${workerUrl}/api/publish/validate`
731
+ publishUrl = `${workerUrl}/publish`
732
+ validateUrl = `${workerUrl}/publish/check`
895
733
  } else {
896
734
  publishToken = authRes.publishToken
897
735
  siteIdResolved = authRes.siteId
898
736
  handleResolved = authRes.handle
899
737
  publishUrl = authRes.publishUrl
900
738
  validateUrl = authRes.validateUrl
739
+ foundationUploadUrl = authRes.foundationUploadUrl
901
740
  mintedFeatures = Array.isArray(authRes.features) ? authRes.features : null
741
+ // Phase 4d: server returns the canonical foundation ref. For
742
+ // `~self/...` placeholders this is the rewritten `~{siteId}/...`
743
+ // form; catalog refs pass through. The CLI uses this for both the
744
+ // foundation upload (next step) and the publish payload below.
745
+ if (authRes.foundationRef) foundation = authRes.foundationRef
902
746
  }
903
747
  } finally {
904
748
  loopback.close()
@@ -918,6 +762,45 @@ export async function deploy(args = []) {
918
762
  say.dim(`Linked site.yml to site.id=${siteIdResolved}`)
919
763
  }
920
764
 
765
+ // Phase 4d: upload site-bound foundation files directly. Replaces the
766
+ // pre-Phase-4d `execSync('uniweb publish')` flow — we now know the
767
+ // canonical `~{siteId}/{name}@{ver}` ref from authorize, and the worker's
768
+ // /foundations endpoint accepts the publish token's siteId claim
769
+ // for this scope.
770
+ if (localFoundation) {
771
+ say.info(`Building foundation at ${localFoundation.relPath}…`)
772
+ console.log('')
773
+ try {
774
+ execSync(`node ${JSON.stringify(process.argv[1])} build`, {
775
+ cwd: localFoundation.path,
776
+ stdio: 'inherit',
777
+ })
778
+ } catch {
779
+ say.err(`Foundation build at ${localFoundation.relPath} failed. See output above.`)
780
+ process.exit(1)
781
+ }
782
+ console.log('')
783
+
784
+ say.info(`Uploading foundation as ${foundation}…`)
785
+ const foundationFiles = await collectFoundationDistFiles(join(localFoundation.path, 'dist'))
786
+ const foundationPublishUrl = foundationUploadUrl || `${workerUrl}/foundations`
787
+ const { gitSha: fGitSha, gitDirty: fGitDirty } = readGitState(localFoundation.path)
788
+ await callFoundationUpload({
789
+ url: foundationPublishUrl,
790
+ token: publishToken,
791
+ body: {
792
+ name: foundation.replace(/@[^@]+$/, ''), // strip `@version` to get `~{siteId}/{name}`
793
+ version: localFoundation.version,
794
+ files: foundationFiles,
795
+ metadata: {
796
+ ...(fGitSha ? { publishedFromGitSha: fGitSha } : {}),
797
+ ...(typeof fGitDirty === 'boolean' ? { publishedFromGitDirty: fGitDirty } : {}),
798
+ },
799
+ },
800
+ })
801
+ say.ok(`Foundation uploaded.`)
802
+ }
803
+
921
804
  // Pre-flight against the Worker. Surfaces "foundation not published" /
922
805
  // "runtime not found" / namespace mismatch BEFORE we ship content.
923
806
  say.info('Validating foundation + runtime…')
@@ -935,36 +818,60 @@ export async function deploy(args = []) {
935
818
  process.exit(1)
936
819
  }
937
820
 
938
- // Asset pipeline upload dist/assets/* + favicon + fonts to S3, then
939
- // rewrite each locale's siteContent so semantic-parser resolves CDN URLs
940
- // at render time. Assets themselves are locale-shared (they live in
941
- // dist/assets/ regardless of language), so the diff/upload runs once
942
- // and the rewrite walks every locale's content tree in localeContents.
821
+ // Collect compiled collection JSON files from dist/data/. The framework
822
+ // emits these for `collection:` data sources `<name>.json` cascade
823
+ // payloads plus per-record `<name>/<slug>.json` files when `deferred:` is
824
+ // declared. Editor publish has no equivalent (collections live in the DB);
825
+ // CLI sites need them shipped as static R2 objects.
826
+ //
827
+ // Read BEFORE the asset pipeline so the asset scan can pick up image
828
+ // refs in collection JSON (e.g. `article.image: "/covers/foo.svg"`)
829
+ // and the rewrite can swap them for CDN URLs alongside locale content.
830
+ const dataFiles = await collectDataFiles(distDir)
831
+ // Decode each data file as JSON so the asset scan can walk the tree;
832
+ // mutated in place by the rewrite step. Re-stringified before publish.
833
+ const dataFileObjects = {}
834
+ for (const [k, raw] of Object.entries(dataFiles)) {
835
+ try {
836
+ dataFileObjects[k] = JSON.parse(raw)
837
+ } catch {
838
+ dataFileObjects[k] = null // unparseable — skip rewrite, ship as-is
839
+ }
840
+ }
841
+ if (Object.keys(dataFiles).length > 0) {
842
+ say.dim(`Data files : ${Object.keys(dataFiles).length} (collection JSON)`)
843
+ }
844
+
845
+ // Asset pipeline — upload dist/assets/* + favicon + fonts + content-scan
846
+ // hits (public/, data file refs) to S3, then rewrite each locale's
847
+ // siteContent + each parsed data file so the runtime resolves CDN URLs at
848
+ // render time. Assets are locale-shared (they live in dist/assets/ +
849
+ // public/ regardless of language); diff/upload runs once and the rewrite
850
+ // walks every locale's content tree + every data-file JSON tree.
943
851
  // Skipped with --skip-assets.
944
852
  if (!skipAssets) {
945
853
  await uploadAssetsAndRewriteContent({
946
854
  siteDir,
947
855
  localeContents,
856
+ dataFileObjects,
948
857
  siteYml,
949
858
  theme,
950
859
  backendUrl,
951
860
  cliToken,
952
861
  siteId: siteIdResolved,
953
862
  })
863
+ // Re-stringify any data-file JSON that the rewrite step mutated, so the
864
+ // publish payload below sees the rewritten URLs. Untouched files round-
865
+ // trip identically.
866
+ for (const k of Object.keys(dataFiles)) {
867
+ if (dataFileObjects[k] !== null) {
868
+ dataFiles[k] = JSON.stringify(dataFileObjects[k])
869
+ }
870
+ }
954
871
  } else {
955
872
  say.dim('Skipping asset upload (--skip-assets).')
956
873
  }
957
874
 
958
- // Collect compiled collection JSON files from dist/data/. The framework
959
- // emits these for `collection:` data sources — `<name>.json` cascade
960
- // payloads plus per-record `<name>/<slug>.json` files when `deferred:` is
961
- // declared. Editor publish has no equivalent (collections live in the DB);
962
- // CLI sites need them shipped as static R2 objects.
963
- const dataFiles = await collectDataFiles(distDir)
964
- if (Object.keys(dataFiles).length > 0) {
965
- say.dim(`Data files : ${Object.keys(dataFiles).length} (collection JSON)`)
966
- }
967
-
968
875
  say.info('Publishing…')
969
876
  const publishPayload = {
970
877
  foundation,
@@ -1039,6 +946,107 @@ export async function deploy(args = []) {
1039
946
  }
1040
947
  }
1041
948
 
949
+ // ─── Static-host deploy (S3+CloudFront, etc.) ─────────────────
950
+ //
951
+ // Distinct from the uniweb-edge flow above. Picked when site.yml's
952
+ // `deploy.host` (or --host flag) names a static-host adapter
953
+ // registered in @uniweb/build/hosts. Always runs `uniweb build`
954
+ // (bundle mode + prerender) first, then hands dist/ to the adapter's
955
+ // deploy hook for upload + invalidation.
956
+ //
957
+ // See kb/framework/plans/static-host-deploy-adapters.md.
958
+
959
+ async function deployStaticHost(siteDir, siteYml, hostName, { dryRun }) {
960
+ let getAdapter
961
+ try {
962
+ ({ getAdapter } = await import('@uniweb/build/hosts'))
963
+ } catch (err) {
964
+ say.err('Failed to load host adapter registry from @uniweb/build/hosts.')
965
+ say.dim(err.message)
966
+ process.exit(1)
967
+ }
968
+
969
+ let adapter
970
+ try {
971
+ adapter = getAdapter(hostName)
972
+ } catch (err) {
973
+ say.err(err.message)
974
+ say.dim(`Set deploy.host in site.yml or pass --host=<name>. See \`uniweb deploy --help\`.`)
975
+ process.exit(1)
976
+ }
977
+
978
+ if (typeof adapter.deploy !== 'function') {
979
+ say.err(`Host adapter '${hostName}' does not implement a deploy step.`)
980
+ say.dim(`Build with \`uniweb build --host=${hostName}\` and upload \`dist/\` manually,`)
981
+ say.dim(`or use a host whose adapter ships a deploy hook (e.g., s3-cloudfront).`)
982
+ process.exit(1)
983
+ }
984
+
985
+ const deployConfig = siteYml.deploy || {}
986
+ const distDir = join(siteDir, 'dist')
987
+
988
+ if (dryRun) {
989
+ say.info(`Dry run — would deploy via host adapter: ${c.bold}${adapter.name}${c.reset}`)
990
+ say.dim(`Site dir : ${siteDir}`)
991
+ say.dim(`dist/ : ${existsSync(distDir) ? 'exists (would not rebuild)' : 'missing (would build)'}`)
992
+ say.dim(`deploy.bucket : ${deployConfig.bucket || '(unset)'}`)
993
+ say.dim(`deploy.distId : ${deployConfig.distributionId || '(unset)'}`)
994
+ say.dim(`deploy.region : ${deployConfig.region || '(unset)'}`)
995
+ say.dim(`deploy.profile : ${deployConfig.profile || '(default AWS chain)'}`)
996
+ return
997
+ }
998
+
999
+ // Always rebuild — the static-host flow expects fresh dist/ on every
1000
+ // deploy. UNIWEB_SKIP_BUILD env var lets CI / dev loops reuse an
1001
+ // existing build (mirrors the uniweb-edge flow's escape hatch).
1002
+ const skipBuild = parseBoolEnv('UNIWEB_SKIP_BUILD')
1003
+ if (skipBuild) {
1004
+ if (!existsSync(distDir)) {
1005
+ say.err('UNIWEB_SKIP_BUILD is set but dist/ does not exist.')
1006
+ process.exit(1)
1007
+ }
1008
+ say.info('UNIWEB_SKIP_BUILD set — reusing existing dist/.')
1009
+ } else {
1010
+ say.info(`Building site (host: ${adapter.name})…`)
1011
+ console.log('')
1012
+ try {
1013
+ execSync(
1014
+ `node ${JSON.stringify(process.argv[1])} build --bundle --host ${JSON.stringify(adapter.name)}`,
1015
+ { cwd: siteDir, stdio: 'inherit' }
1016
+ )
1017
+ } catch {
1018
+ say.err('Build failed. See output above.')
1019
+ process.exit(1)
1020
+ }
1021
+ if (!existsSync(distDir)) {
1022
+ say.err('Build did not produce dist/.')
1023
+ process.exit(1)
1024
+ }
1025
+ console.log('')
1026
+ }
1027
+
1028
+ // Hand off to the adapter. DeployError is the structured shape from
1029
+ // @uniweb/build/hosts/s3-cloudfront — translate to user-facing output.
1030
+ try {
1031
+ await adapter.deploy({
1032
+ distDir,
1033
+ deployConfig,
1034
+ env: process.env,
1035
+ log: (m) => console.log(m),
1036
+ })
1037
+ } catch (err) {
1038
+ if (err && err.name === 'DeployError') {
1039
+ say.err(err.message)
1040
+ if (err.hint) {
1041
+ console.log('')
1042
+ console.log(err.hint)
1043
+ }
1044
+ process.exit(1)
1045
+ }
1046
+ throw err
1047
+ }
1048
+ }
1049
+
1042
1050
  // ─── site.yml ──────────────────────────────────────────────
1043
1051
 
1044
1052
  async function readSiteYml(path) {
@@ -1115,7 +1123,11 @@ async function writeSiteYmlUpdates(path, current, updates) {
1115
1123
 
1116
1124
  // ─── Resolve site dir + runtime ────────────────────────────
1117
1125
 
1118
- async function resolveSiteDir(args) {
1126
+ // Exported so `uniweb export` (commands/export.js) can reuse the same
1127
+ // site-discovery logic without duplicating it. `verb` is the command
1128
+ // being run ("deploy" or "export"); it appears in the error messages
1129
+ // so the user gets accurate guidance.
1130
+ export async function resolveSiteDir(args, verb = 'deploy') {
1119
1131
  const cwd = process.cwd()
1120
1132
  const prefix = getCliPrefix()
1121
1133
 
@@ -1128,16 +1140,16 @@ async function resolveSiteDir(args) {
1128
1140
  if (sites.length === 1) return resolve(workspaceRoot, sites[0])
1129
1141
  if (sites.length > 1) {
1130
1142
  if (isNonInteractive(args)) {
1131
- say.err('Multiple sites found. Specify which one to deploy.')
1143
+ say.err(`Multiple sites found. Specify which one to ${verb}.`)
1132
1144
  console.log('')
1133
1145
  for (const s of sites) {
1134
- console.log(` ${c.cyan}cd ${s} && ${prefix} deploy${c.reset}`)
1146
+ console.log(` ${c.cyan}cd ${s} && ${prefix} ${verb}${c.reset}`)
1135
1147
  }
1136
1148
  process.exit(1)
1137
1149
  }
1138
1150
  const choice = await promptSelect('Which site?', sites)
1139
1151
  if (!choice) {
1140
- console.log('\nDeploy cancelled.')
1152
+ console.log(`\n${verb.charAt(0).toUpperCase() + verb.slice(1)} cancelled.`)
1141
1153
  process.exit(0)
1142
1154
  }
1143
1155
  return resolve(workspaceRoot, choice)
@@ -1145,13 +1157,17 @@ async function resolveSiteDir(args) {
1145
1157
  }
1146
1158
 
1147
1159
  say.err('No site found in this workspace.')
1148
- say.dim('`deploy` publishes a built Uniweb site to the hosting platform.')
1160
+ if (verb === 'export') {
1161
+ say.dim('`export` produces a self-contained dist/ artifact for third-party hosting.')
1162
+ } else {
1163
+ say.dim('`deploy` publishes a built Uniweb site to the hosting platform.')
1164
+ }
1149
1165
  process.exit(1)
1150
1166
  }
1151
1167
 
1152
1168
  async function fetchLatestRuntime(workerUrl) {
1153
1169
  try {
1154
- const res = await fetch(`${workerUrl}/api/runtime/latest`)
1170
+ const res = await fetch(`${workerUrl}/runtime/latest`)
1155
1171
  if (!res.ok) return null
1156
1172
  const body = await res.json()
1157
1173
  return body.version || null
@@ -1307,6 +1323,50 @@ async function callPublish({ url, token, body }) {
1307
1323
  return res.json()
1308
1324
  }
1309
1325
 
1326
+ // ─── Site-bound foundation upload (Phase 4d) ────────────────
1327
+
1328
+ /**
1329
+ * Walk a built foundation's `dist/` directory and return `{ relPath: base64Bytes }`
1330
+ * — the shape `POST /foundations` expects in its `files` field.
1331
+ */
1332
+ async function collectFoundationDistFiles(distDir) {
1333
+ if (!existsSync(distDir)) {
1334
+ say.err(`Foundation dist/ not found at ${distDir}.`)
1335
+ process.exit(1)
1336
+ }
1337
+ const files = {}
1338
+ const entries = await readdir(distDir, { withFileTypes: true, recursive: true })
1339
+ for (const entry of entries) {
1340
+ if (!entry.isFile()) continue
1341
+ const fullPath = join(entry.parentPath, entry.name)
1342
+ const relPath = relative(distDir, fullPath).split(sep).join('/')
1343
+ const bytes = await readFile(fullPath)
1344
+ files[relPath] = bytes.toString('base64')
1345
+ }
1346
+ return files
1347
+ }
1348
+
1349
+ async function callFoundationUpload({ url, token, body }) {
1350
+ const res = await fetch(url, {
1351
+ method: 'POST',
1352
+ headers: {
1353
+ 'Content-Type': 'application/json',
1354
+ Authorization: `Bearer ${token}`,
1355
+ },
1356
+ body: JSON.stringify(body),
1357
+ })
1358
+ if (!res.ok) {
1359
+ let err = `HTTP ${res.status}`
1360
+ try {
1361
+ const j = await res.json()
1362
+ err = j.error || err
1363
+ } catch {}
1364
+ say.err(`Foundation upload failed: ${err}`)
1365
+ process.exit(1)
1366
+ }
1367
+ return res.json()
1368
+ }
1369
+
1310
1370
  // ─── Asset pipeline (Phase 4) ──────────────────────────────
1311
1371
 
1312
1372
  /**
@@ -1318,13 +1378,37 @@ async function callPublish({ url, token, body }) {
1318
1378
  * siteContent is mutated in place so the caller's publish payload picks up
1319
1379
  * the rewritten nodes without passing anything back.
1320
1380
  */
1321
- async function uploadAssetsAndRewriteContent({ siteDir, localeContents, siteYml, theme, backendUrl, cliToken, siteId }) {
1381
+ async function uploadAssetsAndRewriteContent({ siteDir, localeContents, dataFileObjects = {}, siteYml, theme, backendUrl, cliToken, siteId }) {
1322
1382
  const distAssetsDir = join(siteDir, 'dist', 'assets')
1323
1383
  const hasDistAssets = existsSync(distAssetsDir)
1324
1384
 
1325
1385
  // 1. Enumerate local files + read size.
1326
1386
  const localFiles = hasDistAssets ? await walkAssetDir(distAssetsDir) : []
1327
1387
 
1388
+ // 1a. Content-scan: walk site-content.json (and locale variants) for any
1389
+ // asset references (image/document src/href) and resolve absolute
1390
+ // paths to local files under `dist/` or `public/`. This catches static
1391
+ // assets the author placed in `public/covers/`, `public/images/`, etc.
1392
+ // that the dist/assets walk above misses (vite's image-pipeline only
1393
+ // produces files for refs that go through it). Each resolved file
1394
+ // joins the upload pipeline; the rewrite step at the end maps every
1395
+ // such reference to its CDN identifier so content stays portable
1396
+ // across site delete / template extraction.
1397
+ const contentRefMap = await scanContentForAssetRefs(localeContents, dataFileObjects, siteDir)
1398
+ const seenPaths = new Set(localFiles.map((f) => f.fullPath))
1399
+ for (const [, info] of contentRefMap) {
1400
+ if (seenPaths.has(info.resolvedPath)) continue
1401
+ const ext = (info.filename.split('.').pop() || '').toLowerCase()
1402
+ const st = await stat(info.resolvedPath)
1403
+ localFiles.push({
1404
+ filename: info.filename,
1405
+ fullPath: info.resolvedPath,
1406
+ size: st.size,
1407
+ mime: MIME_BY_EXT[ext] || 'application/octet-stream',
1408
+ })
1409
+ seenPaths.add(info.resolvedPath)
1410
+ }
1411
+
1328
1412
  // 1a. Favicon — sits at site root, not in dist/assets. Ship it through
1329
1413
  // the same pipeline so it ends up at assets.uniweb.app with an
1330
1414
  // identifier; config.favicon gets set further down.
@@ -1444,14 +1528,39 @@ async function uploadAssetsAndRewriteContent({ siteDir, localeContents, siteYml,
1444
1528
  }
1445
1529
 
1446
1530
  // 6. Rewrite each locale's content in place. Image/document nodes whose
1447
- // src/href references a local /assets/{filename} get an info.identifier
1448
- // pointing to the uploaded (or reused) asset. Walking every locale
1449
- // means translated content (which still references the same image
1450
- // files via the source ProseMirror tree) gets the same rewrite.
1531
+ // src/href references an uploaded asset get an info.identifier pointing
1532
+ // to the CDN. Walking every locale means translated content (which
1533
+ // still references the same image files via the source ProseMirror
1534
+ // tree) gets the same rewrite.
1535
+ //
1536
+ // Two lookup paths:
1537
+ // - byOriginalRef: full src/href string → identifier (covers static
1538
+ // public/ assets like `/covers/foo.svg` and dist/-resolved refs)
1539
+ // - byFilename: legacy match for `assets/{filename}` shape — kept
1540
+ // for back-compat with content authored against the old vite-
1541
+ // produced `/assets/...` URLs.
1451
1542
  const byFilenameAll = new Map([...reused, ...fresh])
1543
+ const byOriginalRef = new Map()
1544
+ for (const [ref, info] of contentRefMap) {
1545
+ const id = byFilenameAll.get(info.filename)
1546
+ if (id) byOriginalRef.set(ref, id)
1547
+ }
1452
1548
  let rewritten = 0
1453
1549
  for (const lang of Object.keys(localeContents)) {
1454
- rewritten += rewriteAssetReferences(localeContents[lang], byFilenameAll)
1550
+ rewritten += rewriteAssetReferences(localeContents[lang], byFilenameAll, byOriginalRef)
1551
+ }
1552
+ // Data files: walk the JSON tree. Two patterns coexist in collection
1553
+ // payloads:
1554
+ // - Flat fields (e.g. `article.image: "/covers/foo.svg"`) → replace
1555
+ // the string with a resolveAssetCdnUrl(identifier). The runtime
1556
+ // reads these as plain URLs, so rewriting at deploy time is the
1557
+ // simplest path to portability.
1558
+ // - Nested ProseMirror sub-trees (e.g. `article.content`) → use the
1559
+ // existing image/document node rewrite (sets `attrs.info.identifier`).
1560
+ for (const k of Object.keys(dataFileObjects)) {
1561
+ if (dataFileObjects[k] === null) continue
1562
+ rewritten += rewriteFlatAssetUrls(dataFileObjects[k], byOriginalRef)
1563
+ rewritten += rewriteAssetReferences(dataFileObjects[k], byFilenameAll, byOriginalRef)
1455
1564
  }
1456
1565
  if (rewritten > 0) {
1457
1566
  say.dim(`Rewrote ${rewritten} asset reference(s) across ${Object.keys(localeContents).length} locale(s).`)
@@ -1725,44 +1834,68 @@ async function runInPool(items, concurrency, worker) {
1725
1834
 
1726
1835
  /**
1727
1836
  * Walk siteContent (ProseMirror-ish JSON tree) and rewrite any node whose
1728
- * `attrs.src` or `attrs.href` references a local `/assets/{filename}` that
1729
- * we've uploaded/reused. Sets `attrs.info.identifier` so semantic-parser
1730
- * resolves the real CDN URL (and optimized variants) at render time.
1837
+ * `attrs.src` or `attrs.href` references an uploaded/reused asset. Sets
1838
+ * `attrs.info.identifier` so semantic-parser resolves the real CDN URL
1839
+ * (and optimized variants) at render time.
1840
+ *
1841
+ * Two lookup paths, in order:
1842
+ * 1. `byOriginalRef` — full src/href string → identifier. Covers static
1843
+ * public/ assets (`/covers/foo.svg`, `/images/foo.png`) and any
1844
+ * content-scan-resolved file. Decouples assets from site lifecycle
1845
+ * (templates can extract content + identifier; assets stay on CDN).
1846
+ * 2. `byFilename` (legacy) — only fires when the path matches the old
1847
+ * `/assets/{filename}` shape. Kept so re-deploys of content authored
1848
+ * against pre-content-scan CLIs still work.
1731
1849
  *
1732
1850
  * Returns the number of rewrites performed — useful for reporting, and to
1733
1851
  * detect "nothing matched" (likely a content-shape mismatch worth flagging).
1734
1852
  */
1735
- function rewriteAssetReferences(node, byFilename) {
1853
+ function rewriteAssetReferences(node, byFilename, byOriginalRef = new Map()) {
1736
1854
  let count = 0
1737
1855
  const walk = (n) => {
1738
1856
  if (!n || typeof n !== 'object') return
1739
1857
  if (Array.isArray(n)) { for (const child of n) walk(child); return }
1740
1858
  if (n.attrs && typeof n.attrs === 'object') {
1741
- const srcRef = pickAssetRef(n.attrs.src)
1742
- const hrefRef = pickAssetRef(n.attrs.href)
1743
- const ref = srcRef || hrefRef
1744
- if (ref) {
1745
- const identifier = byFilename.get(ref)
1746
- if (identifier) {
1747
- n.attrs.info = {
1748
- ...(n.attrs.info || {}),
1749
- identifier,
1750
- contentType: 'website',
1751
- viewType: 'profile',
1752
- }
1753
- // Clear the local Vite-hashed path so the runtime resolves via
1754
- // info.identifier (→ assets.uniweb.app CDN) instead of requesting
1755
- // a non-existent /assets/... file from the site host.
1756
- if (srcRef) n.attrs.src = null
1757
- if (hrefRef) n.attrs.href = null
1758
- // Match the Editor shape: plain `image` nodes skip identifier
1759
- // resolution in older runtimes; `ImageBlock` routes through
1760
- // parseImgBlock which reads info.identifier and fills url.
1761
- if (n.type === 'image' && n.attrs.role !== 'icon') {
1762
- n.type = 'ImageBlock'
1763
- }
1764
- count++
1859
+ // Prefer full-ref lookup (covers static + dist refs uniformly);
1860
+ // fall back to legacy `assets/{filename}` extraction.
1861
+ let identifier = null
1862
+ let srcMatched = false
1863
+ let hrefMatched = false
1864
+ if (typeof n.attrs.src === 'string' && byOriginalRef.has(n.attrs.src)) {
1865
+ identifier = byOriginalRef.get(n.attrs.src)
1866
+ srcMatched = true
1867
+ } else if (typeof n.attrs.href === 'string' && byOriginalRef.has(n.attrs.href)) {
1868
+ identifier = byOriginalRef.get(n.attrs.href)
1869
+ hrefMatched = true
1870
+ } else {
1871
+ const srcRef = pickAssetRef(n.attrs.src)
1872
+ const hrefRef = pickAssetRef(n.attrs.href)
1873
+ const ref = srcRef || hrefRef
1874
+ if (ref) {
1875
+ identifier = byFilename.get(ref) || null
1876
+ srcMatched = !!srcRef
1877
+ hrefMatched = !srcRef && !!hrefRef
1878
+ }
1879
+ }
1880
+ if (identifier) {
1881
+ n.attrs.info = {
1882
+ ...(n.attrs.info || {}),
1883
+ identifier,
1884
+ contentType: 'website',
1885
+ viewType: 'profile',
1765
1886
  }
1887
+ // Clear the local path so the runtime resolves via info.identifier
1888
+ // (→ assets.uniweb.app CDN) instead of requesting a non-existent
1889
+ // file from the site host.
1890
+ if (srcMatched) n.attrs.src = null
1891
+ if (hrefMatched) n.attrs.href = null
1892
+ // Match the Editor shape: plain `image` nodes skip identifier
1893
+ // resolution in older runtimes; `ImageBlock` routes through
1894
+ // parseImgBlock which reads info.identifier and fills url.
1895
+ if (n.type === 'image' && n.attrs.role !== 'icon') {
1896
+ n.type = 'ImageBlock'
1897
+ }
1898
+ count++
1766
1899
  }
1767
1900
  }
1768
1901
  for (const v of Object.values(n)) if (typeof v === 'object') walk(v)
@@ -1778,6 +1911,153 @@ function pickAssetRef(v) {
1778
1911
  return m ? m[1] : null
1779
1912
  }
1780
1913
 
1914
+ /**
1915
+ * Walk every locale's content for `attrs.src` and `attrs.href` strings, and
1916
+ * resolve absolute-path refs (e.g. `/covers/foo.svg`) to local files under
1917
+ * the site root.
1918
+ *
1919
+ * Resolution order per ref:
1920
+ * 1. `dist/{path}` — vite outputs, link-mode collection JSON, etc.
1921
+ * 2. `public/{path}` — static author-placed assets (covers, images).
1922
+ *
1923
+ * Returns Map<originalRef, { resolvedPath, filename }> where:
1924
+ * - `originalRef` — the exact src/href string from content (used as the
1925
+ * lookup key during rewrite).
1926
+ * - `resolvedPath` — absolute path on disk (used for upload).
1927
+ * - `filename` — basename, used as the assets-server upload filename.
1928
+ * Server keys by (siteId, filename); collisions across
1929
+ * paths with the same basename are flagged as warnings.
1930
+ *
1931
+ * Skips:
1932
+ * - Non-string values, refs that don't start with `/`, protocol-relative
1933
+ * refs (`//cdn.example.com/...`), and external URLs.
1934
+ * - Refs starting with `/api/` or `/_` (worker-internal paths, never
1935
+ * local files).
1936
+ * - Nodes already rewritten with `attrs.info.identifier` set (re-deploy).
1937
+ */
1938
+ async function scanContentForAssetRefs(localeContents, dataFileObjects, siteDir) {
1939
+ const candidates = new Set()
1940
+ for (const lang of Object.keys(localeContents)) {
1941
+ walkContentForAssetRefs(localeContents[lang], candidates)
1942
+ }
1943
+ // Also walk parsed collection JSON files. These contain BOTH ProseMirror-
1944
+ // shaped sub-trees (article.content) AND flat string fields (article.image,
1945
+ // article.cover, etc.). The walker captures both: any string-valued src/
1946
+ // href/image/cover/thumbnail/icon/poster field, plus any string anywhere
1947
+ // that looks like an absolute path with a known media extension.
1948
+ for (const k of Object.keys(dataFileObjects || {})) {
1949
+ if (dataFileObjects[k] !== null) {
1950
+ walkContentForAssetRefs(dataFileObjects[k], candidates)
1951
+ }
1952
+ }
1953
+
1954
+ const results = new Map()
1955
+ const filenameToRef = new Map() // detect collisions (same basename, different path)
1956
+ for (const ref of candidates) {
1957
+ if (!isResolvableContentRef(ref)) continue
1958
+ const cleanPath = ref.split('?')[0].split('#')[0].slice(1) // drop leading '/'
1959
+ const distCandidate = join(siteDir, 'dist', cleanPath)
1960
+ const publicCandidate = join(siteDir, 'public', cleanPath)
1961
+ let resolvedPath = null
1962
+ if (existsSync(distCandidate)) {
1963
+ try { if ((await stat(distCandidate)).isFile()) resolvedPath = distCandidate } catch {}
1964
+ }
1965
+ if (!resolvedPath && existsSync(publicCandidate)) {
1966
+ try { if ((await stat(publicCandidate)).isFile()) resolvedPath = publicCandidate } catch {}
1967
+ }
1968
+ if (!resolvedPath) continue
1969
+ const filename = resolvedPath.split(sep).pop()
1970
+ const prior = filenameToRef.get(filename)
1971
+ if (prior && prior !== resolvedPath) {
1972
+ // Two different files want the same upload filename — server keys by
1973
+ // filename so the second would clobber the first. Skip + warn rather
1974
+ // than silently overwrite. Caller can rename the file or move one
1975
+ // into a vite-processed path to disambiguate via content hashing.
1976
+ say.warn(
1977
+ `Asset filename collision: "${filename}" exists at multiple paths ` +
1978
+ `(${prior}, ${resolvedPath}). Skipping the second; rename to disambiguate.`
1979
+ )
1980
+ continue
1981
+ }
1982
+ filenameToRef.set(filename, resolvedPath)
1983
+ results.set(ref, { resolvedPath, filename })
1984
+ }
1985
+ return results
1986
+ }
1987
+
1988
+ // Field names commonly used for media in collection JSON. The walker
1989
+ // collects any absolute-path string under these keys as a potential asset
1990
+ // reference. ProseMirror image/link nodes are caught separately via attrs.
1991
+ const FLAT_ASSET_FIELDS = new Set([
1992
+ 'src', 'href', 'image', 'cover', 'thumbnail', 'icon', 'poster', 'logo',
1993
+ 'avatar', 'photo', 'banner', 'background',
1994
+ ])
1995
+
1996
+ function walkContentForAssetRefs(node, refs) {
1997
+ if (!node || typeof node !== 'object') return
1998
+ if (Array.isArray(node)) { for (const child of node) walkContentForAssetRefs(child, refs); return }
1999
+ if (node.attrs && typeof node.attrs === 'object') {
2000
+ // Skip nodes already rewritten in a prior deploy — those have an
2001
+ // identifier and the runtime resolves them through the CDN already.
2002
+ if (!node.attrs.info?.identifier) {
2003
+ if (typeof node.attrs.src === 'string') refs.add(node.attrs.src)
2004
+ if (typeof node.attrs.href === 'string') refs.add(node.attrs.href)
2005
+ }
2006
+ }
2007
+ // Flat fields: collection-shaped objects (e.g. an article record) often
2008
+ // carry media URLs as plain string fields rather than ProseMirror nodes.
2009
+ // Capture absolute-path values under known keys.
2010
+ for (const [k, v] of Object.entries(node)) {
2011
+ if (typeof v === 'string' && FLAT_ASSET_FIELDS.has(k) && isResolvableContentRef(v)) {
2012
+ refs.add(v)
2013
+ } else if (typeof v === 'object') {
2014
+ walkContentForAssetRefs(v, refs)
2015
+ }
2016
+ }
2017
+ }
2018
+
2019
+ /**
2020
+ * Walk an arbitrary JSON tree and replace any string equal to a key in
2021
+ * `byOriginalRef` (and not already a CDN URL) with the asset's CDN URL.
2022
+ * Used for collection JSON files where image refs are flat string fields
2023
+ * (e.g. `article.image: "/covers/foo.svg"`) rather than ProseMirror nodes.
2024
+ *
2025
+ * Returns the number of replacements performed.
2026
+ */
2027
+ function rewriteFlatAssetUrls(node, byOriginalRef) {
2028
+ let count = 0
2029
+ const walk = (n, parent, key) => {
2030
+ if (n == null) return
2031
+ if (typeof n === 'string') {
2032
+ const id = byOriginalRef.get(n)
2033
+ if (id && parent != null && key != null) {
2034
+ parent[key] = resolveAssetCdnUrl(id)
2035
+ count++
2036
+ }
2037
+ return
2038
+ }
2039
+ if (typeof n !== 'object') return
2040
+ if (Array.isArray(n)) {
2041
+ for (let i = 0; i < n.length; i++) walk(n[i], n, i)
2042
+ return
2043
+ }
2044
+ for (const [k, v] of Object.entries(n)) walk(v, n, k)
2045
+ }
2046
+ walk(node, null, null)
2047
+ return count
2048
+ }
2049
+
2050
+ function isResolvableContentRef(ref) {
2051
+ if (typeof ref !== 'string' || !ref) return false
2052
+ // Absolute-path only — relative paths (`./foo`, `foo`) are content-author
2053
+ // shorthand handled elsewhere; URLs (`http://`, `//cdn`) never resolve to
2054
+ // local files; worker-internal paths (`/api/`, `/_`) aren't asset content.
2055
+ if (!ref.startsWith('/')) return false
2056
+ if (ref.startsWith('//')) return false
2057
+ if (ref.startsWith('/api/') || ref.startsWith('/_')) return false
2058
+ return true
2059
+ }
2060
+
1781
2061
  // ─── Loopback listener (review path) ───────────────────────
1782
2062
 
1783
2063
  /**