uniweb 0.12.8 → 0.12.10

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
@@ -310,14 +310,22 @@ Start with local files deployed anywhere. The same foundation works across all t
310
310
 
311
311
  ## Deployment
312
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:
313
+ A Uniweb project produces two artifacts — a **site** (content) and a **foundation** (code) — and they don't have to ship together. Two top-level modes:
314
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.
315
+ - **Standalone mode** — site and foundation built into one self-contained `dist/`, deployed to any static host.
316
+ - **Linked mode** — the foundation is a separate file the site loads at runtime, with two flavours:
317
+ - **Site-bound** — the foundation belongs to one site and rides with it (`foundation: ~self/<name>@<version>` in `site.yml`).
318
+ - **Cataloged** — the foundation is a catalog product, published once and licensed to consuming sites (`foundation: '@<org>/<name>@<version>'`).
317
319
 
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.
320
+ `uniweb publish` ships a cataloged foundation; `uniweb deploy` ships a site (and, for site-bound, the foundation along with it). Most projects start standalone or site-bound and grow into cataloged when a foundation needs to serve more than one site.
319
321
 
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.
322
+ Where can you deploy?
323
+
324
+ - **Free static hosts** — Vercel, Cloudflare Pages, Netlify, GitHub Pages — work great when you have a site to publish. Built-in adapters: `vercel`, `cloudflare-pages`, `netlify`, `github-pages`. Lifecycle is Git-driven: connect your repo, the host runs `uniweb build` on each push, serves `dist/`. The framework auto-detects the CI host and emits the right helper files.
325
+ - **AWS S3 + CloudFront** — `uniweb deploy --host=s3-cloudfront` builds, syncs, and invalidates in one command.
326
+ - **Uniweb hosting** — paid (starts at $14/month per site). Always serves linked sites with JIT prerender, edge SSR, locale-aware routing, foundation/runtime version propagation, and the multi-tenant CMS for non-technical content authors via the visual editor. The right choice when foundation developers or agencies build for clients who manage their own content. The catalog is private and access-segregated — foundations are commercial products licensed by site, not packages on a public registry.
327
+
328
+ → **[Deploying](https://github.com/uniweb/docs/blob/main/development/deploying.md)** — the full menu: picking a deploy path (free vs paid), standalone vs linked, site-bound vs cataloged, the two-verb model, CI-detection, and per-host recipes.
321
329
 
322
330
  ---
323
331
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uniweb",
3
- "version": "0.12.8",
3
+ "version": "0.12.10",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,14 +41,14 @@
41
41
  "js-yaml": "^4.1.0",
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.0.0",
44
- "@uniweb/core": "0.7.10",
45
- "@uniweb/kit": "0.9.10",
46
- "@uniweb/runtime": "0.8.11"
44
+ "@uniweb/runtime": "0.8.13",
45
+ "@uniweb/kit": "0.9.11",
46
+ "@uniweb/core": "0.7.11"
47
47
  },
48
48
  "peerDependencies": {
49
- "@uniweb/build": "0.13.5",
50
- "@uniweb/content-reader": "1.1.10",
51
- "@uniweb/semantic-parser": "1.1.17"
49
+ "@uniweb/build": "0.14.2",
50
+ "@uniweb/semantic-parser": "1.1.17",
51
+ "@uniweb/content-reader": "1.1.10"
52
52
  },
53
53
  "peerDependenciesMeta": {
54
54
  "@uniweb/build": {
@@ -172,7 +172,7 @@ The `uniweb` block in `package.json` carries platform-specific configuration tha
172
172
  **Catalog vs site-bound foundations.** Two distribution intents share the same `dist/foundation.js` artifact:
173
173
 
174
174
  - A **catalog foundation** is a deliberate product — named, versioned, listed in the catalog, consumable by other developers' sites. Use `uniweb publish @org/name` for these. The CLI requires an explicit name argument so you don't accidentally catalog a foundation that was meant to be site-bound.
175
- - A **site-bound foundation** powers exactly one site. Don't run `uniweb publish` for it. Just run `uniweb deploy` from the site directory — the CLI auto-publishes your local foundation as part of the deploy, under a registry slot scoped to the site itself, with no naming ceremony. The foundation isn't visible in the catalog and isn't owned by the developer who happened to run the deploy.
175
+ - A **site-bound foundation** powers exactly one site. Don't run `uniweb publish` for it. Just run `uniweb deploy` from the site directory — the CLI auto-publishes your local foundation as part of the deploy, **uploaded with the site's other published assets** (per-site storage, never to the catalog). With no naming ceremony, no catalog visibility, and no developer-vs-site ownership confusion. To later promote the foundation to a catalog product, run `uniweb publish @org/name` from the foundation directory and update the site's `site.yml` to a versioned ref (`foundation: '@org/name@1.2.3'`).
176
176
 
177
177
  **On the split between `package.json::name` and `uniweb.id`:** the workspace name is what pnpm uses for `file:` linking and what `site.yml::foundation` references. The published id is what the registry stores. Keeping them separate means renaming on the registry (e.g. `marketing` → `marketing-pro`) is a one-shot `uniweb publish --name marketing-pro` — it persists to `uniweb.id` without touching the workspace.
178
178
 
@@ -35,6 +35,10 @@
35
35
  * uniweb build --target site # Explicitly build as site
36
36
  * uniweb build --prerender # Force pre-rendering
37
37
  * uniweb build --no-prerender # Skip pre-rendering
38
+ * uniweb build --host <name> # Pick the host adapter for this build's
39
+ * postBuild step (e.g. cloudflare-pages,
40
+ * s3-cloudfront, github-pages,
41
+ * generic-static). Default: cloudflare-pages.
38
42
  *
39
43
  * Internal flags (called by `uniweb deploy` / `uniweb export`):
40
44
  * --link # Data-only pipeline (Uniweb-edge)
@@ -540,7 +544,7 @@ async function resolveFoundationDirForSite(siteDir, siteConfig) {
540
544
  * Build a site
541
545
  */
542
546
  async function buildSite(projectDir, options = {}) {
543
- const { prerender = false, foundationDir, siteConfig = null } = options
547
+ const { prerender = false, foundationDir, siteConfig = null, host = null } = options
544
548
 
545
549
  info('Building site...')
546
550
 
@@ -613,6 +617,7 @@ async function buildSite(projectDir, options = {}) {
613
617
 
614
618
  const result = await prerenderSite(projectDir, {
615
619
  foundationDir: foundationDir || resolveFoundationDir(projectDir, siteConfig),
620
+ host,
616
621
  onProgress: (msg) => log(` ${colors.dim}${msg}${colors.reset}`)
617
622
  })
618
623
 
@@ -710,7 +715,7 @@ async function discoverWorkspacePackages(workspaceDir) {
710
715
  * Build all packages in a workspace
711
716
  */
712
717
  async function buildWorkspace(workspaceDir, options = {}) {
713
- const { prerenderFlag, noPrerenderFlag } = options
718
+ const { prerenderFlag, noPrerenderFlag, host = null } = options
714
719
 
715
720
  log(`${colors.cyan}${colors.bright}Building workspace...${colors.reset}`)
716
721
  log('')
@@ -757,7 +762,7 @@ async function buildWorkspace(workspaceDir, options = {}) {
757
762
  // Resolve foundation directory for this site
758
763
  const foundationDir = resolveFoundationDir(site.path, siteConfig)
759
764
 
760
- await buildSite(site.path, { prerender, foundationDir, siteConfig })
765
+ await buildSite(site.path, { prerender, foundationDir, siteConfig, host })
761
766
  log('')
762
767
  }
763
768
 
@@ -835,6 +840,27 @@ export async function build(args = []) {
835
840
  foundationDir = resolve(args[foundationDirIndex + 1])
836
841
  }
837
842
 
843
+ // --host names the host adapter for this build's prerender step.
844
+ // Default = 'cloudflare-pages' (resolved inside prerender.js, via the
845
+ // registry). Build does not read deploy.yml; that is the deploy
846
+ // orchestrator's job. See kb/framework/plans/static-host-deploy-adapters.md.
847
+ //
848
+ // `--host` with no value → interactive picker (errors in CI / non-TTY).
849
+ const { readFlagValue } = await import('../utils/args.js')
850
+ const hostFlag = readFlagValue(args, '--host')
851
+ let host = null
852
+ if (hostFlag === null) {
853
+ const { promptForHost } = await import('../utils/host-prompt.js')
854
+ try {
855
+ host = await promptForHost({ args })
856
+ } catch (err) {
857
+ error(err.message)
858
+ process.exit(1)
859
+ }
860
+ } else if (typeof hostFlag === 'string') {
861
+ host = hostFlag
862
+ }
863
+
838
864
  // Auto-detect project type if not specified
839
865
  if (!targetType) {
840
866
  targetType = detectProjectType(projectDir)
@@ -868,7 +894,7 @@ export async function build(args = []) {
868
894
  // Run appropriate build
869
895
  try {
870
896
  if (targetType === 'workspace') {
871
- await buildWorkspace(projectDir, { prerenderFlag, noPrerenderFlag })
897
+ await buildWorkspace(projectDir, { prerenderFlag, noPrerenderFlag, host })
872
898
  } else if (targetType === 'foundation') {
873
899
  await buildFoundation(projectDir)
874
900
  } else {
@@ -898,7 +924,7 @@ export async function build(args = []) {
898
924
  if (prerenderFlag) prerender = true
899
925
  if (noPrerenderFlag) prerender = false
900
926
 
901
- await buildSite(projectDir, { prerender, foundationDir, siteConfig })
927
+ await buildSite(projectDir, { prerender, foundationDir, siteConfig, host })
902
928
  }
903
929
  } catch (err) {
904
930
  error(err.message)
@@ -1,13 +1,23 @@
1
1
  /**
2
2
  * Deploy Command
3
3
  *
4
- * Deploys a site to uniweb-edge. Always runtime-linked: the edge serves a
5
- * runtime template + per-site base.html, with the foundation loaded by URL.
6
- * For static-host artifacts (no upload), see `uniweb export`.
4
+ * Deploys a site. Host is determined by the resolved deploy.yml target
5
+ * (or `--target <name>` / `--host <name>` flags). The default is `uniweb`:
7
6
  *
8
- * 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:
9
19
  * 1. Read site.yml → { site.id?, site.handle?, foundation, runtime? }.
10
- * 2. Resolve runtime (default: GET /api/runtime/latest from the Worker).
20
+ * 2. Resolve runtime (default: GET /runtime/latest from the Worker).
11
21
  * 3. ensureAuth() → bearer CLI JWT from ~/.uniweb/auth.json.
12
22
  * 4. Build `dist/` if missing.
13
23
  * 5. Load dist/site-content.json → extract `languages` for the capability
@@ -19,9 +29,9 @@
19
29
  * - publishToken returned → fast path.
20
30
  * - needsReview:true + reviewUrl → open browser, wait for callback,
21
31
  * consume { publishToken, siteId, handle }.
22
- * 9. POST Worker /api/publish/validate to confirm foundation + runtime
32
+ * 9. POST Worker /publish/check to confirm foundation + runtime
23
33
  * exist and the token's namespace claim matches.
24
- * 10. POST Worker /api/publish/process with the full payload.
34
+ * 10. POST Worker /publish with the full payload.
25
35
  * 11. On first-deploy create flow: write site.id + site.handle back into
26
36
  * site.yml so subsequent deploys fast-path.
27
37
  *
@@ -29,6 +39,10 @@
29
39
  * uniweb deploy Normal deploy (browser may open on first deploy)
30
40
  * uniweb deploy --dry-run Resolve everything but skip the Worker POST
31
41
  * uniweb deploy --no-auto-publish Don't auto-publish workspace-local foundation
42
+ * uniweb deploy --target <name> Pick a target from deploy.yml (default: deploy.yml's `default:`)
43
+ * uniweb deploy --host <name> Override the resolved target's host adapter
44
+ * (does not write to deploy.yml on success)
45
+ * uniweb deploy --no-save Skip the auto-save of lastDeploy in deploy.yml
32
46
  *
33
47
  * Internal escape hatches (UNIWEB_* env vars — see framework/cli/docs/env-vars.md):
34
48
  * UNIWEB_SKIP_BUILD=1 Reuse existing dist/ instead of rebuilding
@@ -48,6 +62,9 @@ import { execSync } from 'node:child_process'
48
62
  import yaml from 'js-yaml'
49
63
 
50
64
  import { detectFoundationType } from '@uniweb/build'
65
+ import { loadDeployYml, resolveTarget, recordLastDeploy } from '@uniweb/build/site'
66
+ import { promptForHost } from '../utils/host-prompt.js'
67
+ import { readFlagValue } from '../utils/args.js'
51
68
 
52
69
  import { ensureAuth, readAuth, decodeJwtPayload } from '../utils/auth.js'
53
70
  import { getBackendUrl, getRegistryUrl } from '../utils/config.js'
@@ -65,6 +82,7 @@ function splitRegistryRef(ref) {
65
82
  const m = /^(@[^/]+\/[^@]+|~[^/]+\/[^@]+|[^@]+)@(.+)$/.exec(ref)
66
83
  return m ? { name: m[1], version: m[2] } : null
67
84
  }
85
+
68
86
  import {
69
87
  findWorkspaceRoot,
70
88
  findSites,
@@ -404,6 +422,63 @@ export async function deploy(args = []) {
404
422
  // site.id / site.handle from prior deploys.
405
423
  const siteYmlPath = join(siteDir, 'site.yml')
406
424
  const siteYml = await readSiteYml(siteYmlPath)
425
+
426
+ // Host dispatch.
427
+ //
428
+ // Resolution order:
429
+ // 1. --target <name> picks a target from deploy.yml (full config:
430
+ // host + adapter-specific fields)
431
+ // 2. deploy.yml's `default:` target is used when no flag is given
432
+ // 3. With no deploy.yml at all, the implicit default is host: 'uniweb'
433
+ // 4. --host <name> is a one-off override of the resolved target's host
434
+ // and does NOT persist on success (see saveDeployTarget below).
435
+ //
436
+ // The default flow (`uniweb`) requires a `foundation:` declaration;
437
+ // static-host deploys don't, so this branch comes BEFORE the foundation
438
+ // check. See kb/framework/plans/static-host-deploy-adapters.md.
439
+ const targetFromFlag = readFlagValue(args, '--target')
440
+ let hostFromFlag = readFlagValue(args, '--host')
441
+ const noSave = args.includes('--no-save')
442
+
443
+ let deployYml
444
+ try {
445
+ deployYml = await loadDeployYml(siteDir)
446
+ } catch (err) {
447
+ say.err(err.message)
448
+ process.exit(1)
449
+ }
450
+ let resolved
451
+ try {
452
+ resolved = resolveTarget(deployYml, targetFromFlag || null)
453
+ } catch (err) {
454
+ say.err(err.message)
455
+ process.exit(1)
456
+ }
457
+ // --host with no value → interactive picker. Pre-selects the resolved
458
+ // target's host so Enter does the obvious thing.
459
+ if (hostFromFlag === null) {
460
+ try {
461
+ hostFromFlag = await promptForHost({ args, preselect: resolved.host })
462
+ } catch (err) {
463
+ say.err(err.message)
464
+ process.exit(1)
465
+ }
466
+ }
467
+ const host = hostFromFlag || resolved.host
468
+ const hostOverridden = !!hostFromFlag && hostFromFlag !== resolved.host
469
+ // Auto-save scope: 'off' from --no-save OR an ad-hoc --host override
470
+ // (we don't want a one-off experiment to rewrite the file).
471
+ const autoSave = noSave || hostOverridden ? 'off' : resolved.autoSave
472
+
473
+ if (host !== 'uniweb') {
474
+ await deployStaticHost(siteDir, host, resolved, {
475
+ dryRun,
476
+ autoSave,
477
+ hostOverridden,
478
+ })
479
+ return
480
+ }
481
+
407
482
  if (!siteYml.foundation) {
408
483
  say.err('site.yml is missing `foundation`.')
409
484
  say.dim('Add a line like: foundation: \'@uniweb/docs-foundation@0.1.20\'')
@@ -512,7 +587,7 @@ export async function deploy(args = []) {
512
587
  if (!runtimeVersion) {
513
588
  runtimeVersion = await fetchLatestRuntime(workerUrl)
514
589
  if (!runtimeVersion) {
515
- say.err('Could not resolve a runtime version (no runtime: in site.yml, /api/runtime/latest failed).')
590
+ say.err('Could not resolve a runtime version (no runtime: in site.yml, /runtime/latest failed).')
516
591
  process.exit(1)
517
592
  }
518
593
  say.dim(`Runtime: ${runtimeVersion} (latest; pin via \`runtime:\` in site.yml)`)
@@ -542,11 +617,12 @@ export async function deploy(args = []) {
542
617
  // serving edge, not here). Always --link: the edge serves a runtime
543
618
  // template + per-site base.html, never a self-contained vite bundle.
544
619
  //
545
- // For workspace-local foundations (Phase 2 resolution above),
546
- // UNIWEB_FOUNDATION_REF tells defineSiteConfig to use the resolved
547
- // registry ref instead of site.yml's literal value. Link mode doesn't
548
- // run vite at all, so the env var is harmless but passed through for
549
- // consistency with future work.
620
+ // Phase 4d: workspace-local foundations carry the `~self/{name}@{ver}`
621
+ // placeholder at this point; the canonical `~{siteId}/...` ref isn't
622
+ // known until authorize returns. Link mode doesn't run vite or fetch
623
+ // the foundation, so site-content.json's foundation field reflects
624
+ // whatever's in site.yml — that's fine because the publish payload
625
+ // overrides it with the canonical form post-authorize.
550
626
  //
551
627
  // Spawn the SAME CLI binary that's currently running rather than
552
628
  // `npx uniweb build` — npx walks node_modules and would resolve to
@@ -556,9 +632,7 @@ export async function deploy(args = []) {
556
632
  execSync(`node ${JSON.stringify(process.argv[1])} build --link`, {
557
633
  cwd: siteDir,
558
634
  stdio: 'inherit',
559
- env: foundationBuildOverride
560
- ? { ...process.env, UNIWEB_FOUNDATION_REF: foundationBuildOverride }
561
- : process.env,
635
+ env: process.env,
562
636
  })
563
637
  console.log('')
564
638
  } else if (!existsSync(contentPath)) {
@@ -693,9 +767,16 @@ export async function deploy(args = []) {
693
767
  // CLI can write `features:` back into site.yml accurately. Older
694
768
  // PHP that doesn't include this field is a no-op.
695
769
  mintedFeatures = Array.isArray(cb.features) ? cb.features : null
770
+ // Phase 4d: workspace-local foundation deploys on the create flow
771
+ // need the rewritten `~{siteId}/{name}@{ver}` ref + upload endpoint.
772
+ // PHP/unicloud put them in the finalize response; the web app
773
+ // forwards them to the loopback. Catalog-ref deploys leave them
774
+ // undefined and we fall back to the placeholder/derived URL below.
775
+ if (cb.foundationRef) foundation = cb.foundationRef
776
+ if (cb.foundationUploadUrl) foundationUploadUrl = cb.foundationUploadUrl
696
777
  // Review path: Worker URLs are implicit (we derive them from config).
697
- publishUrl = `${workerUrl}/api/publish/process`
698
- validateUrl = `${workerUrl}/api/publish/validate`
778
+ publishUrl = `${workerUrl}/publish`
779
+ validateUrl = `${workerUrl}/publish/check`
699
780
  } else {
700
781
  publishToken = authRes.publishToken
701
782
  siteIdResolved = authRes.siteId
@@ -731,7 +812,7 @@ export async function deploy(args = []) {
731
812
  // Phase 4d: upload site-bound foundation files directly. Replaces the
732
813
  // pre-Phase-4d `execSync('uniweb publish')` flow — we now know the
733
814
  // canonical `~{siteId}/{name}@{ver}` ref from authorize, and the worker's
734
- // /api/foundations endpoint accepts the publish token's siteId claim
815
+ // /foundations endpoint accepts the publish token's siteId claim
735
816
  // for this scope.
736
817
  if (localFoundation) {
737
818
  say.info(`Building foundation at ${localFoundation.relPath}…`)
@@ -749,7 +830,7 @@ export async function deploy(args = []) {
749
830
 
750
831
  say.info(`Uploading foundation as ${foundation}…`)
751
832
  const foundationFiles = await collectFoundationDistFiles(join(localFoundation.path, 'dist'))
752
- const foundationPublishUrl = foundationUploadUrl || `${workerUrl}/api/foundations`
833
+ const foundationPublishUrl = foundationUploadUrl || `${workerUrl}/foundations`
753
834
  const { gitSha: fGitSha, gitDirty: fGitDirty } = readGitState(localFoundation.path)
754
835
  await callFoundationUpload({
755
836
  url: foundationPublishUrl,
@@ -784,36 +865,60 @@ export async function deploy(args = []) {
784
865
  process.exit(1)
785
866
  }
786
867
 
787
- // Asset pipeline upload dist/assets/* + favicon + fonts to S3, then
788
- // rewrite each locale's siteContent so semantic-parser resolves CDN URLs
789
- // at render time. Assets themselves are locale-shared (they live in
790
- // dist/assets/ regardless of language), so the diff/upload runs once
791
- // and the rewrite walks every locale's content tree in localeContents.
868
+ // Collect compiled collection JSON files from dist/data/. The framework
869
+ // emits these for `collection:` data sources `<name>.json` cascade
870
+ // payloads plus per-record `<name>/<slug>.json` files when `deferred:` is
871
+ // declared. Editor publish has no equivalent (collections live in the DB);
872
+ // CLI sites need them shipped as static R2 objects.
873
+ //
874
+ // Read BEFORE the asset pipeline so the asset scan can pick up image
875
+ // refs in collection JSON (e.g. `article.image: "/covers/foo.svg"`)
876
+ // and the rewrite can swap them for CDN URLs alongside locale content.
877
+ const dataFiles = await collectDataFiles(distDir)
878
+ // Decode each data file as JSON so the asset scan can walk the tree;
879
+ // mutated in place by the rewrite step. Re-stringified before publish.
880
+ const dataFileObjects = {}
881
+ for (const [k, raw] of Object.entries(dataFiles)) {
882
+ try {
883
+ dataFileObjects[k] = JSON.parse(raw)
884
+ } catch {
885
+ dataFileObjects[k] = null // unparseable — skip rewrite, ship as-is
886
+ }
887
+ }
888
+ if (Object.keys(dataFiles).length > 0) {
889
+ say.dim(`Data files : ${Object.keys(dataFiles).length} (collection JSON)`)
890
+ }
891
+
892
+ // Asset pipeline — upload dist/assets/* + favicon + fonts + content-scan
893
+ // hits (public/, data file refs) to S3, then rewrite each locale's
894
+ // siteContent + each parsed data file so the runtime resolves CDN URLs at
895
+ // render time. Assets are locale-shared (they live in dist/assets/ +
896
+ // public/ regardless of language); diff/upload runs once and the rewrite
897
+ // walks every locale's content tree + every data-file JSON tree.
792
898
  // Skipped with --skip-assets.
793
899
  if (!skipAssets) {
794
900
  await uploadAssetsAndRewriteContent({
795
901
  siteDir,
796
902
  localeContents,
903
+ dataFileObjects,
797
904
  siteYml,
798
905
  theme,
799
906
  backendUrl,
800
907
  cliToken,
801
908
  siteId: siteIdResolved,
802
909
  })
910
+ // Re-stringify any data-file JSON that the rewrite step mutated, so the
911
+ // publish payload below sees the rewritten URLs. Untouched files round-
912
+ // trip identically.
913
+ for (const k of Object.keys(dataFiles)) {
914
+ if (dataFileObjects[k] !== null) {
915
+ dataFiles[k] = JSON.stringify(dataFileObjects[k])
916
+ }
917
+ }
803
918
  } else {
804
919
  say.dim('Skipping asset upload (--skip-assets).')
805
920
  }
806
921
 
807
- // Collect compiled collection JSON files from dist/data/. The framework
808
- // emits these for `collection:` data sources — `<name>.json` cascade
809
- // payloads plus per-record `<name>/<slug>.json` files when `deferred:` is
810
- // declared. Editor publish has no equivalent (collections live in the DB);
811
- // CLI sites need them shipped as static R2 objects.
812
- const dataFiles = await collectDataFiles(distDir)
813
- if (Object.keys(dataFiles).length > 0) {
814
- say.dim(`Data files : ${Object.keys(dataFiles).length} (collection JSON)`)
815
- }
816
-
817
922
  say.info('Publishing…')
818
923
  const publishPayload = {
819
924
  foundation,
@@ -886,6 +991,162 @@ export async function deploy(args = []) {
886
991
  if (handleResolved) {
887
992
  console.log(` ${c.cyan}https://${handleResolved}.uniweb.website/${c.reset}`)
888
993
  }
994
+
995
+ // Record a fresh lastDeploy.<target> entry. Skipped on --no-save (and
996
+ // on --host overrides, but uniweb-host can't be reached via override
997
+ // since the override branches into deployStaticHost above).
998
+ await persistLastDeploy(siteDir, {
999
+ targetName: resolved.targetName,
1000
+ targetConfig: resolved.fromFile ? null : { host: 'uniweb' },
1001
+ autoSave,
1002
+ lastDeploy: {
1003
+ at: deployReceipt.deployedAt,
1004
+ host: 'uniweb',
1005
+ url: deployReceipt.url,
1006
+ siteId: siteIdResolved,
1007
+ handle: handleResolved,
1008
+ foundation: {
1009
+ shape: 'linked',
1010
+ ref: foundationRef,
1011
+ },
1012
+ runtime: runtimeVersion,
1013
+ },
1014
+ })
1015
+ }
1016
+
1017
+ // ─── Static-host deploy (S3+CloudFront, etc.) ─────────────────
1018
+ //
1019
+ // Distinct from the uniweb-edge flow above. Picked when the resolved
1020
+ // deploy.yml target (or --host override) names a static-host adapter
1021
+ // registered in @uniweb/build/hosts. Always runs `uniweb build` (bundle
1022
+ // mode + prerender) first, then hands dist/ to the adapter's deploy hook
1023
+ // for upload + invalidation.
1024
+ //
1025
+ // See kb/framework/plans/static-host-deploy-adapters.md.
1026
+
1027
+ async function deployStaticHost(siteDir, hostName, resolved, { dryRun, autoSave, hostOverridden }) {
1028
+ let getAdapter
1029
+ try {
1030
+ ({ getAdapter } = await import('@uniweb/build/hosts'))
1031
+ } catch (err) {
1032
+ say.err('Failed to load host adapter registry from @uniweb/build/hosts.')
1033
+ say.dim(err.message)
1034
+ process.exit(1)
1035
+ }
1036
+
1037
+ let adapter
1038
+ try {
1039
+ adapter = getAdapter(hostName)
1040
+ } catch (err) {
1041
+ say.err(err.message)
1042
+ say.dim('Set the host in deploy.yml or pass --host=<name>. See `uniweb deploy --help`.')
1043
+ process.exit(1)
1044
+ }
1045
+
1046
+ if (typeof adapter.deploy !== 'function') {
1047
+ say.err(`Host adapter '${hostName}' does not implement a deploy step.`)
1048
+ say.dim(`Build with \`uniweb build --host=${hostName}\` and upload \`dist/\` manually,`)
1049
+ say.dim(`or use a host whose adapter ships a deploy hook (e.g., s3-cloudfront).`)
1050
+ process.exit(1)
1051
+ }
1052
+
1053
+ const deployConfig = resolved.config || {}
1054
+ const distDir = join(siteDir, 'dist')
1055
+
1056
+ if (dryRun) {
1057
+ say.info(`Dry run — would deploy via host adapter: ${c.bold}${adapter.name}${c.reset}`)
1058
+ say.dim(`Site dir : ${siteDir}`)
1059
+ say.dim(`dist/ : ${existsSync(distDir) ? 'exists (would not rebuild)' : 'missing (would build)'}`)
1060
+ say.dim(`Target : ${resolved.targetName}`)
1061
+ say.dim(`bucket : ${deployConfig.bucket || '(unset)'}`)
1062
+ say.dim(`distributionId : ${deployConfig.distributionId || '(unset)'}`)
1063
+ say.dim(`region : ${deployConfig.region || '(unset)'}`)
1064
+ say.dim(`profile : ${deployConfig.profile || '(default AWS chain)'}`)
1065
+ return
1066
+ }
1067
+
1068
+ // Always rebuild — the static-host flow expects fresh dist/ on every
1069
+ // deploy. UNIWEB_SKIP_BUILD env var lets CI / dev loops reuse an
1070
+ // existing build (mirrors the uniweb-edge flow's escape hatch).
1071
+ const skipBuild = parseBoolEnv('UNIWEB_SKIP_BUILD')
1072
+ if (skipBuild) {
1073
+ if (!existsSync(distDir)) {
1074
+ say.err('UNIWEB_SKIP_BUILD is set but dist/ does not exist.')
1075
+ process.exit(1)
1076
+ }
1077
+ say.info('UNIWEB_SKIP_BUILD set — reusing existing dist/.')
1078
+ } else {
1079
+ say.info(`Building site (host: ${adapter.name})…`)
1080
+ console.log('')
1081
+ try {
1082
+ execSync(
1083
+ `node ${JSON.stringify(process.argv[1])} build --bundle --host ${JSON.stringify(adapter.name)}`,
1084
+ { cwd: siteDir, stdio: 'inherit' }
1085
+ )
1086
+ } catch {
1087
+ say.err('Build failed. See output above.')
1088
+ process.exit(1)
1089
+ }
1090
+ if (!existsSync(distDir)) {
1091
+ say.err('Build did not produce dist/.')
1092
+ process.exit(1)
1093
+ }
1094
+ console.log('')
1095
+ }
1096
+
1097
+ // Hand off to the adapter. DeployError is the structured shape from
1098
+ // @uniweb/build/hosts/s3-cloudfront — translate to user-facing output.
1099
+ try {
1100
+ await adapter.deploy({
1101
+ distDir,
1102
+ deployConfig,
1103
+ env: process.env,
1104
+ log: (m) => console.log(m),
1105
+ })
1106
+ } catch (err) {
1107
+ if (err && err.name === 'DeployError') {
1108
+ say.err(err.message)
1109
+ if (err.hint) {
1110
+ console.log('')
1111
+ console.log(err.hint)
1112
+ }
1113
+ process.exit(1)
1114
+ }
1115
+ throw err
1116
+ }
1117
+
1118
+ // Record a fresh lastDeploy.<target> entry. Skipped on --no-save and
1119
+ // on ad-hoc --host overrides — see autoSave gating in deploy().
1120
+ await persistLastDeploy(siteDir, {
1121
+ targetName: resolved.targetName,
1122
+ targetConfig: resolved.fromFile ? null : { host: hostName, ...deployConfig },
1123
+ autoSave,
1124
+ lastDeploy: {
1125
+ at: new Date().toISOString(),
1126
+ host: hostName,
1127
+ // Static hosts know their public URL only via the user's CDN config;
1128
+ // we don't have it on hand. Future: pull from a known field.
1129
+ },
1130
+ })
1131
+ if (hostOverridden && !dryRun) {
1132
+ say.dim('--host override active — did not write to deploy.yml. Edit deploy.yml to make this permanent.')
1133
+ }
1134
+ }
1135
+
1136
+ // ─── deploy.yml lastDeploy persistence ──────────────────────────
1137
+
1138
+ async function persistLastDeploy(siteDir, opts) {
1139
+ if (opts.autoSave === 'off') return
1140
+ try {
1141
+ const result = await recordLastDeploy(siteDir, opts)
1142
+ if (result?.created) {
1143
+ say.dim(`Wrote deploy.yml (target: ${opts.targetName})`)
1144
+ }
1145
+ } catch (err) {
1146
+ // The deploy itself succeeded — never fail the whole command on a
1147
+ // memo-write error. Surface it so the user can fix the file.
1148
+ say.dim(`Could not update deploy.yml: ${err.message}`)
1149
+ }
889
1150
  }
890
1151
 
891
1152
  // ─── site.yml ──────────────────────────────────────────────
@@ -1008,7 +1269,7 @@ export async function resolveSiteDir(args, verb = 'deploy') {
1008
1269
 
1009
1270
  async function fetchLatestRuntime(workerUrl) {
1010
1271
  try {
1011
- const res = await fetch(`${workerUrl}/api/runtime/latest`)
1272
+ const res = await fetch(`${workerUrl}/runtime/latest`)
1012
1273
  if (!res.ok) return null
1013
1274
  const body = await res.json()
1014
1275
  return body.version || null
@@ -1168,7 +1429,7 @@ async function callPublish({ url, token, body }) {
1168
1429
 
1169
1430
  /**
1170
1431
  * Walk a built foundation's `dist/` directory and return `{ relPath: base64Bytes }`
1171
- * — the shape `POST /api/foundations` expects in its `files` field.
1432
+ * — the shape `POST /foundations` expects in its `files` field.
1172
1433
  */
1173
1434
  async function collectFoundationDistFiles(distDir) {
1174
1435
  if (!existsSync(distDir)) {
@@ -1219,13 +1480,37 @@ async function callFoundationUpload({ url, token, body }) {
1219
1480
  * siteContent is mutated in place so the caller's publish payload picks up
1220
1481
  * the rewritten nodes without passing anything back.
1221
1482
  */
1222
- async function uploadAssetsAndRewriteContent({ siteDir, localeContents, siteYml, theme, backendUrl, cliToken, siteId }) {
1483
+ async function uploadAssetsAndRewriteContent({ siteDir, localeContents, dataFileObjects = {}, siteYml, theme, backendUrl, cliToken, siteId }) {
1223
1484
  const distAssetsDir = join(siteDir, 'dist', 'assets')
1224
1485
  const hasDistAssets = existsSync(distAssetsDir)
1225
1486
 
1226
1487
  // 1. Enumerate local files + read size.
1227
1488
  const localFiles = hasDistAssets ? await walkAssetDir(distAssetsDir) : []
1228
1489
 
1490
+ // 1a. Content-scan: walk site-content.json (and locale variants) for any
1491
+ // asset references (image/document src/href) and resolve absolute
1492
+ // paths to local files under `dist/` or `public/`. This catches static
1493
+ // assets the author placed in `public/covers/`, `public/images/`, etc.
1494
+ // that the dist/assets walk above misses (vite's image-pipeline only
1495
+ // produces files for refs that go through it). Each resolved file
1496
+ // joins the upload pipeline; the rewrite step at the end maps every
1497
+ // such reference to its CDN identifier so content stays portable
1498
+ // across site delete / template extraction.
1499
+ const contentRefMap = await scanContentForAssetRefs(localeContents, dataFileObjects, siteDir)
1500
+ const seenPaths = new Set(localFiles.map((f) => f.fullPath))
1501
+ for (const [, info] of contentRefMap) {
1502
+ if (seenPaths.has(info.resolvedPath)) continue
1503
+ const ext = (info.filename.split('.').pop() || '').toLowerCase()
1504
+ const st = await stat(info.resolvedPath)
1505
+ localFiles.push({
1506
+ filename: info.filename,
1507
+ fullPath: info.resolvedPath,
1508
+ size: st.size,
1509
+ mime: MIME_BY_EXT[ext] || 'application/octet-stream',
1510
+ })
1511
+ seenPaths.add(info.resolvedPath)
1512
+ }
1513
+
1229
1514
  // 1a. Favicon — sits at site root, not in dist/assets. Ship it through
1230
1515
  // the same pipeline so it ends up at assets.uniweb.app with an
1231
1516
  // identifier; config.favicon gets set further down.
@@ -1345,14 +1630,39 @@ async function uploadAssetsAndRewriteContent({ siteDir, localeContents, siteYml,
1345
1630
  }
1346
1631
 
1347
1632
  // 6. Rewrite each locale's content in place. Image/document nodes whose
1348
- // src/href references a local /assets/{filename} get an info.identifier
1349
- // pointing to the uploaded (or reused) asset. Walking every locale
1350
- // means translated content (which still references the same image
1351
- // files via the source ProseMirror tree) gets the same rewrite.
1633
+ // src/href references an uploaded asset get an info.identifier pointing
1634
+ // to the CDN. Walking every locale means translated content (which
1635
+ // still references the same image files via the source ProseMirror
1636
+ // tree) gets the same rewrite.
1637
+ //
1638
+ // Two lookup paths:
1639
+ // - byOriginalRef: full src/href string → identifier (covers static
1640
+ // public/ assets like `/covers/foo.svg` and dist/-resolved refs)
1641
+ // - byFilename: legacy match for `assets/{filename}` shape — kept
1642
+ // for back-compat with content authored against the old vite-
1643
+ // produced `/assets/...` URLs.
1352
1644
  const byFilenameAll = new Map([...reused, ...fresh])
1645
+ const byOriginalRef = new Map()
1646
+ for (const [ref, info] of contentRefMap) {
1647
+ const id = byFilenameAll.get(info.filename)
1648
+ if (id) byOriginalRef.set(ref, id)
1649
+ }
1353
1650
  let rewritten = 0
1354
1651
  for (const lang of Object.keys(localeContents)) {
1355
- rewritten += rewriteAssetReferences(localeContents[lang], byFilenameAll)
1652
+ rewritten += rewriteAssetReferences(localeContents[lang], byFilenameAll, byOriginalRef)
1653
+ }
1654
+ // Data files: walk the JSON tree. Two patterns coexist in collection
1655
+ // payloads:
1656
+ // - Flat fields (e.g. `article.image: "/covers/foo.svg"`) → replace
1657
+ // the string with a resolveAssetCdnUrl(identifier). The runtime
1658
+ // reads these as plain URLs, so rewriting at deploy time is the
1659
+ // simplest path to portability.
1660
+ // - Nested ProseMirror sub-trees (e.g. `article.content`) → use the
1661
+ // existing image/document node rewrite (sets `attrs.info.identifier`).
1662
+ for (const k of Object.keys(dataFileObjects)) {
1663
+ if (dataFileObjects[k] === null) continue
1664
+ rewritten += rewriteFlatAssetUrls(dataFileObjects[k], byOriginalRef)
1665
+ rewritten += rewriteAssetReferences(dataFileObjects[k], byFilenameAll, byOriginalRef)
1356
1666
  }
1357
1667
  if (rewritten > 0) {
1358
1668
  say.dim(`Rewrote ${rewritten} asset reference(s) across ${Object.keys(localeContents).length} locale(s).`)
@@ -1626,45 +1936,69 @@ async function runInPool(items, concurrency, worker) {
1626
1936
 
1627
1937
  /**
1628
1938
  * Walk siteContent (ProseMirror-ish JSON tree) and rewrite any node whose
1629
- * `attrs.src` or `attrs.href` references a local `/assets/{filename}` that
1630
- * we've uploaded/reused. Sets `attrs.info.identifier` so semantic-parser
1631
- * resolves the real CDN URL (and optimized variants) at render time.
1939
+ * `attrs.src` or `attrs.href` references an uploaded/reused asset. Sets
1940
+ * `attrs.info.identifier` so semantic-parser resolves the real CDN URL
1941
+ * (and optimized variants) at render time.
1942
+ *
1943
+ * Two lookup paths, in order:
1944
+ * 1. `byOriginalRef` — full src/href string → identifier. Covers static
1945
+ * public/ assets (`/covers/foo.svg`, `/images/foo.png`) and any
1946
+ * content-scan-resolved file. Decouples assets from site lifecycle
1947
+ * (templates can extract content + identifier; assets stay on CDN).
1948
+ * 2. `byFilename` (legacy) — only fires when the path matches the old
1949
+ * `/assets/{filename}` shape. Kept so re-deploys of content authored
1950
+ * against pre-content-scan CLIs still work.
1632
1951
  *
1633
1952
  * Returns the number of rewrites performed — useful for reporting, and to
1634
1953
  * detect "nothing matched" (likely a content-shape mismatch worth flagging).
1635
1954
  */
1636
- function rewriteAssetReferences(node, byFilename) {
1955
+ function rewriteAssetReferences(node, byFilename, byOriginalRef = new Map()) {
1637
1956
  let count = 0
1638
1957
  const walk = (n) => {
1639
1958
  if (!n || typeof n !== 'object') return
1640
1959
  if (Array.isArray(n)) { for (const child of n) walk(child); return }
1641
1960
  if (n.attrs && typeof n.attrs === 'object') {
1642
- const srcRef = pickAssetRef(n.attrs.src)
1643
- const hrefRef = pickAssetRef(n.attrs.href)
1644
- const ref = srcRef || hrefRef
1645
- if (ref) {
1646
- const identifier = byFilename.get(ref)
1647
- if (identifier) {
1648
- n.attrs.info = {
1649
- ...(n.attrs.info || {}),
1650
- identifier,
1651
- contentType: 'website',
1652
- viewType: 'profile',
1653
- }
1654
- // Clear the local Vite-hashed path so the runtime resolves via
1655
- // info.identifier (→ assets.uniweb.app CDN) instead of requesting
1656
- // a non-existent /assets/... file from the site host.
1657
- if (srcRef) n.attrs.src = null
1658
- if (hrefRef) n.attrs.href = null
1659
- // Match the Editor shape: plain `image` nodes skip identifier
1660
- // resolution in older runtimes; `ImageBlock` routes through
1661
- // parseImgBlock which reads info.identifier and fills url.
1662
- if (n.type === 'image' && n.attrs.role !== 'icon') {
1663
- n.type = 'ImageBlock'
1664
- }
1665
- count++
1961
+ // Prefer full-ref lookup (covers static + dist refs uniformly);
1962
+ // fall back to legacy `assets/{filename}` extraction.
1963
+ let identifier = null
1964
+ let srcMatched = false
1965
+ let hrefMatched = false
1966
+ if (typeof n.attrs.src === 'string' && byOriginalRef.has(n.attrs.src)) {
1967
+ identifier = byOriginalRef.get(n.attrs.src)
1968
+ srcMatched = true
1969
+ } else if (typeof n.attrs.href === 'string' && byOriginalRef.has(n.attrs.href)) {
1970
+ identifier = byOriginalRef.get(n.attrs.href)
1971
+ hrefMatched = true
1972
+ } else {
1973
+ const srcRef = pickAssetRef(n.attrs.src)
1974
+ const hrefRef = pickAssetRef(n.attrs.href)
1975
+ const ref = srcRef || hrefRef
1976
+ if (ref) {
1977
+ identifier = byFilename.get(ref) || null
1978
+ srcMatched = !!srcRef
1979
+ hrefMatched = !srcRef && !!hrefRef
1666
1980
  }
1667
1981
  }
1982
+ if (identifier) {
1983
+ n.attrs.info = {
1984
+ ...(n.attrs.info || {}),
1985
+ identifier,
1986
+ contentType: 'website',
1987
+ viewType: 'profile',
1988
+ }
1989
+ // Clear the local path so the runtime resolves via info.identifier
1990
+ // (→ assets.uniweb.app CDN) instead of requesting a non-existent
1991
+ // file from the site host.
1992
+ if (srcMatched) n.attrs.src = null
1993
+ if (hrefMatched) n.attrs.href = null
1994
+ // Match the Editor shape: plain `image` nodes skip identifier
1995
+ // resolution in older runtimes; `ImageBlock` routes through
1996
+ // parseImgBlock which reads info.identifier and fills url.
1997
+ if (n.type === 'image' && n.attrs.role !== 'icon') {
1998
+ n.type = 'ImageBlock'
1999
+ }
2000
+ count++
2001
+ }
1668
2002
  }
1669
2003
  for (const v of Object.values(n)) if (typeof v === 'object') walk(v)
1670
2004
  }
@@ -1679,6 +2013,153 @@ function pickAssetRef(v) {
1679
2013
  return m ? m[1] : null
1680
2014
  }
1681
2015
 
2016
+ /**
2017
+ * Walk every locale's content for `attrs.src` and `attrs.href` strings, and
2018
+ * resolve absolute-path refs (e.g. `/covers/foo.svg`) to local files under
2019
+ * the site root.
2020
+ *
2021
+ * Resolution order per ref:
2022
+ * 1. `dist/{path}` — vite outputs, link-mode collection JSON, etc.
2023
+ * 2. `public/{path}` — static author-placed assets (covers, images).
2024
+ *
2025
+ * Returns Map<originalRef, { resolvedPath, filename }> where:
2026
+ * - `originalRef` — the exact src/href string from content (used as the
2027
+ * lookup key during rewrite).
2028
+ * - `resolvedPath` — absolute path on disk (used for upload).
2029
+ * - `filename` — basename, used as the assets-server upload filename.
2030
+ * Server keys by (siteId, filename); collisions across
2031
+ * paths with the same basename are flagged as warnings.
2032
+ *
2033
+ * Skips:
2034
+ * - Non-string values, refs that don't start with `/`, protocol-relative
2035
+ * refs (`//cdn.example.com/...`), and external URLs.
2036
+ * - Refs starting with `/api/` or `/_` (worker-internal paths, never
2037
+ * local files).
2038
+ * - Nodes already rewritten with `attrs.info.identifier` set (re-deploy).
2039
+ */
2040
+ async function scanContentForAssetRefs(localeContents, dataFileObjects, siteDir) {
2041
+ const candidates = new Set()
2042
+ for (const lang of Object.keys(localeContents)) {
2043
+ walkContentForAssetRefs(localeContents[lang], candidates)
2044
+ }
2045
+ // Also walk parsed collection JSON files. These contain BOTH ProseMirror-
2046
+ // shaped sub-trees (article.content) AND flat string fields (article.image,
2047
+ // article.cover, etc.). The walker captures both: any string-valued src/
2048
+ // href/image/cover/thumbnail/icon/poster field, plus any string anywhere
2049
+ // that looks like an absolute path with a known media extension.
2050
+ for (const k of Object.keys(dataFileObjects || {})) {
2051
+ if (dataFileObjects[k] !== null) {
2052
+ walkContentForAssetRefs(dataFileObjects[k], candidates)
2053
+ }
2054
+ }
2055
+
2056
+ const results = new Map()
2057
+ const filenameToRef = new Map() // detect collisions (same basename, different path)
2058
+ for (const ref of candidates) {
2059
+ if (!isResolvableContentRef(ref)) continue
2060
+ const cleanPath = ref.split('?')[0].split('#')[0].slice(1) // drop leading '/'
2061
+ const distCandidate = join(siteDir, 'dist', cleanPath)
2062
+ const publicCandidate = join(siteDir, 'public', cleanPath)
2063
+ let resolvedPath = null
2064
+ if (existsSync(distCandidate)) {
2065
+ try { if ((await stat(distCandidate)).isFile()) resolvedPath = distCandidate } catch {}
2066
+ }
2067
+ if (!resolvedPath && existsSync(publicCandidate)) {
2068
+ try { if ((await stat(publicCandidate)).isFile()) resolvedPath = publicCandidate } catch {}
2069
+ }
2070
+ if (!resolvedPath) continue
2071
+ const filename = resolvedPath.split(sep).pop()
2072
+ const prior = filenameToRef.get(filename)
2073
+ if (prior && prior !== resolvedPath) {
2074
+ // Two different files want the same upload filename — server keys by
2075
+ // filename so the second would clobber the first. Skip + warn rather
2076
+ // than silently overwrite. Caller can rename the file or move one
2077
+ // into a vite-processed path to disambiguate via content hashing.
2078
+ say.warn(
2079
+ `Asset filename collision: "${filename}" exists at multiple paths ` +
2080
+ `(${prior}, ${resolvedPath}). Skipping the second; rename to disambiguate.`
2081
+ )
2082
+ continue
2083
+ }
2084
+ filenameToRef.set(filename, resolvedPath)
2085
+ results.set(ref, { resolvedPath, filename })
2086
+ }
2087
+ return results
2088
+ }
2089
+
2090
+ // Field names commonly used for media in collection JSON. The walker
2091
+ // collects any absolute-path string under these keys as a potential asset
2092
+ // reference. ProseMirror image/link nodes are caught separately via attrs.
2093
+ const FLAT_ASSET_FIELDS = new Set([
2094
+ 'src', 'href', 'image', 'cover', 'thumbnail', 'icon', 'poster', 'logo',
2095
+ 'avatar', 'photo', 'banner', 'background',
2096
+ ])
2097
+
2098
+ function walkContentForAssetRefs(node, refs) {
2099
+ if (!node || typeof node !== 'object') return
2100
+ if (Array.isArray(node)) { for (const child of node) walkContentForAssetRefs(child, refs); return }
2101
+ if (node.attrs && typeof node.attrs === 'object') {
2102
+ // Skip nodes already rewritten in a prior deploy — those have an
2103
+ // identifier and the runtime resolves them through the CDN already.
2104
+ if (!node.attrs.info?.identifier) {
2105
+ if (typeof node.attrs.src === 'string') refs.add(node.attrs.src)
2106
+ if (typeof node.attrs.href === 'string') refs.add(node.attrs.href)
2107
+ }
2108
+ }
2109
+ // Flat fields: collection-shaped objects (e.g. an article record) often
2110
+ // carry media URLs as plain string fields rather than ProseMirror nodes.
2111
+ // Capture absolute-path values under known keys.
2112
+ for (const [k, v] of Object.entries(node)) {
2113
+ if (typeof v === 'string' && FLAT_ASSET_FIELDS.has(k) && isResolvableContentRef(v)) {
2114
+ refs.add(v)
2115
+ } else if (typeof v === 'object') {
2116
+ walkContentForAssetRefs(v, refs)
2117
+ }
2118
+ }
2119
+ }
2120
+
2121
+ /**
2122
+ * Walk an arbitrary JSON tree and replace any string equal to a key in
2123
+ * `byOriginalRef` (and not already a CDN URL) with the asset's CDN URL.
2124
+ * Used for collection JSON files where image refs are flat string fields
2125
+ * (e.g. `article.image: "/covers/foo.svg"`) rather than ProseMirror nodes.
2126
+ *
2127
+ * Returns the number of replacements performed.
2128
+ */
2129
+ function rewriteFlatAssetUrls(node, byOriginalRef) {
2130
+ let count = 0
2131
+ const walk = (n, parent, key) => {
2132
+ if (n == null) return
2133
+ if (typeof n === 'string') {
2134
+ const id = byOriginalRef.get(n)
2135
+ if (id && parent != null && key != null) {
2136
+ parent[key] = resolveAssetCdnUrl(id)
2137
+ count++
2138
+ }
2139
+ return
2140
+ }
2141
+ if (typeof n !== 'object') return
2142
+ if (Array.isArray(n)) {
2143
+ for (let i = 0; i < n.length; i++) walk(n[i], n, i)
2144
+ return
2145
+ }
2146
+ for (const [k, v] of Object.entries(n)) walk(v, n, k)
2147
+ }
2148
+ walk(node, null, null)
2149
+ return count
2150
+ }
2151
+
2152
+ function isResolvableContentRef(ref) {
2153
+ if (typeof ref !== 'string' || !ref) return false
2154
+ // Absolute-path only — relative paths (`./foo`, `foo`) are content-author
2155
+ // shorthand handled elsewhere; URLs (`http://`, `//cdn`) never resolve to
2156
+ // local files; worker-internal paths (`/api/`, `/_`) aren't asset content.
2157
+ if (!ref.startsWith('/')) return false
2158
+ if (ref.startsWith('//')) return false
2159
+ if (ref.startsWith('/api/') || ref.startsWith('/_')) return false
2160
+ return true
2161
+ }
2162
+
1682
2163
  // ─── Loopback listener (review path) ───────────────────────
1683
2164
 
1684
2165
  /**
@@ -17,6 +17,10 @@
17
17
  * Usage:
18
18
  * uniweb export Produce dist/ for static hosting
19
19
  * uniweb export --no-prerender Skip per-page prerendered HTML
20
+ * uniweb export --host <name> Pick a host adapter for postBuild
21
+ * (e.g. cloudflare-pages, s3-cloudfront,
22
+ * github-pages, generic-static).
23
+ * Default: cloudflare-pages.
20
24
  */
21
25
 
22
26
  import { execSync } from 'node:child_process'
@@ -42,14 +46,31 @@ const say = {
42
46
  export async function exportSite(args = []) {
43
47
  const siteDir = await resolveSiteDir(args, 'export')
44
48
 
45
- // Pass through --no-prerender; everything else gets ignored. `uniweb
46
- // export` is intentionally low-flag: the user picks the destination
47
- // host themselves outside the CLI, so there's nothing to configure
48
- // beyond what `uniweb build --bundle` already exposes.
49
+ // Pass through --no-prerender and --host. Everything else is ignored.
50
+ // `uniweb export` stays low-flag: the user picks the destination host
51
+ // themselves outside the CLI, so there's nothing to configure beyond
52
+ // what `uniweb build --bundle` already exposes.
49
53
  const noPrerender = args.includes('--no-prerender')
50
54
  const buildArgs = ['build', '--bundle']
51
55
  if (noPrerender) buildArgs.push('--no-prerender')
52
56
 
57
+ const { readFlagValue } = await import('../utils/args.js')
58
+ const hostFlag = readFlagValue(args, '--host')
59
+ if (hostFlag === null) {
60
+ // --host with no value → prompt here so the build subprocess gets
61
+ // a concrete value (and doesn't re-prompt against its own argv).
62
+ const { promptForHost } = await import('../utils/host-prompt.js')
63
+ try {
64
+ const chosen = await promptForHost({ args })
65
+ buildArgs.push('--host', chosen)
66
+ } catch (err) {
67
+ say.err(err.message)
68
+ process.exit(1)
69
+ }
70
+ } else if (typeof hostFlag === 'string') {
71
+ buildArgs.push('--host', hostFlag)
72
+ }
73
+
53
74
  say.info('Exporting site (vite build → dist/)…')
54
75
  console.log('')
55
76
 
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-05-01T02:07:21.541Z",
3
+ "generatedAt": "2026-05-05T18:36:54.411Z",
4
4
  "packages": {
5
5
  "@uniweb/build": {
6
- "version": "0.13.5",
6
+ "version": "0.14.2",
7
7
  "path": "framework/build",
8
8
  "deps": [
9
9
  "@uniweb/content-reader",
@@ -24,7 +24,7 @@
24
24
  "deps": []
25
25
  },
26
26
  "@uniweb/core": {
27
- "version": "0.7.10",
27
+ "version": "0.7.11",
28
28
  "path": "framework/core",
29
29
  "deps": [
30
30
  "@uniweb/semantic-parser",
@@ -42,7 +42,7 @@
42
42
  "deps": []
43
43
  },
44
44
  "@uniweb/kit": {
45
- "version": "0.9.10",
45
+ "version": "0.9.11",
46
46
  "path": "framework/kit",
47
47
  "deps": [
48
48
  "@uniweb/core"
@@ -59,7 +59,7 @@
59
59
  "deps": []
60
60
  },
61
61
  "@uniweb/runtime": {
62
- "version": "0.8.11",
62
+ "version": "0.8.13",
63
63
  "path": "framework/runtime",
64
64
  "deps": [
65
65
  "@uniweb/core",
@@ -92,7 +92,7 @@
92
92
  "deps": []
93
93
  },
94
94
  "@uniweb/unipress": {
95
- "version": "0.4.4",
95
+ "version": "0.4.6",
96
96
  "path": "framework/unipress",
97
97
  "deps": [
98
98
  "@uniweb/build",
@@ -0,0 +1,37 @@
1
+ /**
2
+ * argv parsing helpers shared across CLI commands.
3
+ */
4
+
5
+ /**
6
+ * Read `--flag value` from argv. Accepts both `--flag value` and
7
+ * `--flag=value`.
8
+ *
9
+ * Returns:
10
+ * - undefined when the flag is absent
11
+ * - null when the flag is present without a value (last arg, next is
12
+ * another flag, or `--flag=` empty form)
13
+ * - string when the flag carries a value
14
+ *
15
+ * The three-state return lets callers distinguish "not given" (e.g.,
16
+ * fall back to a default) from "given but empty" (e.g., trigger an
17
+ * interactive prompt).
18
+ *
19
+ * @param {string[]} args
20
+ * @param {string} name — Including the leading dashes, e.g. '--host'.
21
+ * @returns {string | null | undefined}
22
+ */
23
+ export function readFlagValue(args, name) {
24
+ const eqPrefix = name + '='
25
+ for (let i = 0; i < args.length; i++) {
26
+ if (args[i] === name) {
27
+ const next = args[i + 1]
28
+ if (next === undefined || next.startsWith('--')) return null
29
+ return next
30
+ }
31
+ if (args[i].startsWith(eqPrefix)) {
32
+ const v = args[i].slice(eqPrefix.length)
33
+ return v === '' ? null : v
34
+ }
35
+ }
36
+ return undefined
37
+ }
@@ -14,9 +14,15 @@ import { filterCmd } from './pm.js'
14
14
 
15
15
  // ── Platform URLs ──────────────────────────────────────────────
16
16
 
17
- // Production defaults — regular users get these out of the box
17
+ // Production defaults — regular users get these out of the box.
18
+ // REGISTRY hosts platform operations (publish, foundations, runtime, admin):
19
+ // moved to hosting.uniweb.app in the CDN migration (Phase 4c, 2026-05-04).
20
+ // BACKEND hosts the PHP user-facing surface (login, account, orgs, billing,
21
+ // publish-authorize): owned by the v4 single-domain plan
22
+ // (kb/platform/plans/uniweb-domain-plan-v4.md), which will move it to
23
+ // uniweb.app/api/* when v4 ships. Until then, it stays at hub.uniweb.app.
18
24
  const PRODUCTION_BACKEND_URL = 'https://hub.uniweb.app'
19
- const PRODUCTION_REGISTRY_URL = 'https://site-router.uniweb-edge.workers.dev'
25
+ const PRODUCTION_REGISTRY_URL = 'https://hosting.uniweb.app'
20
26
 
21
27
  /**
22
28
  * Read ~/.uniweb/config.json for persistent URL overrides.
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Interactive host adapter selection
3
+ *
4
+ * Prompts the user to pick a host adapter from the registry. Used when
5
+ * `--host` is passed without a value to `uniweb deploy / build / export`.
6
+ *
7
+ * Non-interactive contexts (CI, piped input, --non-interactive) get a
8
+ * structured error instead of a prompt — never silently default to
9
+ * something the user didn't pick.
10
+ */
11
+
12
+ import { promptSelect } from './workspace.js'
13
+ import { isNonInteractive } from './interactive.js'
14
+
15
+ /**
16
+ * Pick a host adapter, optionally with a pre-selection.
17
+ *
18
+ * @param {object} opts
19
+ * @param {string[]} opts.args — Argv, used only to gate non-interactive mode.
20
+ * @param {string|null} [opts.preselect] — Suggested adapter name; the prompt
21
+ * highlights this so Enter accepts it without arrow-key navigation.
22
+ * @returns {Promise<string>} The chosen adapter name.
23
+ * @throws {Error} When non-interactive (with the registry list in the message),
24
+ * or when the user aborts the prompt.
25
+ */
26
+ export async function promptForHost({ args, preselect = null } = {}) {
27
+ // Lazy-load so this module doesn't pull @uniweb/build at import time
28
+ // for callers that never reach the prompt path.
29
+ const { listAdapters } = await import('@uniweb/build/hosts')
30
+ const adapters = listAdapters()
31
+
32
+ if (isNonInteractive(args || [])) {
33
+ const list = adapters.join(', ')
34
+ throw new Error(
35
+ `--host requires a value when running non-interactively. Known adapters: ${list}.`
36
+ )
37
+ }
38
+
39
+ // promptSelect doesn't expose initial-index, so move the preselect to
40
+ // the top of the list — the menu still highlights index 0 by default.
41
+ const ordered = preselect && adapters.includes(preselect)
42
+ ? [preselect, ...adapters.filter(a => a !== preselect)]
43
+ : adapters
44
+
45
+ const choice = await promptSelect('Pick a host adapter:', ordered)
46
+ if (!choice) {
47
+ throw new Error('Host selection cancelled.')
48
+ }
49
+ return choice
50
+ }
@@ -7,7 +7,7 @@
7
7
  "exports": {
8
8
  ".": "./_entry.generated.js",
9
9
  "./styles": "./styles.css",
10
- "./dist": "./dist/foundation.js",
10
+ "./dist": "./dist/entry.js",
11
11
  "./dist/styles": "./dist/assets/style.css"
12
12
  },
13
13
  "imports": {