uniweb 0.12.33 → 0.12.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,19 +16,22 @@
16
16
  * For static-host artifacts WITHOUT upload, see `uniweb export`. To publish a
17
17
  * site that already lives on the backend as a synced draft, see `uniweb release`.
18
18
  *
19
- * Uniweb-host flow (deployToUniwebBackend):
20
- * 1. Resolve the site dir + the deploy.yml target.
21
- * 2. discover() the backend's anonymous capability handshake (GET /dev/config):
22
- * delivery support + the installed-runtime list.
23
- * 3. Resolve the runtime: `site.yml::runtime` if pinned, else the highest
24
- * version the backend reports installed (fail closed if neither resolves).
25
- * 4. Build the site data (link mode): site-content.json (+ per-locale variants),
26
- * collection data, search indexes.
27
- * 5. Assemble the deploy payload (foundation, runtimeVersion, theme, languages,
28
- * defaultLanguage, locales, optional dataFiles/searchFiles).
29
- * 6. POST /dev/deploy via BackendClient the login bearer authorizes; on first
30
- * deploy the backend mints a delivery uuid (round-tripped through
31
- * deploy.yml::lastDeploy) and returns the serve URL.
19
+ * Uniweb-host flow (deployToUniwebBackend) — the composite deploy = build → ball →
20
+ * media push publish:
21
+ * 1. Resolve the site dir + deploy.yml target; discover() the backend (GET
22
+ * /dev/config) and resolve the runtime (`site.yml::runtime` if pinned, else the
23
+ * backend's highest installed; fail closed if neither resolves).
24
+ * 2. Build the site data (link mode): site-content.json (+ per-locale variants),
25
+ * collection data, search indexes, processed assets.
26
+ * 3. Partition collections by schema presence: schema-less → the static-data ball;
27
+ * schema-backed typed folder entities on the push lane.
28
+ * 4. Upload the site's local media (entity refs + the ball's refs, one deduped set)
29
+ * each site-root ref's backend serve URL; rewrite the ball with it, then upload the
30
+ * rewritten ball (content-addressed → `info.data_bundle`).
31
+ * 5. Push the SAME two-lane sync `uniweb push` uses (site-content with
32
+ * `info.data_bundle` stamped + media refs rewritten, then the folder + records) —
33
+ * over the send-only-changed cache; the backend mints/round-trips the site uuid.
34
+ * 6. Publish — make the just-pushed composite live; the backend returns the serve URL.
32
35
  *
33
36
  * Usage:
34
37
  * uniweb deploy Build + deploy to the resolved target
@@ -45,17 +48,20 @@
45
48
  */
46
49
 
47
50
  import { existsSync } from 'node:fs'
48
- import { readFile, readdir } from 'node:fs/promises'
49
- import { resolve, join, relative } from 'node:path'
51
+ import { readFile } from 'node:fs/promises'
52
+ import { resolve, join } from 'node:path'
50
53
  import { execSync } from 'node:child_process'
51
54
  import yaml from 'js-yaml'
52
55
 
53
- import { loadDeployYml, resolveTarget, recordLastDeploy, rewriteSiteContentPaths } from '@uniweb/build/site'
56
+ import { loadDeployYml, resolveTarget, recordLastDeploy, assembleDataBall, collectBallAssets, rewriteBallAssets } from '@uniweb/build/site'
54
57
  import { promptForHost } from '../utils/host-prompt.js'
55
58
  import { readFlagValue } from '../utils/args.js'
56
59
  import { parseBoolEnv } from '../utils/env.js'
57
60
  import { BackendClient } from '../backend/client.js'
58
- import { collectSiteAssets } from '../utils/asset-upload.js'
61
+ import { emitSyncPackages } from '@uniweb/build/uwx'
62
+ import { makeModelResolver, readSyncCache, pushSyncPackages } from '../backend/site-sync.js'
63
+ import { uploadDataBundle } from '../backend/data-bundle.js'
64
+ import { uploadSiteMedia } from '../backend/site-media.js'
59
65
 
60
66
  import {
61
67
  findWorkspaceRoot,
@@ -262,27 +268,20 @@ async function deployToUniwebBackend(siteDir, siteYml, { foundation, args, dryRu
262
268
  command: 'Deploying',
263
269
  })
264
270
 
265
- // Steer-hint (not enforced): a site that's been synced (has site.yml::$uuid) is
266
- // CMS-managed `uniweb release` publishes its current backend state, including
267
- // app-side edits. `deploy` still works: it hosts the LOCAL file-built payload as
268
- // a separate delivery (deploy ⊥ publish — alternative lifecycles, your choice).
269
- if (siteYml.$uuid) {
270
- say.warn('This site is synced (site.yml::$uuid) — CMS-managed.')
271
- say.dim('`uniweb release` publishes its current backend state; `deploy` hosts your local files as a separate delivery.')
272
- }
271
+ const foundationDir = readFlagValue(args, '--foundation') // optional local foundation for Model schemas
272
+ const asOrg = readFlagValue(args, '--as-org')
273
273
 
274
- // Anonymous capability handshake (cached). Confirms the deploy lane is offered
275
- // and supplies the installed-runtime list (the /dev/config replacement for the
276
- // retired Worker /runtime/latest).
274
+ // Anonymous capability handshake (cached). The composite deploy ends in a publish,
275
+ // so confirm that lane is offered (the push/sync lanes are the backend's baseline).
277
276
  const config = await client.discover()
278
- if (config?.delivery && config.delivery.deploy === false) {
279
- say.err(`Backend at ${client.origin} does not offer the deploy lane (delivery.deploy=false).`)
277
+ if (config?.delivery && config.delivery.publish === false) {
278
+ say.err(`Backend at ${client.origin} does not offer the publish lane (delivery.publish=false).`)
280
279
  process.exit(1)
281
280
  }
282
281
 
283
282
  // Runtime resolution: an explicit site.yml::runtime pin wins; else the highest
284
283
  // version the backend reports installed; else fail closed with a clear
285
- // precondition error (better than serving a site with no runtime — §9.4).
284
+ // precondition error (better than serving a site with no runtime).
286
285
  const installed = Array.isArray(config?.runtime?.installed) ? config.runtime.installed : []
287
286
  if (siteYml.runtime && installed.length && !installed.includes(siteYml.runtime)) {
288
287
  say.err(`Runtime ${siteYml.runtime} (from site.yml) is not installed on the backend.`)
@@ -296,23 +295,18 @@ async function deployToUniwebBackend(siteDir, siteYml, { foundation, args, dryRu
296
295
  process.exit(1)
297
296
  }
298
297
 
299
- // deploy.yml uuid round-trip: a prior deploy recorded the minted delivery uuid
300
- // under lastDeploy.<target>.siteUuid; resend it so /gateway/site/{uuid}/ stays
301
- // stable. First deploy has none → the backend mints one and we write it back.
302
- const priorUuid = readDeployedSiteUuid(deployYml, resolved.targetName)
303
-
304
298
  if (dryRun) {
305
- say.info('Dry run — would deploy to the Uniweb backend:')
299
+ say.info('Dry run — would deploy to the Uniweb backend as a composite (ball → push → publish):')
306
300
  say.dim(`Backend : ${client.origin}`)
307
301
  say.dim(`Foundation : ${typeof foundation === 'string' ? foundation : foundation.ref}`)
308
302
  say.dim(`Runtime : ${runtimeVersion}${siteYml.runtime ? '' : ' (highest installed)'}`)
309
- say.dim(`site_uuid : ${priorUuid || '(none — backend will mint)'}`)
303
+ say.dim(`site_uuid : ${siteYml.$uuid || '(none — the first push mints it)'}`)
310
304
  return
311
305
  }
312
306
 
313
- // Build (link mode): emits dist/site-content.json (+ per-locale variants under
314
- // dist/<lang>/), dist/data/*, dist/_search/*. Spawn the SAME CLI binary that's
315
- // running so the inner build can't resolve to a different installed version.
307
+ // Build (link mode): emits dist/data/*, dist/_search/*, dist/assets/*, and
308
+ // dist/site-content.json. Spawn the SAME CLI binary so the inner build can't resolve
309
+ // to a different installed version.
316
310
  say.info('Building site…')
317
311
  console.log('')
318
312
  execSync(`node ${JSON.stringify(process.argv[1])} build --link`, {
@@ -329,105 +323,125 @@ async function deployToUniwebBackend(siteDir, siteYml, { foundation, args, dryRu
329
323
  process.exit(1)
330
324
  }
331
325
 
332
- const siteContent = JSON.parse(await readFile(contentPath, 'utf8'))
333
- const languages = extractLanguages(siteContent)
334
- const defaultLanguage = siteContent?.config?.defaultLanguage || languages[0] || 'en'
335
- const theme = await readTheme(siteDir, siteContent)
336
-
337
- // Per-locale content: default + each non-default dist/<lang>/site-content.json.
338
- const localeContents = { [defaultLanguage]: siteContent }
339
- for (const lang of languages) {
340
- if (lang === defaultLanguage) continue
341
- const p = join(distDir, lang, 'site-content.json')
342
- if (existsSync(p)) localeContents[lang] = JSON.parse(await readFile(p, 'utf8'))
343
- else say.warn(`Locale "${lang}" listed in site config but no dist/${lang}/site-content.json — skipping.`)
344
- }
345
-
346
- // Collection JSON ( /data/<key>) and search indexes (→ /_search/<key>) two
347
- // distinct serve namespaces on the backend.
348
- const dataFiles = await collectDataFiles(distDir)
349
- if (Object.keys(dataFiles).length) say.dim(`Data files : ${Object.keys(dataFiles).length} (collection JSON)`)
350
- const searchFiles = await collectSearchFiles(distDir)
351
- if (Object.keys(searchFiles).length) say.dim(`Search indexes : ${Object.keys(searchFiles).length} (_search/ JSON)`)
352
-
353
- // Assets: when the backend advertises the lane (config.assets.supported), upload
354
- // the site's processed media (dist/assets/*) to the content-addressed store and
355
- // rewrite the content's local refs to durable serve URLs. Image-free sites
356
- // collect nothing and deploy unchanged; an un-advertised lane is skipped.
357
- // Contract: kb/framework/build/delivery-lane.md §Assets (channel f90d).
358
- if (config?.assets && config.assets.supported === false) {
359
- say.dim('Asset lane not yet available on this backend skipping upload (image-free deploy).')
360
- } else {
361
- const assetFiles = collectSiteAssets(distDir)
362
- if (assetFiles.length) {
363
- say.info(`Uploading ${assetFiles.length} asset(s)…`)
364
- let assetResult
365
- try {
366
- assetResult = await client.uploadSiteAssets({ distDir, files: assetFiles, onProgress: (m) => say.dim(` ${m}`) })
367
- } catch (err) {
368
- say.err(`Asset upload failed: ${err.message}`)
369
- process.exit(1)
370
- }
371
- if (assetResult.failed.length) {
372
- say.err(`${assetResult.failed.length} asset(s) failed to upload:`)
373
- for (const f of assetResult.failed) say.dim(` ${f.path} HTTP ${f.status} ${f.detail}`)
374
- process.exit(1)
375
- }
376
- // Build localUrl → durable serve URL, then re-run the build's own rewrite
377
- // over each locale's content (image nodes, marks, dataBlocks, param fields).
378
- // assetBase comes from /dev/config (origin-relative in dev → prepend origin;
379
- // absolute CDN in prod → used verbatim).
380
- const urlMapping = {}
381
- for (const [localUrl, { id, ext }] of Object.entries(assetResult.assetsByLocalUrl)) {
382
- urlMapping[localUrl] = buildAssetUrl(client.origin, config.assetBase, id, ext)
383
- }
384
- for (const lang of Object.keys(localeContents)) {
385
- localeContents[lang] = rewriteSiteContentPaths(localeContents[lang], urlMapping)
386
- }
387
- const skippedNote = assetResult.skipped?.length ? `, ${assetResult.skipped.length} already present` : ''
388
- say.dim(`Assets : ${assetResult.uploaded.length} uploaded${skippedNote} (${assetResult.mode}) → ${config.assetBase}`)
326
+ // Non-local @std/registry Model schemas resolve through the backend (same as push).
327
+ const resolveModel = makeModelResolver({ client, offline: false })
328
+
329
+ // 1. Partition the collections by schema presence. A first emit reads `schemaless`
330
+ // — the collections with no data schema, delivered statically via the ball. Its
331
+ // packages are discarded (deploy is not a hot path; the cheap clarity beats a
332
+ // schemaless-only fast path, a later optimization).
333
+ let probe
334
+ try {
335
+ probe = await emitSyncPackages(siteDir, { ...(foundationDir ? { foundationDir } : {}), resolveModel })
336
+ } catch (err) {
337
+ say.err(`Could not build the sync package: ${err.message}`)
338
+ process.exit(1)
339
+ }
340
+ const schemalessNames = (probe.schemaless || []).map((col) => col.name)
341
+ const localAssets = probe.localAssets || [] // entity-content site-root media refs
342
+
343
+ // 2. Assemble the static-data ball (schema-less collection data + the search index)
344
+ // BEFORE uploading it, because its schema-less records can carry local media too,
345
+ // which we upload + rewrite to serve URLs exactly like entity content (the backend
346
+ // serves a serve_url in the ball identically — it unwraps the ball verbatim).
347
+ let ball = await assembleDataBall(distDir, schemalessNames)
348
+ const ballAssets = collectBallAssets(ball)
349
+
350
+ // 2b. Upload ALL local media (entity refs + ball refs) on one asset lane the
351
+ // ref→serveUrl map. The same map rewrites the entity content (assetRewrite, real
352
+ // emit below) AND the ball (here, before it's uploaded). Co-located refs were
353
+ // warned + skipped by the producer; a missing file is skipped here (warned).
354
+ let assetRewrite = null
355
+ const mediaRefs = [...new Set([...localAssets, ...ballAssets])]
356
+ if (mediaRefs.length) {
357
+ say.info('Uploading media…')
358
+ try {
359
+ const map = await uploadSiteMedia(client, siteDir, mediaRefs, {
360
+ onProgress: (m) => say.dim(` ${m}`),
361
+ warn: (m) => say.dim(`! ${m}`),
362
+ })
363
+ if (Object.keys(map).length) assetRewrite = map
364
+ if (ballAssets.length) ball = rewriteBallAssets(ball, map) // swap the ball's local refs → serve URLs
365
+ say.dim(`Media : ${Object.keys(map).length}/${mediaRefs.length} ref(s) → serve URL`)
366
+ } catch (err) {
367
+ say.err(`Media upload failed: ${err.message}`)
368
+ process.exit(1)
389
369
  }
390
370
  }
391
371
 
392
- const payload = {
393
- foundation,
394
- runtimeVersion,
395
- theme,
396
- languages,
397
- defaultLanguage,
398
- ...(Object.keys(dataFiles).length ? { dataFiles } : {}),
399
- ...(Object.keys(searchFiles).length ? { searchFiles } : {}),
400
- // One entry per language — single-locale sites end up with { [default]: content };
401
- // multi-locale carry per-locale translated content. Same shape as Editor publish.
402
- locales: localeContents,
372
+ // 2c. Upload the (media-rewritten) ball. `data_bundle` is its content-addressed serve
373
+ // URL; omitted when there is nothing static to deliver.
374
+ let dataBundle
375
+ if (ball) {
376
+ say.info('Uploading data bundle…')
377
+ try {
378
+ dataBundle = await uploadDataBundle(client, ball, { onProgress: (m) => say.dim(` ${m}`) })
379
+ } catch (err) {
380
+ say.err(`Data bundle upload failed: ${err.message}`)
381
+ process.exit(1)
382
+ }
383
+ say.dim(`Data bundle : ${Object.keys(ball.data).length} data + ${Object.keys(ball.search).length} search file(s)`)
384
+ }
385
+
386
+ // 3. Push the site (content + folder) over the send-only-changed cache — the SAME
387
+ // two-lane submission `uniweb push` uses — stamping info.data_bundle on the
388
+ // site-content entity and rewriting local media refs to their backend serve URLs.
389
+ const priorHashes = readSyncCache(siteDir)
390
+ let pkg
391
+ try {
392
+ pkg = await emitSyncPackages(siteDir, {
393
+ ...(foundationDir ? { foundationDir } : {}),
394
+ resolveModel,
395
+ priorHashes,
396
+ ...(dataBundle ? { injectInfo: { data_bundle: dataBundle } } : {}),
397
+ ...(assetRewrite ? { assetRewrite } : {}),
398
+ })
399
+ } catch (err) {
400
+ say.err(`Could not build the sync package: ${err.message}`)
401
+ process.exit(1)
402
+ }
403
+ for (const w of pkg.warnings) say.dim(`! ${w}`)
404
+ const report = {
405
+ info: (m) => say.info(m),
406
+ note: (m) => say.dim(m),
407
+ error: (m) => say.err(m),
408
+ dim: (s) => `${c.dim}${s}${c.reset}`,
409
+ }
410
+ const pushResult = await pushSyncPackages({ client, siteDir, pkg, asOrg, report })
411
+ if (pushResult.exitCode !== 0) process.exit(pushResult.exitCode)
412
+ const siteUuid = pushResult.boundSiteUuid
413
+ if (!siteUuid) {
414
+ say.err('Push did not yield a site uuid — cannot publish.')
415
+ process.exit(1)
403
416
  }
404
417
 
405
- say.info(`Deploying to ${c.dim}${client.origin}${c.reset} …`)
406
- let res
418
+ // 4. Publish: make the just-pushed composite live (its current backend state).
419
+ const siteContent = JSON.parse(await readFile(contentPath, 'utf8'))
420
+ const languages = extractLanguages(siteContent)
421
+ say.info(`Publishing to ${c.dim}${client.origin}${c.reset} …`)
422
+ let pubRes
407
423
  try {
408
- res = await client.deploy(payload, { siteUuid: priorUuid || undefined })
424
+ pubRes = await client.publishSite(siteUuid, { runtimeVersion, ...(languages ? { languages } : {}) })
409
425
  } catch (err) {
410
426
  say.err(`Could not reach the backend at ${client.origin}: ${err.message}`)
411
427
  say.dim('Set the origin with --backend <url> or UNIWEB_REGISTER_URL.')
412
428
  process.exit(1)
413
429
  }
414
- if (!res.ok) {
415
- say.err(`Deploy rejected: HTTP ${res.status} ${res.statusText}`)
416
- if (res.status === 401 || res.status === 403) {
430
+ if (!pubRes.ok) {
431
+ say.err(`Publish rejected: HTTP ${pubRes.status} ${pubRes.statusText}`)
432
+ if (pubRes.status === 401 || pubRes.status === 403) {
417
433
  say.dim("Credentials weren't accepted — run `uniweb login` (or pass --token <bearer>).")
418
434
  }
419
- const body = await res.text().catch(() => '')
435
+ const body = await pubRes.text().catch(() => '')
420
436
  if (body) say.dim(body.slice(0, 800))
421
437
  process.exit(1)
422
438
  }
423
439
  let result
424
- try { result = await res.json() } catch { result = {} }
425
-
426
- const mintedUuid = result.site_uuid || priorUuid || null
440
+ try { result = await pubRes.json() } catch { result = {} }
427
441
  const serveUrl = absolutizeServeUrl(client.origin, result.url)
428
442
 
429
- // Persist deploy memory + the minted uuid for the next round-trip. recordLastDeploy
430
- // touches only lastDeploy.<target>, so siteUuid rides there safely.
443
+ // Persist deploy memory. One identity: site.yml::$uuid (the push uuid) no separate
444
+ // deploy uuid. recordLastDeploy touches only lastDeploy.<target>.
431
445
  await persistLastDeploy(siteDir, {
432
446
  targetName: resolved.targetName,
433
447
  targetConfig: resolved.fromFile ? null : { host: 'uniweb' },
@@ -436,7 +450,7 @@ async function deployToUniwebBackend(siteDir, siteYml, { foundation, args, dryRu
436
450
  at: new Date().toISOString(),
437
451
  host: 'uniweb',
438
452
  backend: client.origin,
439
- siteUuid: mintedUuid,
453
+ siteUuid,
440
454
  url: serveUrl,
441
455
  foundation: { ref: typeof foundation === 'string' ? foundation : foundation?.ref },
442
456
  runtime: runtimeVersion,
@@ -445,7 +459,7 @@ async function deployToUniwebBackend(siteDir, siteYml, { foundation, args, dryRu
445
459
  })
446
460
 
447
461
  console.log('')
448
- say.ok(`Deployed ${c.bold}${mintedUuid || 'site'}${c.reset}`)
462
+ say.ok(`Deployed ${c.bold}${siteUuid}${c.reset}`)
449
463
  if (serveUrl) console.log(` ${c.cyan}${serveUrl}${c.reset}`)
450
464
  }
451
465
 
@@ -457,12 +471,6 @@ function pickHighestRuntime(installed) {
457
471
  return [...installed].sort((a, b) => String(b).localeCompare(String(a), undefined, { numeric: true }))[0]
458
472
  }
459
473
 
460
- // The previously-minted delivery uuid for `targetName` (lastDeploy.<target>.siteUuid
461
- // in a loaded deploy.yml), or null on a first deploy / absent file.
462
- function readDeployedSiteUuid(deployYml, targetName) {
463
- return deployYml?.lastDeploy?.[targetName]?.siteUuid || null
464
- }
465
-
466
474
  // The deploy response `url` is the serve path. When origin-relative (the self-serve
467
475
  // default, e.g. /gateway/site/<uuid>/) prefix the BackendClient origin so the printed
468
476
  // link is clickable; absolute URLs pass through unchanged.
@@ -472,15 +480,6 @@ function absolutizeServeUrl(origin, url) {
472
480
  return `${origin.replace(/\/$/, '')}${url.startsWith('/') ? '' : '/'}${url}`
473
481
  }
474
482
 
475
- // Build a durable asset serve URL from /dev/config's assetBase. Origin-relative
476
- // (`/gateway/asset/` in dev) → prepend the backend origin; absolute (a prod CDN)
477
- // → used verbatim. Shape: {assetBase}dist/{id}/base.{ext} — basename literally
478
- // `base`, {ext} the source extension the plan echoed.
479
- function buildAssetUrl(origin, assetBase, id, ext) {
480
- const base = /^https?:\/\//.test(assetBase) ? assetBase : `${origin}${assetBase}`
481
- return `${base.replace(/\/$/, '')}/dist/${id}/base.${ext}`
482
- }
483
-
484
483
  // ─── Static-host deploy (S3+CloudFront, etc.) ─────────────────
485
484
  //
486
485
  // Distinct from the uniweb-edge flow above. Picked when the resolved
@@ -679,66 +678,3 @@ function extractLanguages(siteContent) {
679
678
  // Three accepted shapes: plain `'en'`, Editor `{ value, label }`, site.yml `{ code, label }`.
680
679
  return langs.map((l) => (typeof l === 'string' ? l : l?.value || l?.code)).filter(Boolean)
681
680
  }
682
-
683
- // Collect compiled collection JSON files from dist/data/ recursively.
684
- // Returns `{ '<relPath>': '<utf8-content>' }` keyed by the path under data/
685
- // so the worker can write each to `${sitePrefix}/data/<relPath>` in R2.
686
- // Empty object when the site has no `collection:` data sources.
687
- async function collectDataFiles(distDir) {
688
- const dataDir = join(distDir, 'data')
689
- if (!existsSync(dataDir)) return {}
690
- const files = {}
691
- const entries = await readdir(dataDir, { withFileTypes: true, recursive: true })
692
- for (const entry of entries) {
693
- if (!entry.isFile()) continue
694
- if (!entry.name.endsWith('.json')) continue
695
- const fullPath = join(entry.parentPath || entry.path, entry.name)
696
- const relPath = relative(dataDir, fullPath)
697
- files[relPath] = await readFile(fullPath, 'utf8')
698
- }
699
- return files
700
- }
701
-
702
- // Collect search index files from dist/_search/ recursively.
703
- // Returns `{ '<locale>/<name>.json': '<utf8-content>' }` so the worker can
704
- // write each to `${sitePrefix}/_search/<key>` in R2, gated by searchEnabled.
705
- // Empty object when the build emitted no search indexes.
706
- async function collectSearchFiles(distDir) {
707
- const searchDir = join(distDir, '_search')
708
- if (!existsSync(searchDir)) return {}
709
- const files = {}
710
- const entries = await readdir(searchDir, { withFileTypes: true, recursive: true })
711
- for (const entry of entries) {
712
- if (!entry.isFile()) continue
713
- if (!entry.name.endsWith('.json')) continue
714
- const fullPath = join(entry.parentPath || entry.path, entry.name)
715
- const relPath = relative(searchDir, fullPath)
716
- files[relPath] = await readFile(fullPath, 'utf8')
717
- }
718
- return files
719
- }
720
-
721
- /**
722
- * Resolve theme config.
723
- *
724
- * The build pipeline does not (today) emit a separate theme.json, so we read
725
- * the developer-authored theme.yml from the site root. The Worker's
726
- * `buildTheme()` tolerates an empty config — sites with no theme.yml still
727
- * publish, they just get default tokens.
728
- */
729
- async function readTheme(siteDir, siteContent) {
730
- const themePath = join(siteDir, 'theme.yml')
731
- if (existsSync(themePath)) {
732
- try {
733
- const parsed = yaml.load(await readFile(themePath, 'utf8'))
734
- if (parsed && typeof parsed === 'object') return parsed
735
- } catch {
736
- // fall through to site-content.json fallback
737
- }
738
- }
739
- // site-content sometimes carries a `theme` key produced by collectors.
740
- if (siteContent?.theme && typeof siteContent.theme === 'object') {
741
- return siteContent.theme
742
- }
743
- return {}
744
- }
@@ -33,9 +33,13 @@ import { readFile } from 'node:fs/promises'
33
33
  import { join } from 'node:path'
34
34
  import yaml from 'js-yaml'
35
35
 
36
+ import { createInterface } from 'node:readline/promises'
37
+
36
38
  import { BackendClient } from '../backend/client.js'
37
39
  import { resolveSiteDir } from './deploy.js'
38
40
  import { readFlagValue } from '../utils/args.js'
41
+ import { isNonInteractive } from '../utils/interactive.js'
42
+ import { probeUnpushed } from '../backend/site-sync.js'
39
43
 
40
44
  const c = {
41
45
  reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
@@ -49,6 +53,18 @@ const say = {
49
53
  dim: (m) => console.log(` ${c.dim}${m}${c.reset}`),
50
54
  }
51
55
 
56
+ // Minimal yes/no prompt. Returns `defaultYes` on an empty answer.
57
+ async function confirm(question, defaultYes = false) {
58
+ const rl = createInterface({ input: process.stdin, output: process.stdout })
59
+ try {
60
+ const a = (await rl.question(`${question} ${defaultYes ? '[Y/n]' : '[y/N]'} `)).trim().toLowerCase()
61
+ if (!a) return defaultYes
62
+ return a === 'y' || a === 'yes'
63
+ } finally {
64
+ rl.close()
65
+ }
66
+ }
67
+
52
68
  // Highest installed runtime from the backend's /dev/config list (numeric-aware
53
69
  // sort). Mirrors deploy.js's resolver. Null when the list is empty.
54
70
  function pickHighestRuntime(installed) {
@@ -76,6 +92,7 @@ function extractLanguages(siteYml) {
76
92
 
77
93
  export async function publish(args = []) {
78
94
  const dryRun = args.includes('--dry-run')
95
+ const skipVerify = args.includes('--yes') || args.includes('--force') || args.includes('--no-verify')
79
96
  const siteDir = await resolveSiteDir(args, 'publish')
80
97
 
81
98
  // The site-content uuid lives in site.yml::$uuid (written by `uniweb push`).
@@ -134,6 +151,27 @@ export async function publish(args = []) {
134
151
  return { exitCode: 0 }
135
152
  }
136
153
 
154
+ // Pre-flight: `publish` makes the BACKEND's current state live — NOT your local
155
+ // files. If local content differs from the last push, surface it. Interactive
156
+ // only; --yes / --force / --no-verify skip it, and a build error never blocks.
157
+ if (!skipVerify && !isNonInteractive(args)) {
158
+ let probe = null
159
+ try {
160
+ probe = await probeUnpushed(siteDir)
161
+ } catch {
162
+ probe = null
163
+ }
164
+ if (probe && probe.changed > 0) {
165
+ say.warn(`You have ${probe.changed} unpushed local content change${probe.changed === 1 ? '' : 's'}.`)
166
+ say.dim('`publish` makes the backend state live as-is; local edits are not included. Push first, or `uniweb deploy` to do both.')
167
+ const proceed = await confirm('Publish the current backend state anyway?', true)
168
+ if (!proceed) {
169
+ say.info('Aborted — run `uniweb push`, then `uniweb publish`.')
170
+ return { exitCode: 0 }
171
+ }
172
+ }
173
+ }
174
+
137
175
  say.info(`Publishing the synced site to ${c.dim}${client.origin}${c.reset} …`)
138
176
  say.dim('Publishes the CURRENT backend state (incl. app-side edits) — run `uniweb push` first to include local edits.')
139
177
  let res