uniweb 0.14.22 → 0.14.24

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.14.22",
3
+ "version": "0.14.24",
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/runtime": "0.9.5",
45
44
  "@uniweb/core": "0.8.2",
46
- "@uniweb/kit": "0.10.19"
45
+ "@uniweb/kit": "0.10.20",
46
+ "@uniweb/runtime": "0.9.6"
47
47
  },
48
48
  "peerDependencies": {
49
49
  "@uniweb/content-reader": "1.2.2",
50
- "@uniweb/semantic-parser": "1.2.1",
51
- "@uniweb/build": "0.16.12"
50
+ "@uniweb/build": "0.16.14",
51
+ "@uniweb/semantic-parser": "1.2.1"
52
52
  },
53
53
  "peerDependenciesMeta": {
54
54
  "@uniweb/build": {
@@ -216,6 +216,8 @@ A versionless `@org/name` is an error rather than a shorthand — the build reje
216
216
 
217
217
  > **Foundations are never npm packages.** They're runtime federated modules, not libraries. Don't `npm install` one, and don't add one to `dependencies` by hand. The four supported shapes are exactly the ones above: a workspace sibling, a `file:` dependency, a versioned registry ref, or a full URL.
218
218
 
219
+ > **The same `@scope/name` shape means different things in different places.** As a `foundation:` value it is a **catalog** ref (never npm). As a data-schema ref in `meta.js` it resolves on disk from an **npm package** — `@std/*` from `@uniweb/schemas`, any other `@org/*` from that org's `@org/schemas`. And `@uniweb/kit` in `dependencies` is an ordinary npm package. Position decides the registry; the shape tells you nothing.
220
+
219
221
  **Check `paths:` in `site.yml` before going looking for content.** It mounts outside directories into the page tree (`paths: { pages/docs: ../../../docs }`), so a route's markdown may live in another repo or a submodule rather than under `<site>/pages/`.
220
222
 
221
223
  ### 2. Learn this project's vocabulary — before you write anything
@@ -229,6 +231,10 @@ cat <foundation>/sections/Hero/meta.js # what one expects and accepts
229
231
 
230
232
  **Extensions add to that vocabulary.** If `site.yml` carries an `extensions:` list, each entry is a second foundation contributing its own section types, usable by name in exactly the same way. Enumerate their `sections/` too. The primary foundation wins on a name collision, and extensions are checked in declared order.
231
233
 
234
+ **An extension *is* a foundation** — same build, same output; it just contributes no Layout or theme variables. So each entry takes the **same shapes** `foundation:` does: a workspace package name, a versioned catalog ref (`@org/name@1.2.3`), or a full URL. Use the same table above to decide whether you can add a section type to one.
235
+
236
+ > **A site-relative URL (`/effects/entry.js`) only works where the site serves its own files** — `uniweb export` and `uniweb deploy --host=<adapter>`. A site published to Uniweb hosting ships no JS, so nothing serves that path, and `uniweb publish` rejects it with a pointer to the catalog-ref form. Register the extension (`uniweb register` in its directory) and reference it like any other foundation. When you reference a workspace-local extension, `uniweb publish` brings it along exactly as it does the primary — releasing it if its code changed, and pinning the released version on the published site.
237
+
232
238
  Each `meta.js` is a catalog entry: `description` (what the type is for), `content:` (what markdown it expects), `params:` (what frontmatter it accepts, with defaults), `presets:` (named param bundles). Read them as a menu — that is what they are. There is no CLI command that lists them; reading the folder *is* the discovery step.
233
239
 
234
240
  > **Never write a `type:` or a param you haven't confirmed exists.** Both failures are silent (Part 0) — invisible from the terminal, visible only on the page.
@@ -613,6 +619,9 @@ slug: { fr: a-propos } # Localized URL segment per language
613
619
  # site.yml
614
620
  index: home # Just set the homepage
615
621
  pages: [home, about, ...] # Order pages (... = rest, first = homepage); without ... = strict
622
+ foundation: '@acme/ui@1.2.0' # The component system (see Part 2, step 1)
623
+ extensions: ['@acme/fx@0.3.1'] # Secondary foundations — same shapes as foundation:
624
+ runtime: 0.9.6 # Optional runtime pin; omit and the host chooses
616
625
  ```
617
626
 
618
627
  **Configuration cascades: `page.yml` → `folder.yml` → `site.yml` → foundation defaults.** Each level inherits from the one above and overrides specific values, the way CSS specificity works. This is what makes bulk assignment natural — put `layout: marketing` in a `folder.yml` and every page in that folder inherits it, while one page can still override with its own `page.yml`. Reach for `folder.yml` before editing the same key into a dozen `page.yml` files.
@@ -1946,6 +1955,8 @@ Platform-specific configuration that doesn't belong in npm-standard fields. All
1946
1955
 
1947
1956
  **Runtime policy** (foundation authors only — sites don't set this). At build time a foundation pins the `@uniweb/runtime` version it built against into `dist/runtime-pin.json`, alongside a policy controlling how that version moves forward on already-published sites: `exact` (stay put), `auto-patch` (within `MAJOR.MINOR.x`), `auto-minor` (within `MAJOR.x.y`, the default). Most foundations should leave it unset — the runtime is backwards-compatible at the minor level by convention, so `auto-minor` lets sites pick up fixes without a foundation rebuild. Set `exact` only if you depend on undocumented runtime internals or have audited against one release. Site owners cannot override your choice.
1948
1957
 
1958
+ **Why this is yours and not the site's.** A published site ships **no JS** — content, config, data and assets only. Your foundation is the only part that contains code linking against the runtime, and that link is wider than it looks: a foundation externalizes `react`, `react-dom`, `react-dom/server`, both JSX runtimes and `@uniweb/core`, all supplied by the runtime at load time. So a foundation built against a runtime is bound to *that* React and *that* core API. The general rule, which also explains why the reverse holds: **each policy is declared by whoever's code binds to the thing being updated** — you declare the runtime policy because your code links against the runtime; the site declares its foundation policy because its content binds to your section types.
1959
+
1949
1960
  `@uniweb/runtime` arrives **transitively** through `@uniweb/build`, so your foundation pins a runtime version without declaring one — that's intentional. **Don't add `@uniweb/runtime` to your foundation's dependencies**; to bump the pinned version, bump `@uniweb/build`. If the pin is missing or malformed, the platform serves the foundation through its legacy compatibility path — sites still work, they just don't participate in runtime propagation.
1950
1961
 
1951
1962
  ### Localization
@@ -461,9 +461,14 @@ export class BackendClient {
461
461
  // Runtime rides as a query param (?runtime=<version>) per the shipped /dev
462
462
  // route (D3, "request-carried"), NOT the body. Languages, when present, go in
463
463
  // the body; absent → no body (the route only requires the runtime).
464
+ // Omitted when the site pins no runtime — silence is NOT a request to change
465
+ // it, so the backend keeps the site on its current resolved version (its
466
+ // order: body → current → UNIWEBD_DEFAULT_RUNTIME → highest installed → 400).
467
+ // Sending a locally-computed guess instead would undo a propagation walk on
468
+ // the next publish.
464
469
  return this.request(`/dev/site/publish/${encodeURIComponent(uuid)}`, {
465
470
  method: 'POST',
466
- query: { runtime: runtimeVersion },
471
+ ...(runtimeVersion ? { query: { runtime: runtimeVersion } } : {}),
467
472
  ...(languages ? { body: JSON.stringify({ languages }) } : {})
468
473
  })
469
474
  }
@@ -26,7 +26,7 @@ import { readFileSync } from 'node:fs'
26
26
  import { join } from 'node:path'
27
27
  import { execFileSync } from 'node:child_process'
28
28
 
29
- import { detectFoundationType } from '@uniweb/build'
29
+ import { detectFoundationType, isExtensionUrl } from '@uniweb/build'
30
30
  import { computeFoundationDigest } from '../utils/code-upload.js'
31
31
  import { readFlagValue } from '../utils/args.js'
32
32
  import { isNonInteractive } from '../utils/interactive.js'
@@ -60,6 +60,52 @@ export function resolveLocalFoundation(siteDir, siteYml) {
60
60
  }
61
61
  }
62
62
 
63
+ /**
64
+ * The site's LOCAL extensions — the ones publish must bring along. An extension IS
65
+ * a foundation (same build, same output), so it is declared and resolved the same
66
+ * way and goes through the SAME resolver, for the same reason `resolveLocalFoundation`
67
+ * does: "which code" must never drift between the build and the publish.
68
+ *
69
+ * A declaration that resolves to a URL or a catalog ref yields nothing local — the
70
+ * host already serves that code. Only workspace-local extensions need releasing.
71
+ *
72
+ * @param {string} siteDir
73
+ * @param {object} siteYml - parsed site.yml
74
+ * @returns {Array<{ decl: string, dir: string, scopedName: string|null, version: string|null }>}
75
+ * `decl` is the authored declaration, which is the wire entry's `$id` — the key
76
+ * publish stamps the pinned ref back onto.
77
+ */
78
+ export function resolveLocalExtensions(siteDir, siteYml) {
79
+ const list = siteYml?.extensions
80
+ if (!Array.isArray(list)) return []
81
+ const out = []
82
+ for (const entry of list) {
83
+ // Only the name/ref form can be local; an explicit `url` never is.
84
+ const decl =
85
+ entry && typeof entry === 'object'
86
+ ? entry.ref || entry.name || null
87
+ : typeof entry === 'string'
88
+ ? entry
89
+ : null
90
+ if (!decl || isExtensionUrl(decl)) continue
91
+ let info
92
+ try {
93
+ info = detectFoundationType(decl, siteDir)
94
+ } catch {
95
+ // Unresolved — the site build surfaces the canonical error; nothing local.
96
+ continue
97
+ }
98
+ if (!info || info.type !== 'local' || !info.path) continue
99
+ out.push({
100
+ decl,
101
+ dir: info.path,
102
+ scopedName: foundationScopedName(info.path),
103
+ version: readPkgField(info.path, 'version')
104
+ })
105
+ }
106
+ return out
107
+ }
108
+
63
109
  // The foundation's scoped catalog name (`@org/name`) from its package.json — an
64
110
  // already-scoped `name`, else `uniweb.scope` + a bare `name`. Null when neither
65
111
  // yields a scoped name (then we can't look up the registered version, so the
@@ -140,11 +186,43 @@ export async function bringFoundationAlong({
140
186
  // along, and no ref override (forward the site.yml ref verbatim).
141
187
  return { released: false, proceed: true, ref: null }
142
188
  }
189
+ return bringLocalCodeAlong({
190
+ client,
191
+ local,
192
+ kind: 'foundation',
193
+ args,
194
+ say,
195
+ confirm,
196
+ cliBin,
197
+ dryRun
198
+ })
199
+ }
143
200
 
201
+ /**
202
+ * Bring ONE piece of local code along — the primary foundation or one extension.
203
+ * Identical logic for both because an extension is a foundation; `kind` only names
204
+ * it in the messages.
205
+ *
206
+ * @param {object} o
207
+ * @param {{dir: string, scopedName: string|null, version: string|null}} o.local
208
+ * @param {'foundation'|'extension'} o.kind
209
+ * @returns {Promise<{ released: boolean, proceed: boolean, ref: string|null }>}
210
+ */
211
+ async function bringLocalCodeAlong({
212
+ client,
213
+ local,
214
+ kind,
215
+ args,
216
+ say,
217
+ confirm,
218
+ cliBin,
219
+ dryRun = false
220
+ }) {
221
+ const Kind = kind === 'extension' ? 'Extension ' : 'Foundation'
144
222
  const label =
145
223
  local.scopedName || local.version
146
- ? `${local.scopedName || 'foundation'}${local.version ? `@${local.version}` : ''}`
147
- : 'the local foundation'
224
+ ? `${local.scopedName || kind}${local.version ? `@${local.version}` : ''}`
225
+ : `the local ${kind}`
148
226
  const skipPrompts =
149
227
  args.includes('--yes') ||
150
228
  args.includes('--force') ||
@@ -163,7 +241,7 @@ export async function bringFoundationAlong({
163
241
  // a login (the digest read is auth-gated). The real run does the compare.
164
242
  if (dryRun) {
165
243
  say.dim(
166
- `Foundation : ${label} — local; would release if changed or not yet registered`
244
+ `${Kind} : ${label} — local; would release if changed or not yet registered`
167
245
  )
168
246
  return { released: false, proceed: true, ref: null }
169
247
  }
@@ -175,7 +253,7 @@ export async function bringFoundationAlong({
175
253
  : null
176
254
 
177
255
  if (!reg) {
178
- say.info(`Releasing the foundation ${label} (not yet registered)…`)
256
+ say.info(`Releasing the ${kind} ${label} (not yet registered)…`)
179
257
  return {
180
258
  released: releaseFoundation(local, args, cliBin, say),
181
259
  proceed: true,
@@ -190,7 +268,7 @@ export async function bringFoundationAlong({
190
268
 
191
269
  if (reg.digest && localDigest && reg.digest === localDigest) {
192
270
  say.dim(
193
- `Foundation : ${label} — unchanged since release (digest matches); nothing to release.`
271
+ `${Kind} : ${label} — unchanged since release (digest matches); nothing to release.`
194
272
  )
195
273
  return { released: false, proceed: true, ref: pinnedRef() }
196
274
  }
@@ -198,7 +276,7 @@ export async function bringFoundationAlong({
198
276
  // A different version locally → a new version to release.
199
277
  if (local.version && local.version !== reg.latest_version) {
200
278
  say.info(
201
- `Releasing the foundation ${label} (new version; registered latest is ${reg.latest_version})…`
279
+ `Releasing the ${kind} ${label} (new version; registered latest is ${reg.latest_version})…`
202
280
  )
203
281
  return {
204
282
  released: releaseFoundation(local, args, cliBin, say),
@@ -233,14 +311,14 @@ export async function bringFoundationAlong({
233
311
  return { released: false, proceed: true, ref: pinnedRef() }
234
312
  }
235
313
 
236
- // Case 3 (§4): the foundation was edited but the version wasn't bumped. The
314
+ // Case 3 (§4): the code was edited but the version wasn't bumped. The
237
315
  // registered version is immutable, so we never silently ship the old code —
238
316
  // the deliberate release gate is a version bump (§3.1).
239
317
  say.warn(
240
318
  `Your local ${label} differs from the registered version ${reg.latest_version}, but the version wasn't bumped.`
241
319
  )
242
320
  say.dim(
243
- "A registered version is immutable. Bump the foundation's version to release the change, then re-run `uniweb publish`."
321
+ `A registered version is immutable. Bump the ${kind}'s version to release the change, then re-run \`uniweb publish\`.`
244
322
  )
245
323
  if (skipPrompts || isNonInteractive(args)) {
246
324
  say.dim(`Proceeding with the already-registered ${reg.latest_version}.`)
@@ -252,7 +330,7 @@ export async function bringFoundationAlong({
252
330
  )
253
331
  if (!proceed) {
254
332
  say.info(
255
- 'Aborted — bump the foundation version, then re-run `uniweb publish`.'
333
+ `Aborted — bump the ${kind} version, then re-run \`uniweb publish\`.`
256
334
  )
257
335
  return { released: false, proceed: false, ref: null }
258
336
  }
@@ -282,3 +360,52 @@ function releaseFoundation(local, args, cliBin, say) {
282
360
  console.log('')
283
361
  return true
284
362
  }
363
+
364
+ /**
365
+ * Bring the site's LOCAL extensions along — the exact parallel of
366
+ * `bringFoundationAlong`, run for each workspace-local extension.
367
+ *
368
+ * An extension is a foundation, so it gets a foundation's freshness guarantee:
369
+ * released when unregistered or newly versioned, skipped on a digest match, and
370
+ * never silently shipped stale. Before this, a site could go live against a stale
371
+ * extension with nothing noticing — the primary was covered and the rest were not.
372
+ *
373
+ * @param {object} o - same shape as bringFoundationAlong
374
+ * @returns {Promise<{ proceed: boolean, released: number, pins: Object<string,string> }>}
375
+ * `pins` maps each authored declaration (the wire entry's `$id`) → the pinned
376
+ * `@scope/name@version`, for `emitSyncPackages({ injectExtensions })`. Delivery is
377
+ * version-pinned, so an unpinned local name on the wire points at code the host
378
+ * cannot serve — the same reason the primary's ref is stamped.
379
+ */
380
+ export async function bringExtensionsAlong({
381
+ client,
382
+ siteDir,
383
+ siteYml,
384
+ args,
385
+ say,
386
+ confirm,
387
+ cliBin,
388
+ dryRun = false
389
+ }) {
390
+ const locals = resolveLocalExtensions(siteDir, siteYml)
391
+ const pins = {}
392
+ let released = 0
393
+ for (const local of locals) {
394
+ const r = await bringLocalCodeAlong({
395
+ client,
396
+ local,
397
+ kind: 'extension',
398
+ args,
399
+ say,
400
+ confirm,
401
+ cliBin,
402
+ dryRun
403
+ })
404
+ // A declined prompt aborts the whole publish, exactly as it does for the
405
+ // primary — a site live against half its code is worse than not shipping.
406
+ if (!r.proceed) return { proceed: false, released, pins: {} }
407
+ if (r.released) released += 1
408
+ if (r.ref) pins[local.decl] = r.ref
409
+ }
410
+ return { proceed: true, released, pins }
411
+ }
@@ -49,6 +49,7 @@ import {
49
49
  rewriteBallAssets
50
50
  } from '@uniweb/build/site'
51
51
  import { emitSyncPackages } from '@uniweb/build/uwx'
52
+ import { isSiteRelativeExtensionUrl } from '@uniweb/build'
52
53
  import { resolveDefaultLocale } from '@uniweb/core/locale-config'
53
54
 
54
55
  import { BackendClient } from '../backend/client.js'
@@ -68,7 +69,10 @@ import {
68
69
  } from '../backend/site-sync.js'
69
70
  import { uploadDataBundle } from '../backend/data-bundle.js'
70
71
  import { uploadSiteMedia, describeAssetRefusal } from '../backend/site-media.js'
71
- import { bringFoundationAlong } from '../backend/foundation-bring-along.js'
72
+ import {
73
+ bringFoundationAlong,
74
+ bringExtensionsAlong
75
+ } from '../backend/foundation-bring-along.js'
72
76
  import { settlePaymentIfNeeded } from '../backend/payment-handoff.js'
73
77
 
74
78
  const c = {
@@ -104,15 +108,6 @@ async function confirm(question, defaultYes = false) {
104
108
  }
105
109
  }
106
110
 
107
- // Highest installed runtime from the backend's /dev/config list (numeric-aware
108
- // sort). Null when the list is empty.
109
- function pickHighestRuntime(installed) {
110
- if (!Array.isArray(installed) || installed.length === 0) return null
111
- return [...installed].sort((a, b) =>
112
- String(b).localeCompare(String(a), undefined, { numeric: true })
113
- )[0]
114
- }
115
-
116
111
  // Origin-relative serve path → clickable absolute URL (self-serve default).
117
112
  function absolutizeServeUrl(origin, url) {
118
113
  if (!url || typeof url !== 'string') return null
@@ -218,14 +213,24 @@ export async function publish(args = []) {
218
213
  )
219
214
  if (!dryRun) return { exitCode: 1 }
220
215
  }
221
- const runtimeVersion = siteYml.runtime || pickHighestRuntime(installed)
222
- if (!runtimeVersion && !dryRun) {
223
- say.err('Could not resolve a runtime version.')
224
- say.dim(
225
- 'Pin one with `runtime:` in site.yml, or install one on the backend so /dev/config reports it.'
226
- )
227
- return { exitCode: 1 }
228
- }
216
+ // An explicit pin is sent; NOTHING is synthesized when site.yml is silent.
217
+ //
218
+ // This used to fall back to the highest version the backend reported
219
+ // installed. That is the producer guessing at a fact the control plane owns —
220
+ // and once propagation moves sites, actively wrong: a walk advances a site to
221
+ // X, and the next publish would restate a *different* version the producer
222
+ // computed locally, silently undoing it.
223
+ //
224
+ // Silence is not a request to change the runtime, so the backend resolves it:
225
+ // the site's CURRENT resolved runtime → UNIWEBD_DEFAULT_RUNTIME → (self-serve)
226
+ // highest installed → 400. That keeps a propagated site where the walk put it,
227
+ // and it is the authority's answer rather than ours.
228
+ //
229
+ // ⚠️ Do NOT reintroduce a local fallback. Sending our own guess when the site
230
+ // did not ask is what makes an unpinned republish regress. (The pinned path is
231
+ // unaffected — an explicit pin is still validated fail-closed above, and the
232
+ // backend refuses a backward move unless forced.)
233
+ const runtimeVersion = siteYml.runtime || null
229
234
 
230
235
  // deploy.yml target (the Uniweb hosting memory). No --target on publish — it
231
236
  // always targets Uniweb hosting; resolveTarget gives us the target name +
@@ -249,11 +254,46 @@ export async function publish(args = []) {
249
254
  }
250
255
  const autoSave = noSave ? 'off' : resolved.autoSave || 'lastDeploy'
251
256
 
257
+ // A SITE-RELATIVE extension URL cannot work on Uniweb hosting: the published
258
+ // site ships no JS, so nothing serves that path. The request falls through to
259
+ // the SPA shell and returns 200 with `text/html`, which `import()` then fails
260
+ // to parse — and `loadExtensions` uses Promise.allSettled, so nothing throws
261
+ // and every section the extension provides silently renders "Component not
262
+ // found". A 200-with-HTML is strictly worse to debug than a 404 (the same
263
+ // shape that forced the `/data/` carve-out at the edge), so fail here, at the
264
+ // author's screen, rather than at a visitor's.
265
+ //
266
+ // `export` / `deploy --host` are unaffected — there the site serves its own
267
+ // files and a relative URL is exactly right.
268
+ const relativeExtensions = (
269
+ Array.isArray(siteYml.extensions) ? siteYml.extensions : []
270
+ )
271
+ .map((e) => (e && typeof e === 'object' ? e.url || e.ref || e.name : e))
272
+ .filter((d) => isSiteRelativeExtensionUrl(d))
273
+ if (relativeExtensions.length) {
274
+ say.err(
275
+ `Site-relative extension URL${relativeExtensions.length > 1 ? 's' : ''} cannot be served by Uniweb hosting: ${relativeExtensions.join(', ')}`
276
+ )
277
+ say.dim(
278
+ 'A published site ships no JS, so nothing serves that path. An extension is a foundation —'
279
+ )
280
+ say.dim(
281
+ 'register it (`uniweb register` in the extension directory) and reference it by name or'
282
+ )
283
+ say.dim(
284
+ '`@org/name@version` in site.yml::extensions, the same way the primary foundation is declared.'
285
+ )
286
+ say.dim(
287
+ 'Site-relative URLs keep working with `uniweb export` and `uniweb deploy --host=<adapter>`.'
288
+ )
289
+ return { exitCode: 1 }
290
+ }
291
+
252
292
  if (dryRun) {
253
293
  say.info('Dry run — would bring the foundation along, sync, and go live:')
254
294
  say.dim(`Backend : ${client.origin}`)
255
295
  say.dim(
256
- `Runtime : ${runtimeVersion || '(unresolvedneeds a backend or a site.yml runtime: pin)'}${runtimeVersion && !siteYml.runtime ? ' (highest installed)' : ''}`
296
+ `Runtime : ${runtimeVersion || '(not pinned the backend keeps this site on its current runtime)'}`
257
297
  )
258
298
  say.dim(
259
299
  `site_uuid : ${siteYml.$uuid || '(none — the site is created before anything uploads)'}`
@@ -270,6 +310,16 @@ export async function publish(args = []) {
270
310
  cliBin: process.argv[1],
271
311
  dryRun: true
272
312
  })
313
+ await bringExtensionsAlong({
314
+ client,
315
+ siteDir,
316
+ siteYml,
317
+ args,
318
+ say,
319
+ confirm,
320
+ cliBin: process.argv[1],
321
+ dryRun: true
322
+ })
273
323
  await settlePaymentIfNeeded({
274
324
  client,
275
325
  uuid: siteYml.$uuid || null,
@@ -298,6 +348,27 @@ export async function publish(args = []) {
298
348
  say.dim('Fix the foundation, then re-run `uniweb publish`.')
299
349
  return { exitCode: 1 }
300
350
  }
351
+
352
+ // 1b. Same for the site's LOCAL extensions. An extension is a foundation, so
353
+ // it gets the same freshness guarantee — otherwise a site could go live
354
+ // against stale extension code with nothing noticing.
355
+ let ext
356
+ try {
357
+ ext = await bringExtensionsAlong({
358
+ client,
359
+ siteDir,
360
+ siteYml,
361
+ args,
362
+ say,
363
+ confirm,
364
+ cliBin: process.argv[1]
365
+ })
366
+ } catch (err) {
367
+ say.err(`Extension release failed: ${err.message}`)
368
+ say.dim('Fix the extension, then re-run `uniweb publish`.')
369
+ return { exitCode: 1 }
370
+ }
371
+ if (!ext.proceed) return { exitCode: 1 }
301
372
  if (!fnd.proceed) return { exitCode: 0 }
302
373
 
303
374
  // 2. Build the site data (link mode): dist/site-content.json (+ per-locale),
@@ -489,6 +560,9 @@ export async function publish(args = []) {
489
560
  ? { baseVersions, itemBaseVersions: readItemBaseVersions(siteDir) }
490
561
  : {}),
491
562
  ...(Object.keys(injectInfo).length ? { injectInfo } : {}),
563
+ ...(Object.keys(ext.pins).length
564
+ ? { injectExtensions: ext.pins }
565
+ : {}),
492
566
  ...(assetRewrite ? { assetRewrite } : {})
493
567
  })
494
568
  } catch (err) {
@@ -598,7 +672,11 @@ export async function publish(args = []) {
598
672
  ...(recordedRef ? { ref: recordedRef } : {}),
599
673
  released: fnd.released
600
674
  },
601
- runtime: runtimeVersion,
675
+ // Only when the site pinned one. An unpinned site's runtime is resolved by
676
+ // the backend and can move under propagation, so recording a value here
677
+ // would be a snapshot that silently goes stale — and `deploy.yml` is a
678
+ // record of what this publish did, not a cache of backend state.
679
+ ...(runtimeVersion ? { runtime: runtimeVersion } : {}),
602
680
  locales: Array.isArray(result.locales) ? result.locales : languages
603
681
  }
604
682
  })
@@ -1,9 +1,22 @@
1
1
  /**
2
2
  * uniweb runtime register — upload a built `@uniweb/runtime` to the backend so it
3
3
  * can serve the runtime version. The runtime is a SYSTEM artifact: registering it
4
- * requires **@std membership** (a non-@std bearer 403s). Foundations pin a runtime
5
- * version (`dist/runtime-pin.json`); that version must be registered, or `uniweb
6
- * register` of such a foundation fails.
4
+ * requires **@std membership** (a non-@std bearer 403s).
5
+ *
6
+ * A foundation emits `dist/runtime-pin.json`, but NOTHING READS IT — not this CLI,
7
+ * not the backend, not the edge (verified across all three lanes 2026-08-04,
8
+ * channel `platform-backend-framework-8de5`). An earlier version of this comment
9
+ * claimed `uniweb register` of a foundation fails when its pinned version isn't
10
+ * registered. That was never true, and it propagated into a kb doc before anyone
11
+ * checked it against `commands/register.js` three files away.
12
+ *
13
+ * The pin is a **compatibility floor**, not a selector: a site loads a primary
14
+ * foundation plus N extensions, each emitting its own pin, and a site has exactly
15
+ * one runtime — so pins are plural and the selector must be singular. The selector
16
+ * is `site.yml::runtime` (see commands/publish.js), which the backend stamps into
17
+ * the site's meta at publish. The pin's designed use is publish-time VALIDATION
18
+ * (is the selected runtime inside every foundation's compatible interval?), which
19
+ * is producer-side and not yet implemented.
7
20
  *
8
21
  * Contract AGREED with the backend (2026-06-14): `POST /dev/runtime`, @std-gated,
9
22
  * manifest-last. Wire + the two-half artifact set (SPA + ssr-edge isolate, the
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-08-02T07:19:41.642Z",
3
+ "generatedAt": "2026-08-04T19:33:10.456Z",
4
4
  "packages": {
5
5
  "@uniweb/build": {
6
- "version": "0.16.12",
6
+ "version": "0.16.14",
7
7
  "path": "framework/build",
8
8
  "deps": [
9
9
  "@uniweb/content-reader",
@@ -45,7 +45,7 @@
45
45
  "deps": []
46
46
  },
47
47
  "@uniweb/kit": {
48
- "version": "0.10.19",
48
+ "version": "0.10.20",
49
49
  "path": "framework/kit",
50
50
  "deps": [
51
51
  "@uniweb/core",
@@ -54,12 +54,12 @@
54
54
  ]
55
55
  },
56
56
  "@uniweb/loom": {
57
- "version": "0.2.3",
57
+ "version": "0.2.4",
58
58
  "path": "framework/loom",
59
59
  "deps": []
60
60
  },
61
61
  "@uniweb/press": {
62
- "version": "0.4.14",
62
+ "version": "0.4.15",
63
63
  "path": "framework/press",
64
64
  "deps": []
65
65
  },
@@ -72,7 +72,7 @@
72
72
  ]
73
73
  },
74
74
  "@uniweb/runtime": {
75
- "version": "0.9.5",
75
+ "version": "0.9.6",
76
76
  "path": "framework/runtime",
77
77
  "deps": [
78
78
  "@uniweb/core",
@@ -80,7 +80,7 @@
80
80
  ]
81
81
  },
82
82
  "@uniweb/scene": {
83
- "version": "0.1.2",
83
+ "version": "0.1.3",
84
84
  "path": "framework/scene",
85
85
  "deps": []
86
86
  },
@@ -90,7 +90,7 @@
90
90
  "deps": []
91
91
  },
92
92
  "@uniweb/scholar": {
93
- "version": "0.2.2",
93
+ "version": "0.2.3",
94
94
  "path": "framework/scholar",
95
95
  "deps": []
96
96
  },
@@ -110,7 +110,7 @@
110
110
  "deps": []
111
111
  },
112
112
  "@uniweb/unipress": {
113
- "version": "0.6.11",
113
+ "version": "0.6.12",
114
114
  "path": "framework/unipress",
115
115
  "deps": [
116
116
  "@uniweb/build",