uniweb 0.12.8 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uniweb",
3
- "version": "0.12.8",
3
+ "version": "0.12.9",
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
44
  "@uniweb/kit": "0.9.10",
46
- "@uniweb/runtime": "0.8.11"
45
+ "@uniweb/runtime": "0.8.11",
46
+ "@uniweb/core": "0.7.10"
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.1",
50
+ "@uniweb/semantic-parser": "1.1.17",
51
+ "@uniweb/content-reader": "1.1.10"
52
52
  },
53
53
  "peerDependenciesMeta": {
54
54
  "@uniweb/build": {
@@ -35,6 +35,8 @@
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> # Override site.yml deploy.host (e.g.
39
+ * netlify, s3-cloudfront, generic-static)
38
40
  *
39
41
  * Internal flags (called by `uniweb deploy` / `uniweb export`):
40
42
  * --link # Data-only pipeline (Uniweb-edge)
@@ -540,7 +542,7 @@ async function resolveFoundationDirForSite(siteDir, siteConfig) {
540
542
  * Build a site
541
543
  */
542
544
  async function buildSite(projectDir, options = {}) {
543
- const { prerender = false, foundationDir, siteConfig = null } = options
545
+ const { prerender = false, foundationDir, siteConfig = null, host = null } = options
544
546
 
545
547
  info('Building site...')
546
548
 
@@ -613,6 +615,7 @@ async function buildSite(projectDir, options = {}) {
613
615
 
614
616
  const result = await prerenderSite(projectDir, {
615
617
  foundationDir: foundationDir || resolveFoundationDir(projectDir, siteConfig),
618
+ host,
616
619
  onProgress: (msg) => log(` ${colors.dim}${msg}${colors.reset}`)
617
620
  })
618
621
 
@@ -710,7 +713,7 @@ async function discoverWorkspacePackages(workspaceDir) {
710
713
  * Build all packages in a workspace
711
714
  */
712
715
  async function buildWorkspace(workspaceDir, options = {}) {
713
- const { prerenderFlag, noPrerenderFlag } = options
716
+ const { prerenderFlag, noPrerenderFlag, host = null } = options
714
717
 
715
718
  log(`${colors.cyan}${colors.bright}Building workspace...${colors.reset}`)
716
719
  log('')
@@ -757,7 +760,7 @@ async function buildWorkspace(workspaceDir, options = {}) {
757
760
  // Resolve foundation directory for this site
758
761
  const foundationDir = resolveFoundationDir(site.path, siteConfig)
759
762
 
760
- await buildSite(site.path, { prerender, foundationDir, siteConfig })
763
+ await buildSite(site.path, { prerender, foundationDir, siteConfig, host })
761
764
  log('')
762
765
  }
763
766
 
@@ -835,6 +838,14 @@ export async function build(args = []) {
835
838
  foundationDir = resolve(args[foundationDirIndex + 1])
836
839
  }
837
840
 
841
+ // --host overrides site.yml's deploy.host for this build's prerender
842
+ // step. Validated lazily (inside prerender.js, via the registry).
843
+ let host = null
844
+ const hostIndex = args.indexOf('--host')
845
+ if (hostIndex !== -1 && args[hostIndex + 1]) {
846
+ host = args[hostIndex + 1]
847
+ }
848
+
838
849
  // Auto-detect project type if not specified
839
850
  if (!targetType) {
840
851
  targetType = detectProjectType(projectDir)
@@ -868,7 +879,7 @@ export async function build(args = []) {
868
879
  // Run appropriate build
869
880
  try {
870
881
  if (targetType === 'workspace') {
871
- await buildWorkspace(projectDir, { prerenderFlag, noPrerenderFlag })
882
+ await buildWorkspace(projectDir, { prerenderFlag, noPrerenderFlag, host })
872
883
  } else if (targetType === 'foundation') {
873
884
  await buildFoundation(projectDir)
874
885
  } else {
@@ -898,7 +909,7 @@ export async function build(args = []) {
898
909
  if (prerenderFlag) prerender = true
899
910
  if (noPrerenderFlag) prerender = false
900
911
 
901
- await buildSite(projectDir, { prerender, foundationDir, siteConfig })
912
+ await buildSite(projectDir, { prerender, foundationDir, siteConfig, host })
902
913
  }
903
914
  } catch (err) {
904
915
  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 `deploy.host` in site.yml (or
5
+ * `--host <name>` flag). 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,8 @@
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 --host <name> Static-host flow (e.g., s3-cloudfront,
43
+ * generic-static). Overrides site.yml deploy.host.
32
44
  *
33
45
  * Internal escape hatches (UNIWEB_* env vars — see framework/cli/docs/env-vars.md):
34
46
  * UNIWEB_SKIP_BUILD=1 Reuse existing dist/ instead of rebuilding
@@ -404,6 +416,22 @@ export async function deploy(args = []) {
404
416
  // site.id / site.handle from prior deploys.
405
417
  const siteYmlPath = join(siteDir, 'site.yml')
406
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
+
407
435
  if (!siteYml.foundation) {
408
436
  say.err('site.yml is missing `foundation`.')
409
437
  say.dim('Add a line like: foundation: \'@uniweb/docs-foundation@0.1.20\'')
@@ -512,7 +540,7 @@ export async function deploy(args = []) {
512
540
  if (!runtimeVersion) {
513
541
  runtimeVersion = await fetchLatestRuntime(workerUrl)
514
542
  if (!runtimeVersion) {
515
- 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).')
516
544
  process.exit(1)
517
545
  }
518
546
  say.dim(`Runtime: ${runtimeVersion} (latest; pin via \`runtime:\` in site.yml)`)
@@ -542,11 +570,12 @@ export async function deploy(args = []) {
542
570
  // serving edge, not here). Always --link: the edge serves a runtime
543
571
  // template + per-site base.html, never a self-contained vite bundle.
544
572
  //
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.
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.
550
579
  //
551
580
  // Spawn the SAME CLI binary that's currently running rather than
552
581
  // `npx uniweb build` — npx walks node_modules and would resolve to
@@ -556,9 +585,7 @@ export async function deploy(args = []) {
556
585
  execSync(`node ${JSON.stringify(process.argv[1])} build --link`, {
557
586
  cwd: siteDir,
558
587
  stdio: 'inherit',
559
- env: foundationBuildOverride
560
- ? { ...process.env, UNIWEB_FOUNDATION_REF: foundationBuildOverride }
561
- : process.env,
588
+ env: process.env,
562
589
  })
563
590
  console.log('')
564
591
  } else if (!existsSync(contentPath)) {
@@ -693,9 +720,16 @@ export async function deploy(args = []) {
693
720
  // CLI can write `features:` back into site.yml accurately. Older
694
721
  // PHP that doesn't include this field is a no-op.
695
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
696
730
  // Review path: Worker URLs are implicit (we derive them from config).
697
- publishUrl = `${workerUrl}/api/publish/process`
698
- validateUrl = `${workerUrl}/api/publish/validate`
731
+ publishUrl = `${workerUrl}/publish`
732
+ validateUrl = `${workerUrl}/publish/check`
699
733
  } else {
700
734
  publishToken = authRes.publishToken
701
735
  siteIdResolved = authRes.siteId
@@ -731,7 +765,7 @@ export async function deploy(args = []) {
731
765
  // Phase 4d: upload site-bound foundation files directly. Replaces the
732
766
  // pre-Phase-4d `execSync('uniweb publish')` flow — we now know the
733
767
  // canonical `~{siteId}/{name}@{ver}` ref from authorize, and the worker's
734
- // /api/foundations endpoint accepts the publish token's siteId claim
768
+ // /foundations endpoint accepts the publish token's siteId claim
735
769
  // for this scope.
736
770
  if (localFoundation) {
737
771
  say.info(`Building foundation at ${localFoundation.relPath}…`)
@@ -749,7 +783,7 @@ export async function deploy(args = []) {
749
783
 
750
784
  say.info(`Uploading foundation as ${foundation}…`)
751
785
  const foundationFiles = await collectFoundationDistFiles(join(localFoundation.path, 'dist'))
752
- const foundationPublishUrl = foundationUploadUrl || `${workerUrl}/api/foundations`
786
+ const foundationPublishUrl = foundationUploadUrl || `${workerUrl}/foundations`
753
787
  const { gitSha: fGitSha, gitDirty: fGitDirty } = readGitState(localFoundation.path)
754
788
  await callFoundationUpload({
755
789
  url: foundationPublishUrl,
@@ -784,36 +818,60 @@ export async function deploy(args = []) {
784
818
  process.exit(1)
785
819
  }
786
820
 
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.
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.
792
851
  // Skipped with --skip-assets.
793
852
  if (!skipAssets) {
794
853
  await uploadAssetsAndRewriteContent({
795
854
  siteDir,
796
855
  localeContents,
856
+ dataFileObjects,
797
857
  siteYml,
798
858
  theme,
799
859
  backendUrl,
800
860
  cliToken,
801
861
  siteId: siteIdResolved,
802
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
+ }
803
871
  } else {
804
872
  say.dim('Skipping asset upload (--skip-assets).')
805
873
  }
806
874
 
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
875
  say.info('Publishing…')
818
876
  const publishPayload = {
819
877
  foundation,
@@ -888,6 +946,107 @@ export async function deploy(args = []) {
888
946
  }
889
947
  }
890
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
+
891
1050
  // ─── site.yml ──────────────────────────────────────────────
892
1051
 
893
1052
  async function readSiteYml(path) {
@@ -1008,7 +1167,7 @@ export async function resolveSiteDir(args, verb = 'deploy') {
1008
1167
 
1009
1168
  async function fetchLatestRuntime(workerUrl) {
1010
1169
  try {
1011
- const res = await fetch(`${workerUrl}/api/runtime/latest`)
1170
+ const res = await fetch(`${workerUrl}/runtime/latest`)
1012
1171
  if (!res.ok) return null
1013
1172
  const body = await res.json()
1014
1173
  return body.version || null
@@ -1168,7 +1327,7 @@ async function callPublish({ url, token, body }) {
1168
1327
 
1169
1328
  /**
1170
1329
  * Walk a built foundation's `dist/` directory and return `{ relPath: base64Bytes }`
1171
- * — the shape `POST /api/foundations` expects in its `files` field.
1330
+ * — the shape `POST /foundations` expects in its `files` field.
1172
1331
  */
1173
1332
  async function collectFoundationDistFiles(distDir) {
1174
1333
  if (!existsSync(distDir)) {
@@ -1219,13 +1378,37 @@ async function callFoundationUpload({ url, token, body }) {
1219
1378
  * siteContent is mutated in place so the caller's publish payload picks up
1220
1379
  * the rewritten nodes without passing anything back.
1221
1380
  */
1222
- async function uploadAssetsAndRewriteContent({ siteDir, localeContents, siteYml, theme, backendUrl, cliToken, siteId }) {
1381
+ async function uploadAssetsAndRewriteContent({ siteDir, localeContents, dataFileObjects = {}, siteYml, theme, backendUrl, cliToken, siteId }) {
1223
1382
  const distAssetsDir = join(siteDir, 'dist', 'assets')
1224
1383
  const hasDistAssets = existsSync(distAssetsDir)
1225
1384
 
1226
1385
  // 1. Enumerate local files + read size.
1227
1386
  const localFiles = hasDistAssets ? await walkAssetDir(distAssetsDir) : []
1228
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
+
1229
1412
  // 1a. Favicon — sits at site root, not in dist/assets. Ship it through
1230
1413
  // the same pipeline so it ends up at assets.uniweb.app with an
1231
1414
  // identifier; config.favicon gets set further down.
@@ -1345,14 +1528,39 @@ async function uploadAssetsAndRewriteContent({ siteDir, localeContents, siteYml,
1345
1528
  }
1346
1529
 
1347
1530
  // 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.
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.
1352
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
+ }
1353
1548
  let rewritten = 0
1354
1549
  for (const lang of Object.keys(localeContents)) {
1355
- 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)
1356
1564
  }
1357
1565
  if (rewritten > 0) {
1358
1566
  say.dim(`Rewrote ${rewritten} asset reference(s) across ${Object.keys(localeContents).length} locale(s).`)
@@ -1626,44 +1834,68 @@ async function runInPool(items, concurrency, worker) {
1626
1834
 
1627
1835
  /**
1628
1836
  * 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.
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.
1632
1849
  *
1633
1850
  * Returns the number of rewrites performed — useful for reporting, and to
1634
1851
  * detect "nothing matched" (likely a content-shape mismatch worth flagging).
1635
1852
  */
1636
- function rewriteAssetReferences(node, byFilename) {
1853
+ function rewriteAssetReferences(node, byFilename, byOriginalRef = new Map()) {
1637
1854
  let count = 0
1638
1855
  const walk = (n) => {
1639
1856
  if (!n || typeof n !== 'object') return
1640
1857
  if (Array.isArray(n)) { for (const child of n) walk(child); return }
1641
1858
  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++
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',
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'
1666
1897
  }
1898
+ count++
1667
1899
  }
1668
1900
  }
1669
1901
  for (const v of Object.values(n)) if (typeof v === 'object') walk(v)
@@ -1679,6 +1911,153 @@ function pickAssetRef(v) {
1679
1911
  return m ? m[1] : null
1680
1912
  }
1681
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
+
1682
2061
  // ─── Loopback listener (review path) ───────────────────────
1683
2062
 
1684
2063
  /**
@@ -17,6 +17,9 @@
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> Select a host adapter (e.g.
21
+ * netlify, s3-cloudfront, generic-static).
22
+ * Overrides site.yml's deploy.host.
20
23
  */
21
24
 
22
25
  import { execSync } from 'node:child_process'
@@ -42,14 +45,19 @@ const say = {
42
45
  export async function exportSite(args = []) {
43
46
  const siteDir = await resolveSiteDir(args, 'export')
44
47
 
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.
48
+ // Pass through --no-prerender and --host. Everything else is ignored.
49
+ // `uniweb export` stays low-flag: the user picks the destination host
50
+ // themselves outside the CLI, so there's nothing to configure beyond
51
+ // what `uniweb build --bundle` already exposes.
49
52
  const noPrerender = args.includes('--no-prerender')
50
53
  const buildArgs = ['build', '--bundle']
51
54
  if (noPrerender) buildArgs.push('--no-prerender')
52
55
 
56
+ const hostIndex = args.indexOf('--host')
57
+ if (hostIndex !== -1 && args[hostIndex + 1]) {
58
+ buildArgs.push('--host', args[hostIndex + 1])
59
+ }
60
+
53
61
  say.info('Exporting site (vite build → dist/)…')
54
62
  console.log('')
55
63
 
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-05-01T02:07:21.541Z",
3
+ "generatedAt": "2026-05-04T22:31:28.093Z",
4
4
  "packages": {
5
5
  "@uniweb/build": {
6
- "version": "0.13.5",
6
+ "version": "0.14.1",
7
7
  "path": "framework/build",
8
8
  "deps": [
9
9
  "@uniweb/content-reader",
@@ -92,7 +92,7 @@
92
92
  "deps": []
93
93
  },
94
94
  "@uniweb/unipress": {
95
- "version": "0.4.4",
95
+ "version": "0.4.5",
96
96
  "path": "framework/unipress",
97
97
  "deps": [
98
98
  "@uniweb/build",
@@ -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.
@@ -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": {