uniweb 1.21.22 → 1.22.0

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": "1.21.22",
3
+ "version": "1.22.0",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,15 +41,15 @@
41
41
  "js-yaml": "^4.3.2",
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.5.21",
44
- "@uniweb/content-writer": "~1.3.1",
45
- "@uniweb/core": "~1.4.12",
46
44
  "@uniweb/schemas": "~1.6.5",
47
- "@uniweb/semantic-parser": "~1.13.0"
45
+ "@uniweb/content-writer": "~1.3.1",
46
+ "@uniweb/semantic-parser": "~1.13.0",
47
+ "@uniweb/core": "~1.4.12"
48
48
  },
49
49
  "peerDependencies": {
50
- "@uniweb/build": "~1.10.14",
50
+ "@uniweb/semantic-parser": "~1.13.0",
51
51
  "@uniweb/content-reader": "~1.5.7",
52
- "@uniweb/semantic-parser": "~1.13.0"
52
+ "@uniweb/build": "~1.11.0"
53
53
  },
54
54
  "peerDependenciesMeta": {
55
55
  "@uniweb/build": {
@@ -544,7 +544,9 @@ Optional attributes: `{size=20}`, `{color=red}`. Custom SVGs: `![Logo](./logo.sv
544
544
  ![alt](./img.jpg){href=/products} <!-- Clickable media: href + target ride along -->
545
545
  ```
546
546
 
547
- Sizing and loading ride on the image: `{width=800 height=600 loading=lazy fit=cover position=center}`, and its credit as `{author="Jane Doe"}`. A video adds `{poster=./thumb.jpg autoplay muted loop controls}`; a `{role=pdf}` document adds `{preview=./cover.jpg author=… description=…}` and arrives in `content.documents` — `{ url, name, mime, size?, alt, caption, … }` — declare it in `content:` as `documents`.
547
+ Sizing and loading ride on the image: `{width=800 height=600 loading=lazy fit=cover position=center}`, and its credit as `{author="Jane Doe"}`. A video adds `{poster=./thumb.jpg autoplay muted loop controls}`; a `{role=pdf}` document adds `{preview=./cover.jpg author=… description=…}` and arrives in `content.documents` — `{ url, name, mime, size?, alt, caption, … }` — declare it in `content:` as `documents`. Name a video's poster: a static build makes one from the video only when none is named and ffmpeg is installed, and `uniweb publish` sends only the poster you name.
548
+
549
+ **Where media lives.** `./x` names a file beside the page, `/x` a file in `public/`, and both reach a static build and a site published with Uniweb. Keep a page's media beside it, and keep in `public/` what needs a fixed address: the favicon, fonts, a file other sites link to. `uniweb push` uploads a file beside a page wherever the page is, in a folder `paths:` mounts too; a file outside the site and the folders it mounts is not uploaded, so put that in `public/`. A site has no `assets/` folder for media: a build stops on a `/x` only `assets/` holds.
548
550
 
549
551
  **Placement — where a block sits in a section rendered in order.** `{placement=left}` (or `right`, `wide`, `full`) on an image or a video; a table takes it on its **caption line**, the paragraph after the table, after a blank line — `: Plans and prices {placement=wide #tbl-plans}`, which also gives the table a caption and a cross-reference id. It is the author's intent, not a size: kit's `<Render>`, `<Prose>` and `<Article>` lay all four out (`data-placement`, styled by `prose-tokens.css`), and any other component ignores it. A section type says which it honours in `meta.js` — `sequence: { label, placement: true }` — and the build warns when a file asks for one its type does not offer.
550
552
 
@@ -2877,8 +2879,8 @@ Foundations have their own free path too: `uniweb add ci --target foundation` pu
2877
2879
  **Three facts about a site, and they are independent — `uniweb status` opens with all three:**
2878
2880
 
2879
2881
  - **Synced** — the project has a site on a backend, so authors can edit it in the Uniweb app. `sync.json` names it on each backend: `https://uniweb.app` by default, or a server of your own (`uniweb login --server <url>`).
2880
- - **Published** — that backend serves it live: Uniweb Cloud, on `https://uniweb.app`. A published site is a synced site; a synced site need not be published — it can sit in the app as a draft.
2881
- - **Deployed** — a build of it is on another host, such as GitHub Pages or S3: by CI on every push (`uniweb add ci`), or by hand (`uniweb deploy`). `deploy.yml` lists those hosts, and a bare `uniweb deploy` goes to its default target.
2882
+ - **Published** — that backend serves it live: Uniweb Cloud, on `https://uniweb.app`. A published site is a synced site; a synced site need not be published — it can sit in the app as a draft. A publish is recorded in `deploy.yml` too, under a `host: uniweb` target: `deploys.<target>.url` is the address that publish delivered to. Where visitors go now (a custom domain, while one is active) is the backend's answer, and `uniweb status` shows it.
2883
+ - **Deployed** — a build of it is on another host, such as GitHub Pages or S3: by CI on every push (`uniweb add ci`), or by hand (`uniweb deploy`). `deploy.yml` lists those hosts beside the one a publish records, and a bare `uniweb deploy` goes to its default target.
2882
2884
 
2883
2885
  A site can be in more than one place — usually while it moves from one host to another. Before you ship, read where it is: `publish` makes a change live on Uniweb Cloud, `git push` on a CI-deployed host, and `uniweb deploy --target <name>` on one deployed by hand — and each of these is the person's to decide. `uniweb status` also lists the services the live site has (`services:` in `site.yml` is the request, never proof of what the site has).
2884
2886
 
@@ -3074,6 +3076,8 @@ These are **body only — no frontmatter**; params and config still come from th
3074
3076
 
3075
3077
  **A tagged data block translates by the shape its key declares** in the component's `meta.js` `data:` — a data schema or an inline field map: its text fields, never an enum, a URL or a `translatable: false` field. A key declared `{}` leaves its blocks to a heuristic that offers any string whose field name and value don't look structural, so a `style: display-black` naming one of your component's looks gets translated too. Declare the shape of every block a translator will see — `{ name: 'string', style: { type: 'string', enum: [...] } }`.
3076
3078
 
3079
+ **An image translates by its alt text.** Words alone give it its alt text in that language and keep its file. A translation that is an image also names the file that language shows: `"2d093636": "![La superposition](/docs-media/fr/overview.png)"`. Name it with a `/` path, a file in `public/`. A caption or a video's `poster` can be given the same way. An image with no alt text has nothing to translate, so give every image one.
3080
+
3077
3081
  **Records translate in the same `extract` run**, into their own manifest at `locales/records/manifest.json` — keyed by the RECORD, so a record translated once is found by every query that returns it. A record whose query has a data schema translates exactly the fields the schema says are text — never an enum, a URL, a date, a number, a file, a reference or a `translatable: false` field — which are the fields a push carries in each language; mark a string that is a code or a key `translatable: false` and no translator is asked for it. An excerpt the build derives is re-derived from each language's body.
3078
3082
 
3079
3083
  **Component side.** Nothing to do: `content.title` arrives in the active language. The one thing a foundation builds is a switcher.
@@ -920,8 +920,9 @@ export class BackendClient {
920
920
  /**
921
921
  * GET /dev/site — one page of the sites in the workspace the client names
922
922
  * (`setWorkspace`; none ⇒ personal): `{ sites: [{ uuid, name?, updated_at,
923
- * deployment?: { status, published_url, … } }] }`. A page shorter than `limit` is
924
- * the last. `deployment` is absent for a site never published.
923
+ * deployment?: { status, published_url, visitor_url, … } }] }`. A page shorter than
924
+ * `limit` is the last. `deployment` is absent for a site never published. `uniweb site`
925
+ * shows `visitor_url`, else `published_url` (`liveAddress`).
925
926
  *
926
927
  * @param {{ limit?: number, offset?: number }} [opts]
927
928
  * @returns {Promise<Response>}
@@ -951,11 +952,14 @@ export class BackendClient {
951
952
  * who has the site open in the app right now, and on what — `uniweb status` shows
952
953
  * it (`describeEditors`, commands/status.js).
953
954
  *
954
- * ⭐ **The backend also serves a LIVE-SITE record here, and nothing in this CLI reads
955
- * it yet** (shipped 2026-08-29; documented here so it is not lost twice):
955
+ * ⭐ **The backend also serves a LIVE-SITE record here** (shipped 2026-08-29), and
956
+ * `uniweb status` reads part of it: the address it shows (`visitor_url`, else
957
+ * `last_published_url` — `liveAddress`), and the foundation and runtime its `--json`
958
+ * detail carries. Nothing reads the rest yet:
956
959
  *
957
960
  * last_published_url · last_published_foundation · last_published_extensions
958
961
  * last_published_runtime · last_published_runtime_floor · last_published_runtime_resolution
962
+ * visitor_url — where visitors go: the custom domain while one is active (2026-10-28)
959
963
  *
960
964
  * Three things about it that a reader will otherwise get wrong:
961
965
  *
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Where a site's visitors go — the address the CLI shows wherever it says where a site is live:
3
+ * `uniweb publish`, `uniweb status`, `uniweb site list` and `uniweb site unpublish`.
4
+ *
5
+ * A backend names two addresses for a published site. `visitor_url` is where its visitors go: the
6
+ * site's custom domain while one is active, else the address it was delivered to. The other — `url`
7
+ * on a publish, `last_published_url` on a site's status, `deployment.published_url` on a list row —
8
+ * is where the last publish delivered it, which may be a subdomain that only redirects to that
9
+ * domain. The first is shown; the second only where the first is absent or null, as from a backend
10
+ * that does not send it. Both are complete addresses, read verbatim and never composed onto.
11
+ *
12
+ * ⛔ Not a liveness claim. Whether a site IS live is `published` (status) or `deployment.status`
13
+ * (list): an unpublished site keeps both addresses.
14
+ *
15
+ * @param {unknown} visitorUrl - the answer's `visitor_url`
16
+ * @param {unknown} deliveredUrl - the address the last publish delivered the site to
17
+ * @returns {string|null}
18
+ */
19
+ export function liveAddress(visitorUrl, deliveredUrl) {
20
+ for (const url of [visitorUrl, deliveredUrl]) if (typeof url === 'string' && url) return url
21
+ return null
22
+ }
@@ -5,7 +5,8 @@
5
5
  *
6
6
  * Input is the site-root asset refs the producer surfaced in
7
7
  * `emitSyncPackages().localAssets` (`/images/hero.png`); `resolveAssetPath` finds the
8
- * file under the site's `public/` (or `assets/`). A ref whose file is missing is
8
+ * file under the site's `public/`, and refuses one only `assets/` holds, a folder retired
9
+ * 2026-10-28. A ref whose file is missing is
9
10
  * skipped (warned), never a broken serve URL. The serve URL is the backend's canonical
10
11
  * `serve_url`, read verbatim — origin-relative forms included; nothing here composes one.
11
12
  * Content-addressed like every asset: identical bytes → same id → a re-deploy of
@@ -49,8 +50,9 @@ export async function uploadSiteMedia(
49
50
  const files = []
50
51
  const missing = []
51
52
  // ⭐ A ref is a site-root media path (`/images/x.png`), resolved under the site — or a file the
52
- // caller already located, `{ ref, path, contentType }`: a file record's file and its preview, which
53
- // live in the records directory and declare their own type (`@uniweb/file`, 2026-09-28).
53
+ // caller already located, `{ ref, path, contentType? }`: a file record's file and its preview, which
54
+ // live in the records directory and declare their own type (`@uniweb/file`, 2026-09-28), and a
55
+ // section's co-located file, keyed by its path from the site root (`uwx/located-media.js`, 2026-10-28).
54
56
  const keys = []
55
57
  for (const item of refs) {
56
58
  const ref = typeof item === 'string' ? item : item?.ref
@@ -29,7 +29,6 @@ import {
29
29
  } from '../utils/sync-point.js'
30
30
  import { siteContentRoots } from '../utils/git.js'
31
31
  import { humanBytes } from '../utils/bytes.js'
32
- import { isAuthoredPreview } from '../utils/preview.js'
33
32
  import {
34
33
  describeRequestError,
35
34
  describeWorkspace,
@@ -56,7 +55,6 @@ import {
56
55
  updateBackendMap,
57
56
  clearBackendSections,
58
57
  normalizeBackendOrigin,
59
- writeSiteConfig,
60
58
  harvestRecordItems,
61
59
  storedRecordItems,
62
60
  reprintRecordItems,
@@ -734,51 +732,6 @@ export function clearRemoteSyncStateIfUnbound(siteDir, backend) {
734
732
  return stale
735
733
  }
736
734
 
737
- /**
738
- * Drop the `site.yml` values that describe ONE PARTICULAR backend site, when this
739
- * project is bound to none (no `$uuid`) — a brand-new site, or the state our own
740
- * "clear `$uuid` to re-publish as a new site" recovery puts you in. Today that is
741
- * one value: `preview` in the APP's form — a timestamp naming the previous site's
742
- * generated card image, which is keyed by that site's uuid and would dangle on this
743
- * one.
744
- *
745
- * ⛔ An AUTHOR's preview is theirs and stays: a URL, or a path to an image in the
746
- * project, is as good for the new site as for the old.
747
- *
748
- * (`$url`, where the previous site was live, was dropped here too until it was
749
- * retired on 2026-09-17. A leftover line is inert — nothing reads or sends it.)
750
- *
751
- * The site.yml sibling of `clearRemoteSyncStateIfUnbound`, called beside it for the
752
- * same reason — before the create mints a uuid and makes the project look bound.
753
- *
754
- * @param {string} siteDir
755
- * @returns {string[]} the keys removed
756
- */
757
- export function dropSiteBoundValues(siteDir, backend) {
758
- const file = join(siteDir, 'site.yml')
759
- let y
760
- try {
761
- y = yaml.load(readFileSync(file, 'utf8'))
762
- } catch {
763
- return []
764
- }
765
- // ⛔ "Bound" is a question about THIS backend, answered by sync.json. This read
766
- // `site.yml::$uuid` until 2026-09-20 and kept doing so after step 4 moved the key —
767
- // so every project read as unbound and every push stripped a bound site's
768
- // generated preview. A write, from a stale read, with nothing to notice it.
769
- if (!y || typeof y !== 'object') return []
770
- if (readBackendState(siteDir, backend).site?.uuid) return []
771
- const dropped = []
772
- if (
773
- y.preview !== undefined &&
774
- !isAuthoredPreview(y.preview) &&
775
- writeSiteConfig(siteDir, { preview: null }) === 'updated'
776
- ) {
777
- dropped.push('preview')
778
- }
779
- return dropped
780
- }
781
-
782
735
  export function readSyncCache(siteDir, backend) {
783
736
  return readMap(siteDir, backend, 'hashes')
784
737
  }
@@ -1112,9 +1112,16 @@ const DESCRIPTION_SHOWN = 160
1112
1112
  /** A path into the site's own files (`/images/card.jpg`), not an address of its own. */
1113
1113
  const isSitePath = (v) => typeof v === 'string' && v.startsWith('/') && !v.startsWith('//')
1114
1114
 
1115
- /** `/x` → the file under the site's public/ or assets/ that serves it, or null when there is none. */
1115
+ /** `/x` → the file under the site's public/ that serves it, or null when there is none. */
1116
1116
  function siteFile(sitePath, ref) {
1117
- const { resolved } = resolveAssetPath(ref, join(sitePath, 'site.yml'), sitePath)
1117
+ let resolved
1118
+ try {
1119
+ ;({ resolved } = resolveAssetPath(ref, join(sitePath, 'site.yml'), sitePath))
1120
+ } catch (err) {
1121
+ // A file in assets/, which no longer serves a `/x` — the build says how to move it.
1122
+ if (err?.code === 'ASSETS_FOLDER_RETIRED') return null
1123
+ throw err
1124
+ }
1118
1125
  return resolved && existsSync(resolved) ? resolved : null
1119
1126
  }
1120
1127
 
@@ -1195,8 +1202,8 @@ export async function checkLinkPreviews({ sitePath, siteName, issues }) {
1195
1202
  const file = siteFile(sitePath, image)
1196
1203
  if (!file) {
1197
1204
  const id = 'share-image-missing'
1198
- issues.push({ id, type: 'warn', site: siteName, message: `${image}, the image a link to ${shownOn} shows, is not in public/ or assets/` })
1199
- warn(`[${id}] ${image} is not in public/ or assets/ — a link to ${shownOn} shows no image`)
1205
+ issues.push({ id, type: 'warn', site: siteName, message: `${image}, the image a link to ${shownOn} shows, is not in public/` })
1206
+ warn(`[${id}] ${image} is not in public/ — a link to ${shownOn} shows no image`)
1200
1207
  continue
1201
1208
  }
1202
1209
  readImageSize ??= (await import('@uniweb/build/images')).readImageSize
@@ -1276,8 +1283,8 @@ export async function checkFavicon({ sitePath, siteName, siteYml, issues, should
1276
1283
  if (named) {
1277
1284
  if (!isSitePath(named) || siteFile(sitePath, named)) return
1278
1285
  const id = 'favicon-missing'
1279
- issues.push({ id, type: 'warn', site: siteName, message: `favicon: ${named} is not in public/ or assets/` })
1280
- warn(`[${id}] favicon: ${named} is not in public/ or assets/ — every page links an icon that is not there`)
1286
+ issues.push({ id, type: 'warn', site: siteName, message: `favicon: ${named} is not in public/` })
1287
+ warn(`[${id}] favicon: ${named} is not in public/ — every page links an icon that is not there`)
1281
1288
  return
1282
1289
  }
1283
1290
 
@@ -1359,8 +1366,8 @@ export async function checkTouchIcon({ sitePath, siteName, siteYml, issues, shou
1359
1366
  const file = siteFile(sitePath, named)
1360
1367
  if (!file) {
1361
1368
  const id = 'touch-icon-missing'
1362
- issues.push({ id, type: 'warn', site: siteName, message: `touchIcon: ${named} is not in public/ or assets/` })
1363
- warn(`[${id}] touchIcon: ${named} is not in public/ or assets/ — every page links an icon that is not there`)
1369
+ issues.push({ id, type: 'warn', site: siteName, message: `touchIcon: ${named} is not in public/` })
1370
+ warn(`[${id}] touchIcon: ${named} is not in public/ — every page links an icon that is not there`)
1364
1371
  return
1365
1372
  }
1366
1373
  const { readImageSize, isImageOpaque } = await import('@uniweb/build/images')
@@ -110,7 +110,6 @@ import {
110
110
  unsettledFiles,
111
111
  commitWriteBacks,
112
112
  reportWriteBacks,
113
- dropSiteBoundValues,
114
113
  pushSyncPackages,
115
114
  readSiteWorkspace
116
115
  } from '../backend/site-sync.js'
@@ -118,6 +117,7 @@ import { repoOf } from '../utils/sync-point.js'
118
117
  import { commitBeforeSending } from '../backend/commit-before-sending.js'
119
118
  import { resolveWorkspace, describeWorkspace, previewWorkspace, SOURCE_LABEL } from '../backend/workspace.js'
120
119
  import { uploadSiteMedia, describeAssetRefusal } from '../backend/site-media.js'
120
+ import { liveAddress } from '../backend/live-address.js'
121
121
  import {
122
122
  updateBackendMap,
123
123
  readBackendState,
@@ -671,12 +671,6 @@ export async function publish(args = []) {
671
671
  `Cleared stale sync state from a previous site (${droppedState.join(', ')}).`
672
672
  )
673
673
  }
674
- const droppedValues = dropSiteBoundValues(siteDir, client.origin)
675
- if (droppedValues.length) {
676
- say.dim(
677
- `Dropped the previous site's ${droppedValues.join(' and ')} from site.yml.`
678
- )
679
- }
680
674
  // 3b. Make sure the SITE EXISTS before a single byte is uploaded.
681
675
  //
682
676
  // Uploaded bytes are metered against an owning entity and reclaimed by
@@ -1064,6 +1058,10 @@ export async function publish(args = []) {
1064
1058
  // prefixed with our own origin; the reply is absolute now, so a path from an
1065
1059
  // older backend is simply shown as one.
1066
1060
  const serveUrl = typeof result.url === 'string' && result.url ? result.url : null
1061
+ // …and where its visitors go, which is what is shown: its custom domain while one is
1062
+ // active (`liveAddress`). The backend reads it on every publish and stores it nowhere, so
1063
+ // deploy.yml keeps the address this publish delivered to.
1064
+ const shownUrl = liveAddress(result.visitor_url, serveUrl)
1067
1065
 
1068
1066
  // 8. Persist deploy.yml memory — a record of what went live (and so a re-run
1069
1067
  // reuses the resolved target without re-asking). Identity is sync.json's,
@@ -1145,7 +1143,7 @@ export async function publish(args = []) {
1145
1143
  )
1146
1144
  say.dim(` site.yml lists ${unserved.join(', ')}, and ${unserved.length === 1 ? 'it was' : 'they were'} not published.`)
1147
1145
  }
1148
- if (serveUrl) console.log(` ${c.cyan}${serveUrl}${c.reset}`)
1146
+ if (shownUrl) console.log(` ${c.cyan}${shownUrl}${c.reset}`)
1149
1147
  // ⛔ No site.yml write for where it went live. That is a fact about this deploy,
1150
1148
  // not about the site, and it is recorded where deploy facts live: deploy.yml's
1151
1149
  // `lastDeploy.<target>.url` (step 8 above). site.yml carried it as `$url` ⇄
@@ -26,7 +26,8 @@
26
26
  * `uniweb login && uniweb push`. Run from a site, or a workspace with one site.
27
27
  *
28
28
  * Usage:
29
- * uniweb push Build, push both lanes, back-fill $uuid
29
+ * uniweb push Bring the foundation along, push both lanes,
30
+ * back-fill $uuid
30
31
  * uniweb push --org @org Work in @org for this push, instead of the
31
32
  * uniweb push --personal workspace chosen at `uniweb login` (or your
32
33
  * personal workspace). A site this push creates
@@ -97,7 +98,6 @@ import {
97
98
  refuseUnsendableRecords,
98
99
  ensureSiteExists,
99
100
  clearRemoteSyncStateIfUnbound,
100
- dropSiteBoundValues,
101
101
  untakenBase,
102
102
  unsettledFiles,
103
103
  commitWriteBacks,
@@ -367,10 +367,6 @@ export async function push(args = [], deps = {}) {
367
367
  `Cleared stale sync state from a previous site (${dropped.join(', ')}).`
368
368
  )
369
369
  }
370
- const stale = dropSiteBoundValues(siteDir, client.origin)
371
- if (stale.length) {
372
- note(`Dropped the previous site's ${stale.join(' and ')} from site.yml.`)
373
- }
374
370
  }
375
371
  const priorHashes = readSyncCache(siteDir, client.origin)
376
372
  // Per-item identity, without which the backend reads every record as new and
@@ -27,6 +27,7 @@ import { join, resolve } from 'node:path'
27
27
 
28
28
  import { BackendClient, WorkspaceMismatchError, describeWorkspace } from '../backend/client.js'
29
29
  import { resolveWorkspace } from '../backend/workspace.js'
30
+ import { liveAddress } from '../backend/live-address.js'
30
31
  import { checkFlags } from '../utils/flag-guard.js'
31
32
  import { confirm, isNonInteractive } from '../utils/interactive.js'
32
33
  import { readSiteIdentity } from '../utils/site-identity.js'
@@ -135,7 +136,7 @@ async function list(args) {
135
136
  name: s.name ?? null,
136
137
  status: s.deployment?.status ?? null,
137
138
  published: s.deployment?.status === 'published',
138
- url: s.deployment?.published_url ?? null,
139
+ url: liveAddress(s.deployment?.visitor_url, s.deployment?.published_url),
139
140
  updated_at: s.updated_at ?? null
140
141
  }))
141
142
  if (json) {
@@ -270,7 +271,7 @@ async function unpublish(args) {
270
271
  if (!target) return { exitCode: 2 }
271
272
  const site = await findSite(client, target.uuid)
272
273
  const label = site?.name ? `“${site.name}” (${target.uuid})` : target.uuid
273
- const url = site?.deployment?.published_url
274
+ const url = liveAddress(site?.deployment?.visitor_url, site?.deployment?.published_url)
274
275
  const ok = await confirmed(
275
276
  args,
276
277
  `Unpublish ${label} on ${client.origin}?${url ? ` Visitors get nothing at ${url} until it is published again.` : ''}`,
@@ -23,8 +23,9 @@
23
23
  * A site keeps its chosen look in `site/snapshot.yml` (utils/snapshot-settings.js):
24
24
  * the command's defaults for that site, which flags override and `--save` writes.
25
25
  *
26
- * `preview:` is written only when site.yml has none, or holds the app's generated
27
- * token. An address the author wrote is never replaced.
26
+ * `preview:` is written only when site.yml has none, or holds something that is no
27
+ * author's address — the token an older pull wrote there for a card image the app
28
+ * generated. An address the author wrote is never replaced.
28
29
  */
29
30
 
30
31
  import { spawn } from 'node:child_process'
@@ -33,13 +34,12 @@ import { createRequire } from 'node:module'
33
34
  import { isAbsolute, join, relative, resolve, sep } from 'node:path'
34
35
  import { pathToFileURL } from 'node:url'
35
36
  import yaml from 'js-yaml'
36
- import { writeSiteConfig } from '@uniweb/build/uwx'
37
+ import { isAuthoredPreview, writeSiteConfig } from '@uniweb/build/uwx'
37
38
 
38
39
  import { didYouMean } from '../utils/args.js'
39
40
  import { humanBytes } from '../utils/bytes.js'
40
41
  import { discoverSites } from '../utils/discover.js'
41
42
  import { detectWorkspacePm } from '../utils/pm.js'
42
- import { isAuthoredPreview } from '../utils/preview.js'
43
43
  import {
44
44
  SETTINGS_FILE,
45
45
  SettingsError,
@@ -447,7 +447,7 @@ export async function snapshot(args = []) {
447
447
  if (setPreview()) console.log(` site.yml: ${CYAN}preview: ${value}${RESET}`)
448
448
  break
449
449
  case 'replace':
450
- if (setPreview()) console.log(` site.yml: ${CYAN}preview: ${value}${RESET} ${DIM}(replaces the app-generated preview)${RESET}`)
450
+ if (setPreview()) console.log(` site.yml: ${CYAN}preview: ${value}${RESET} ${DIM}(replaces ${current}, which is not an image address)${RESET}`)
451
451
  break
452
452
  case 'unchanged':
453
453
  console.log(` ${DIM}site.yml already has preview: ${value}${RESET}`)
@@ -60,6 +60,7 @@ import {
60
60
  import { describeWorkspace, resolveWorkspace, SOURCE_LABEL } from '../backend/workspace.js'
61
61
  import { BackendClient, resolveBackendOrigin } from '../backend/client.js'
62
62
  import { readAppChanges } from '../backend/app-changes.js'
63
+ import { liveAddress } from '../backend/live-address.js'
63
64
  import { probePull } from './pull.js'
64
65
  import { readOrgFlag } from '../utils/args.js'
65
66
  import { listSyncedBackends, readBackendState } from '@uniweb/build/uwx'
@@ -649,9 +650,10 @@ async function appChecks(siteDir, siteYml, { offline, args, git }) {
649
650
  if (site) {
650
651
  // ⭐ WHERE THE SITE IS LIVE IS THE BACKEND'S TO SAY, never deploy.yml's: a receipt is
651
652
  // what this copy published, and a publish from the app or another copy does not move it.
652
- // ⛔ `last_published_url` outlives an unpublish (`client.siteStatus`), so it is said as
653
- // where the site WAS live when it is not live now — never as where it is.
654
- const url = typeof site.last_published_url === 'string' && site.last_published_url ? site.last_published_url : null
653
+ // It is where visitors go: the custom domain while one is active (`liveAddress`).
654
+ // ⛔ Both addresses outlive an unpublish (`client.siteStatus`), so the one shown is said
655
+ // as where the site WAS live when it is not live now — never as where it is.
656
+ const url = liveAddress(site.visitor_url, site.last_published_url)
655
657
  const when = site.last_published_at ? sinceLabel(site.last_published_at, Date.now()) : null
656
658
  const at = `${url ? ` at ${url}` : ''}${when ? `, published ${when}` : ''}`
657
659
  const detail = {
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-10-28T14:10:12.997Z",
3
+ "generatedAt": "2026-10-29T01:09:12.877Z",
4
4
  "packages": {
5
5
  "@uniweb/api": {
6
- "version": "1.6.4"
6
+ "version": "1.6.5"
7
7
  },
8
8
  "@uniweb/build": {
9
- "version": "1.10.14"
9
+ "version": "1.11.0"
10
10
  },
11
11
  "@uniweb/content-reader": {
12
12
  "version": "1.5.7"
@@ -36,7 +36,7 @@
36
36
  "version": "1.2.7"
37
37
  },
38
38
  "@uniweb/runtime": {
39
- "version": "1.2.12"
39
+ "version": "1.2.13"
40
40
  },
41
41
  "@uniweb/scene": {
42
42
  "version": "1.0.1"
@@ -60,7 +60,7 @@
60
60
  "version": "1.2.1"
61
61
  },
62
62
  "@uniweb/unipress": {
63
- "version": "1.1.16"
63
+ "version": "1.1.17"
64
64
  }
65
65
  },
66
66
  "templates": {
package/src/index.js CHANGED
@@ -1607,8 +1607,8 @@ ${colors.bright}Usage:${colors.reset}
1607
1607
 
1608
1608
  Opens the site in a headless Chrome, captures it, and composes the captures into
1609
1609
  one image: by default ${colors.bright}site/public/preview.webp${colors.reset}, recorded as ${colors.cyan}preview:${colors.reset} in site.yml
1610
- when site.yml has none, or only the app's generated one. A URL or image path you
1611
- wrote is never replaced.
1610
+ when site.yml has none, or what it has is not an image address. A URL or image path
1611
+ you wrote is never replaced.
1612
1612
 
1613
1613
  A page that scrolls gets the ${colors.bright}split${colors.reset} layout: the first view in a browser window,
1614
1614
  beside a long strip of the page. A page that does not scroll as a page (a docs
@@ -1,20 +0,0 @@
1
- /**
2
- * `site.yml::preview` — the site's card image — has two writers: an author, who
3
- * writes an address (a URL, or a path to an image in the project), and the app,
4
- * which writes a token naming an image it generated. This is the one test that
5
- * tells them apart.
6
- *
7
- * It recognizes the author's shapes rather than the app's, so it does not depend
8
- * on the token's format: a URL, a path starting `/`, `./` or `../`, or a relative
9
- * path to an image file (`images/card.png`).
10
- */
11
-
12
- const IMAGE_FILE = /\.(avif|gif|jpe?g|png|svg|webp)$/i
13
-
14
- /**
15
- * @param {unknown} value
16
- * @returns {boolean} true when `value` is an author's address rather than the app's token
17
- */
18
- export const isAuthoredPreview = (value) =>
19
- typeof value === 'string' &&
20
- (/^https?:\/\//i.test(value) || /^\.{0,2}\//.test(value) || IMAGE_FILE.test(value.trim()))