uniweb 0.14.23 → 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 +4 -4
- package/partials/agents.md +9 -0
- package/src/backend/client.js +6 -1
- package/src/backend/foundation-bring-along.js +137 -10
- package/src/commands/publish.js +98 -20
- package/src/commands/runtime.js +16 -3
- package/src/framework-index.json +4 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uniweb",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.24",
|
|
4
4
|
"description": "Create structured Vite + React sites with content/code separation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -46,9 +46,9 @@
|
|
|
46
46
|
"@uniweb/runtime": "0.9.6"
|
|
47
47
|
},
|
|
48
48
|
"peerDependencies": {
|
|
49
|
-
"@uniweb/
|
|
50
|
-
"@uniweb/
|
|
51
|
-
"@uniweb/
|
|
49
|
+
"@uniweb/content-reader": "1.2.2",
|
|
50
|
+
"@uniweb/build": "0.16.14",
|
|
51
|
+
"@uniweb/semantic-parser": "1.2.1"
|
|
52
52
|
},
|
|
53
53
|
"peerDependenciesMeta": {
|
|
54
54
|
"@uniweb/build": {
|
package/partials/agents.md
CHANGED
|
@@ -231,6 +231,10 @@ cat <foundation>/sections/Hero/meta.js # what one expects and accepts
|
|
|
231
231
|
|
|
232
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.
|
|
233
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
|
+
|
|
234
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.
|
|
235
239
|
|
|
236
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.
|
|
@@ -615,6 +619,9 @@ slug: { fr: a-propos } # Localized URL segment per language
|
|
|
615
619
|
# site.yml
|
|
616
620
|
index: home # Just set the homepage
|
|
617
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
|
|
618
625
|
```
|
|
619
626
|
|
|
620
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.
|
|
@@ -1948,6 +1955,8 @@ Platform-specific configuration that doesn't belong in npm-standard fields. All
|
|
|
1948
1955
|
|
|
1949
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.
|
|
1950
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
|
+
|
|
1951
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.
|
|
1952
1961
|
|
|
1953
1962
|
### Localization
|
package/src/backend/client.js
CHANGED
|
@@ -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 ||
|
|
147
|
-
:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/commands/publish.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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 || '(
|
|
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
|
|
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
|
})
|
package/src/commands/runtime.js
CHANGED
|
@@ -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).
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
package/src/framework-index.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-08-
|
|
3
|
+
"generatedAt": "2026-08-04T19:33:10.456Z",
|
|
4
4
|
"packages": {
|
|
5
5
|
"@uniweb/build": {
|
|
6
|
-
"version": "0.16.
|
|
6
|
+
"version": "0.16.14",
|
|
7
7
|
"path": "framework/build",
|
|
8
8
|
"deps": [
|
|
9
9
|
"@uniweb/content-reader",
|
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
"deps": []
|
|
60
60
|
},
|
|
61
61
|
"@uniweb/press": {
|
|
62
|
-
"version": "0.4.
|
|
62
|
+
"version": "0.4.15",
|
|
63
63
|
"path": "framework/press",
|
|
64
64
|
"deps": []
|
|
65
65
|
},
|
|
@@ -110,7 +110,7 @@
|
|
|
110
110
|
"deps": []
|
|
111
111
|
},
|
|
112
112
|
"@uniweb/unipress": {
|
|
113
|
-
"version": "0.6.
|
|
113
|
+
"version": "0.6.12",
|
|
114
114
|
"path": "framework/unipress",
|
|
115
115
|
"deps": [
|
|
116
116
|
"@uniweb/build",
|