uniweb 0.41.1 → 0.42.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": "0.41.1",
3
+ "version": "0.42.0",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,13 +41,13 @@
41
41
  "js-yaml": "^4.1.0",
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.0.0",
44
- "@uniweb/core": "^0.19.0",
45
- "@uniweb/kit": "^0.15.6",
44
+ "@uniweb/core": "^0.20.0",
46
45
  "@uniweb/semantic-parser": "^1.4.0",
47
- "@uniweb/runtime": "^0.14.2"
46
+ "@uniweb/kit": "^0.15.7",
47
+ "@uniweb/runtime": "^0.15.0"
48
48
  },
49
49
  "peerDependencies": {
50
- "@uniweb/build": "^0.37.1",
50
+ "@uniweb/build": "^0.38.0",
51
51
  "@uniweb/content-reader": "^1.2.4",
52
52
  "@uniweb/semantic-parser": "^1.4.0"
53
53
  },
@@ -876,6 +876,8 @@ pages/blog/
876
876
 
877
877
  > **The record arrives as a single-element array under the query key** — `content.data.recent[0]`, not `content.data.article`. The runtime never coerces it to an object and never synthesizes a singular key. See *Data* in Part 4.
878
878
 
879
+ **Records with URLs of their own shape — `[...path]/`.** A folder named exactly `[...path]` (one fixed spelling) captures the rest of the URL: `/blog/my-post` and `/blog/rust/2025/my-post` both reach it. The capture yields three standard variables — `:path` (the whole capture), `:dir` (everything before the last segment), `:slug` (the last segment, the record's handle) — and the record is still delivered by `slug`, so the section reads `content.data.recent[0]` as before. A record's URL is its folder placement plus its slug (`- folder: rust/2025` in `records.yml` → `/blog/rust/2025/my-post`). A query may bind a part — `scope: :dir` exposes the folder branch, `where: { tag: :dir }` keeps it private — and an unbound variable drops its clause, so one saved query serves the list page and the detail page. Reference: `reference/dynamic-routes.md`.
880
+
879
881
  **Two options for bigger sets:**
880
882
 
881
883
  `deferred: [body]` strips heavy fields from the list payload — cards stay light, while a `[slug]` page still receives the full record automatically and other components fetch on demand via `useEntityDetail`. For a remote source, add `detailUrl: /api/articles/{slug}` so the framework knows how to fetch one full record; file-based records emit per-record files at `/data/<name>/<slug>.json` and need no configuration.
@@ -938,7 +940,7 @@ import LessonHeader from '../../components/LessonHeader' // ❌ breaks if you re
938
940
 
939
941
  Within the same directory, use normal relative imports (`./AIFeedbackCard`).
940
942
 
941
- **Foundation entry (`main.js`).** A single `export default { … }` whose top-level keys are the capabilities the foundation provides — `name`, `description`, `defaultLayout`, `defaultSection`, `viewTransitions`, `props`, `defaultInsets`, `xref`, `outputs`, `handlers` — plus an optional named `vars` export. Section types and layouts are auto-discovered and merged in by `@uniweb/build`. The build wraps your default export under `default.capabilities` in `dist/entry.js`; you never write that wrapper. The one place it matters: when you import your **own** `main.js` from a component (e.g. a download button calling `compileDocument(website, { foundation })`), you get the bare default object — pass it through directly, Press handles both shapes.
943
+ **Foundation entry (`main.js`).** A single `export default { … }` whose top-level keys are the capabilities the foundation provides — `name`, `description`, `defaultLayout`, `defaultSection`, `viewTransitions`, `props`, `defaultInsets`, `xref`, `outputs`, `handlers` — plus an optional named `vars` export. Everything here is read at render; the one thing a foundation declares that *isn't* — which host services it supports — lives in `package.json` instead (see [Declaring what your foundation supports](#declaring-what-your-foundation-supports)). Section types and layouts are auto-discovered and merged in by `@uniweb/build`. The build wraps your default export under `default.capabilities` in `dist/entry.js`; you never write that wrapper. The one place it matters: when you import your **own** `main.js` from a component (e.g. a download button calling `compileDocument(website, { foundation })`), you get the bare default object — pass it through directly, Press handles both shapes.
942
944
 
943
945
  ### Props interface
944
946
 
@@ -1722,6 +1724,7 @@ const page = website.activePage
1722
1724
  | `block.stableId` / `block.key` | Stable ID from filename or `id:` / unique key across pages — use as React key |
1723
1725
  | `block.path` | Page route this block belongs to |
1724
1726
  | `block.dataLoading` | True while declared data is still resolving |
1727
+ | `block.dataError` | `{ <key>: message }` when a declared fetch FAILED, else `null`. A failed key is absent from `content.data` — never `[]`, which means "no records" |
1725
1728
 
1726
1729
  ```jsx
1727
1730
  // getPageHierarchy(options) →
@@ -1752,6 +1755,7 @@ A component on a page with a `data:` or `fetch:` declaration automatically recei
1752
1755
  ```jsx
1753
1756
  function Article({ content, block }) {
1754
1757
  if (block.dataLoading) return <DataPlaceholder />
1758
+ if (block.dataError?.articles) return <LoadFailed /> // the request failed — not "no records"
1755
1759
  const article = content.data.articles?.[0] // focused record on a [slug] page
1756
1760
  if (!article) return <NotFound />
1757
1761
  return <ArticleView article={article} />
@@ -1775,7 +1779,7 @@ export default {
1775
1779
 
1776
1780
  A foundation can route a scope to a plain folder of schema files instead of a package via an optional `schemas.config.js` at its root — `export default { '@acme': '../shared/acme-schemas' }`. A routed scope wins over the package convention; `@/` and `@uniweb` are never routable; a routed scope has no package fallback for a missing schema (it errors rather than silently loading a different definition). Per-schema keys override single entries (most-specific wins: file › directory › package). Worked examples: `development/schemas-in-practice.md`.
1777
1781
 
1778
- **Authoring queries.** Fetch declarations accept `where:` (a where-object predicate), `sort:` (e.g. `date desc`), and `limit:`. Whether the source evaluates them or the framework applies them as a runtime fallback is a transport detail controlled by the site's `fetcher.supports:` declaration.
1782
+ **Authoring queries.** Fetch declarations accept `where:` (a where-object predicate), `sort:` (ONE key, e.g. `date desc`), and `limit:`. The framework evaluates them in the browser over the records it fetched; a host that answers queries evaluates the same language at the source; a foundation transport decides for itself. The declaration is identical in every case.
1779
1783
 
1780
1784
  ```yaml
1781
1785
  # pages/blog/page.yml
@@ -1794,33 +1798,29 @@ fetch:
1794
1798
 
1795
1799
  ### Fetching from other sources (`fetcher:`)
1796
1800
 
1797
- A site isn't limited to file-based collections. The `fetcher:` block in `site.yml` tunes the framework's default fetcher and opts into foundation-provided **named transports** per schema:
1801
+ A site isn't limited to file-based records. The default fetcher also reads a plain JSON `url:` GET, or `method: POST` with a `body:`, an optional `transform:` dot-path, the `detail:` forms for a record — and evaluates `where` / `sort` / `limit` in the browser over what arrived. A site published to a Uniweb host reads the host's records with no configuration at all.
1802
+
1803
+ A backend with its own base URL, headers, wire or query language is a **transport**: a named `{ resolve, cacheKey? }` exported by the foundation (or an extension), which the site selects per schema in `fetcher:` — the only thing that block is for:
1798
1804
 
1799
1805
  ```yaml
1800
1806
  # site.yml
1801
1807
  fetcher:
1802
- baseUrl: https://api.example.com
1803
- headers: { X-Tenant: acme }
1804
- envelope: { list: data.items, item: data.article, error: errors.0.message }
1805
-
1806
- supports: [where, limit, sort] # which operators the source evaluates natively
1807
-
1808
1808
  transports:
1809
- articles: uniweb # a foundation-registered transport handles `data: articles`
1809
+ articles: acme # a foundation-registered transport handles `data: articles`
1810
1810
  events: default # explicitly route back to the default fetcher
1811
- uniweb: # binding config that transport reads
1812
- siteFolder: abc-123-def
1811
+ acme: # binding config that transport reads
1812
+ apiKey: pk_public_123
1813
1813
  ```
1814
1814
 
1815
- **`supports:` is a capability declaration, not a switch.** With `supports: []` (the default) the source is treated as static: the whole collection is fetched and the framework applies `where` / `sort` / `limit` in JS afterward, so two pages with different predicates share one cache entry. With `supports: [where]` the predicate ships in the request and the cache splits per predicate. With `[where, limit, sort]` the source returns the final result and the framework passes it through. Pushdown applies only to remote `url:` sources — local `path:` reads are static files and always evaluate operators as a runtime fallback.
1815
+ **Selection is explicit and site-owned.** For each request: `fetcher.transports[as]` wins if set; otherwise `fetcher.transports.default` if set; otherwise the framework's default fetcher. No route-walking, no `match()` predicates, no silent foundation-owned routing the site picks.
1816
1816
 
1817
- **Selection is explicit and site-owned.** For each request: `fetcher.transports[schema]` wins if set; otherwise `fetcher.transports.default` if set; otherwise the framework's default fetcher applies `baseUrl` / `headers` / `envelope`. No route-walking, no `match()` predicates, no silent foundation-owned routing the site picks.
1817
+ `fetcher.baseUrl`, `headers`, `envelope`, `supports` and `request.*` are **retired**: a third party's conventions belong in a transport, not in the runtime every site loads. The build warns once and ignores them.
1818
1818
 
1819
1819
  > **Never put secrets in `site.yml`** — every value in it is public to the browser. Sites needing private credentials proxy through the same origin at the deployment layer, so the site fetches `/api/…` and the proxy attaches the credential server-side.
1820
1820
 
1821
- **Failures degrade rather than break:** a failed fetch falls back to `[]`, logs a build warning, and the page still renders. Components should handle the empty case which the guaranteed content shape already encourages.
1821
+ **Failures are visible, not empty:** a fetch that failed leaves its key ABSENT from `content.data` and names the message on `block.dataError[key]`; it is never delivered as `[]`, which means "no records". The page still renders a section reads `dataError` to tell the two apart.
1822
1822
 
1823
- Recipes for staying on the default fetcher, and for writing a custom transport: `development/connecting-a-backend.md`.
1823
+ When a plain `url:` is enough and when a transport is the answer: `development/connecting-a-backend.md`.
1824
1824
 
1825
1825
  Full model: `reference/data-fetching.md`. Where-object format with examples: `authoring/predicates.md`.
1826
1826
 
@@ -1911,6 +1911,49 @@ if (!url) return null // this site has no agent — render nothing, or
1911
1911
 
1912
1912
  *(A live agent that errors mid-conversation is a different problem — that's ordinary request failure, handled where you make the request.)*
1913
1913
 
1914
+ ### Declaring what your foundation supports
1915
+
1916
+ `resolveService` is how you ask at render time. The other direction — telling a
1917
+ host, *before* anything renders, which services your foundation is built to use —
1918
+ is one line in the foundation's `package.json`:
1919
+
1920
+ ```json
1921
+ {
1922
+ "uniweb": { "supports": ["search", "submit", "tracking"] }
1923
+ }
1924
+ ```
1925
+
1926
+ Service names, the same ones you pass to `resolveService`. It reaches a host when
1927
+ the foundation is registered, and it answers a question a host cannot otherwise
1928
+ answer: **a service only does something if the foundation renders something
1929
+ against it.** A host that offers search has no way to know whether your sections
1930
+ draw a search box, so without this it either offers a site something its code
1931
+ will ignore, or withholds something it would have used.
1932
+
1933
+ **Three states, and they are three different answers:**
1934
+
1935
+ | | |
1936
+ |---|---|
1937
+ | the key is **absent** | *unknown* — nobody said. Not a refusal |
1938
+ | `"supports": []` | an explicit *none* — this foundation honours no host service |
1939
+ | `"supports": ["search"]` | these, and only these |
1940
+
1941
+ ⛔ **Nothing is assumed on your behalf**, in either direction. An unstated set is
1942
+ never read as "all" and never as "none", so the only way a host learns your
1943
+ search box exists is that you said so.
1944
+
1945
+ **List what you actually integrate.** `uniweb doctor` warns when your source
1946
+ reaches for a service you did not list — but it reads your code with a pattern
1947
+ matcher, so it sees `resolveService(website, 'search')` and misses a service
1948
+ reached through a variable or a helper. It can tell you that you forgot one; it
1949
+ cannot promise it found them all. The declaration is yours to keep accurate.
1950
+
1951
+ ⚖️ **Baseline behaviour is not yours to declare.** Some services do something for
1952
+ a site whether or not a foundation cooperates — the runtime reports page views
1953
+ wherever tracking is configured, with no help from your components. List
1954
+ `tracking` when you go *beyond* that (your own events on your own components);
1955
+ leaving it out does not switch the baseline off.
1956
+
1914
1957
  ```yaml
1915
1958
  # site.yml — only when YOU are providing the endpoint. Publishing to Uniweb
1916
1959
  # Cloud needs nothing here; `uniweb export` and most `deploy --host` targets do.
@@ -581,7 +581,7 @@ export class BackendClient {
581
581
 
582
582
  /**
583
583
  * GET /dev/site/status/{uuid} → the site's publish lifecycle (Contract 3,
584
- * shipped backend-side — collab backend-framework-b220):
584
+ * shipped backend-side — collab backendframework):
585
585
  * { published: boolean, last_pushed_at?: string, last_published_at?: string, draft_dirty?: boolean }
586
586
  * `draft_dirty` = never-published, or the synced draft changed since the last
587
587
  * publish ("pushed but not published").
@@ -174,7 +174,7 @@ function forwardedFlags(args) {
174
174
  * any release, so it reflects the released version + the scope register
175
175
  * derived. Delivery is version-pinned end-to-end (the gateway serves a
176
176
  * foundation only by a concrete version, no latest-resolution at serve time —
177
- * collab framework-backend-5c3e), so an unversioned local ref MUST be pinned
177
+ * collab frameworkbackend), so an unversioned local ref MUST be pinned
178
178
  * on the wire or the live site points at code the gateway can't serve. null
179
179
  * when the site already references a registry ref / URL (no override needed)
180
180
  * or no scoped ref can be formed.
@@ -345,7 +345,7 @@ export function readSyncCache(siteDir) {
345
345
  * that. An offline re-emit produces the AUTHORED document, which is a different
346
346
  * document, so it matches nothing and every entity reads as changed forever. That is
347
347
  * not hypothetical: it is what `uniweb status` did on any site with one local image
348
- * (backend-framework-787e, 2026-08-19) — `push` said "1 entity unchanged" and
348
+ * (backendframework, 2026-08-19) — `push` said "1 entity unchanged" and
349
349
  * `status --json` said `changed: 1`, from the same cache, seconds apart.
350
350
  *
351
351
  * ⭐ What is left is what only a backend round-trip produces — an asset **serve URL**
@@ -487,7 +487,7 @@ export function readFolderItemUuids(siteDir) {
487
487
  * The backend refuses an all-blank section over stored items rather than applying it,
488
488
  * because applying it would insert every record fresh and delete every stored row —
489
489
  * content survives, identity does not. So `push` worked once and every push after was
490
- * refused. Measured 2026-08-29; collab framework-backend-812b.
490
+ * refused. Measured 2026-08-29; collab frameworkbackend.
491
491
  *
492
492
  * ⭐ Keyed by NAME, which the backend enforces unique within the section and uses as
493
493
  * its own join key. ⛔ Not `$id`: it holds the same string but is a payload-local
@@ -1225,7 +1225,7 @@ export async function pushSyncPackages({
1225
1225
  // ⭐ THE BACKEND ALREADY SENDS WHAT LOCATES IT — `section_id`,
1226
1226
  // `records_without_uuid`, `stored_items` — and this branch discarded every
1227
1227
  // one of them, so every refusal anyone collected was missing the only fields
1228
- // that say WHERE. (Named by backend in collab `framework-backend-812b`,
1228
+ // that say WHERE. (Named by backend in collab frameworkbackend,
1229
1229
  // 2026-08-28: "the offending section and both counts have been in the body of
1230
1230
  // every refusal you have collected".)
1231
1231
  const n = problem.records_without_uuid
@@ -177,31 +177,76 @@ function loadSiteYml(dir) {
177
177
  * inside a dependency is the wrong answer.
178
178
  */
179
179
  function sourceMatches(dir, pattern) {
180
+ let found = false
181
+ forEachSourceFile(dir, (text) => {
182
+ // `pattern` may carry /g, whose lastIndex persists across .test() calls —
183
+ // reset so a second file is not silently skipped.
184
+ pattern.lastIndex = 0
185
+ if (pattern.test(text)) found = true
186
+ })
187
+ return found
188
+ }
189
+
190
+ /**
191
+ * Read every component source file under `dir`, handing each one's text to `fn`.
192
+ *
193
+ * Same roots and same exclusions as `sourceMatches` (which is built on this):
194
+ * only where a foundation puts components, never `node_modules`, because the
195
+ * question is always "did the developer write this".
196
+ */
197
+ function forEachSourceFile(dir, fn) {
180
198
  const roots = ['sections', 'layouts', 'components']
181
199
  const walk = (d, depth = 0) => {
182
- if (depth > 6) return false
200
+ if (depth > 6) return
183
201
  let entries
184
202
  try {
185
203
  entries = readdirSync(d, { withFileTypes: true })
186
204
  } catch {
187
- return false
205
+ return
188
206
  }
189
207
  for (const entry of entries) {
190
208
  if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue
191
209
  const p = join(d, entry.name)
192
210
  if (entry.isDirectory()) {
193
- if (walk(p, depth + 1)) return true
211
+ walk(p, depth + 1)
194
212
  } else if (/\.(jsx?|tsx?)$/.test(entry.name)) {
195
213
  try {
196
- if (pattern.test(readFileSync(p, 'utf8'))) return true
214
+ fn(readFileSync(p, 'utf8'))
197
215
  } catch {
198
216
  // unreadable file — not this check's problem to report
199
217
  }
200
218
  }
201
219
  }
202
- return false
203
220
  }
204
- return roots.some((r) => walk(join(dir, r)))
221
+ for (const r of roots) walk(join(dir, r))
222
+ }
223
+
224
+ /**
225
+ * The host services a foundation's own source actually reaches for.
226
+ *
227
+ * ⚠️ A HINT, NEVER THE ANSWER. This is a regex over source, so it sees only the
228
+ * literal spellings below: `resolveService(website, name)` with a variable name
229
+ * is invisible to it, and so is a service reached through a helper. That makes
230
+ * it usable for "you used this and did not declare it" (a false negative just
231
+ * means no warning) and useless as a source of truth (a false negative would
232
+ * mean a capability silently dropped from what we publish).
233
+ *
234
+ * ⛔ Which is why the declaration is authored and this only checks it. Deriving
235
+ * `uniweb.supports` from a scan would make a missed match into a service the
236
+ * operator cannot buy, with nothing anywhere naming the cause.
237
+ */
238
+ function servicesUsedInSource(dir) {
239
+ const found = new Set()
240
+ forEachSourceFile(dir, (text) => {
241
+ for (const m of text.matchAll(/resolveService\s*\(\s*[^,()]+,\s*['"`]([\w-]+)['"`]/g)) {
242
+ found.add(m[1])
243
+ }
244
+ // The service-specific readers, which name no service string of their own.
245
+ if (/\bisSearchEnabled\s*\(/.test(text)) found.add('search')
246
+ if (/\buseTracker\s*\(/.test(text)) found.add('tracking')
247
+ if (/from\s+['"`]@uniweb\/api['"`]/.test(text)) found.add('api')
248
+ })
249
+ return found
205
250
  }
206
251
 
207
252
  // A fenced data block tagged `form`, in any of the serialization formats the
@@ -380,6 +425,71 @@ const CONSENT_NEAR_MISSES = new Set(['require', 'requires', 'required.', 'true',
380
425
  * A bare `tracking: <url>` string is the documented shorthand and carries no
381
426
  * options — there is nothing in it to be wrong.
382
427
  */
428
+ /**
429
+ * `uniweb doctor` — a foundation's `uniweb.supports` declaration.
430
+ *
431
+ * A host offers services (`config.services.<name>`); a foundation has to render
432
+ * something against one, or an operator who provisioned it gets nothing and no
433
+ * error. `package.json::uniweb.supports` is how a foundation states which ones
434
+ * it honours, and it rides to the registry as `info.supports`
435
+ * (`build/src/uwx/registry-package.js`).
436
+ *
437
+ * ## ⭐ WHY THIS WARNS RATHER THAN THE BUILD FILLING IT IN
438
+ *
439
+ * `servicesUsedInSource` is a regex and cannot see a service reached through a
440
+ * variable or a helper. Deriving the declaration from it would publish a list
441
+ * SHORTER than the truth, and a capability missing from that list is one the
442
+ * operator cannot buy — with nothing anywhere naming the cause. A false negative
443
+ * in a warning costs a missing warning; a false negative in a derivation costs a
444
+ * silently unsellable feature. So the developer authors it and this checks it,
445
+ * at the one moment the person who can fix it is looking.
446
+ *
447
+ * ## ⚖️ Nothing here defaults a service ON
448
+ *
449
+ * Absent means UNKNOWN, not "none" and not "all". Assuming search because most
450
+ * foundations have one would claim a service for the developer who never met
451
+ * this key — who is the same developer who forgot to declare it — turning a loud
452
+ * failure (reported unknown) into a silent one (an operator paying for something
453
+ * their site will never render).
454
+ *
455
+ * Silent when a foundation reaches for no service at all: a check that fires on
456
+ * correct configuration is how everyone learns to ignore the checker.
457
+ */
458
+ export function checkFoundationSupports({ foundationName, folderName, srcDir, pkg, issues }) {
459
+ const used = servicesUsedInSource(srcDir)
460
+ if (used.size === 0) return
461
+
462
+ const declared = pkg?.uniweb?.supports
463
+ const hasDeclaration = Array.isArray(declared)
464
+ const missing = [...used]
465
+ .filter((name) => !hasDeclaration || !declared.includes(name))
466
+ .sort()
467
+ if (missing.length === 0) return
468
+
469
+ const id = 'foundation-supports-incomplete'
470
+ const list = missing.join(', ')
471
+ issues.push({
472
+ id,
473
+ type: 'warning',
474
+ foundation: foundationName,
475
+ message: hasDeclaration
476
+ ? `${foundationName} uses ${list} but does not declare ${missing.length === 1 ? 'it' : 'them'} in uniweb.supports`
477
+ : `${foundationName} uses ${list} but declares no uniweb.supports`,
478
+ details: { used: [...used].sort(), declared: hasDeclaration ? declared : null }
479
+ })
480
+
481
+ if (hasDeclaration) {
482
+ warn(`[${id}] ${foundationName} uses ${list}, not listed in uniweb.supports`)
483
+ } else {
484
+ warn(`[${id}] ${foundationName} uses ${list} and declares no uniweb.supports`)
485
+ log(` Without it a host cannot tell "this foundation supports nothing" from`)
486
+ log(` "nobody said" — so what it renders may never be offered to the operator.`)
487
+ }
488
+ const merged = [...new Set([...(hasDeclaration ? declared : []), ...missing])].sort()
489
+ log(` Add to ${folderName}/package.json:`)
490
+ log(` ${colors.dim}"uniweb": { "supports": ${JSON.stringify(merged)} }${colors.reset}`)
491
+ }
492
+
383
493
  export function checkTrackingBlock({ siteName, siteYml, issues }) {
384
494
  const tracking = siteYml?.tracking
385
495
  if (tracking === undefined || tracking === null) return
@@ -829,6 +939,17 @@ export async function doctor(args = []) {
829
939
  log(` ${colors.dim}@import "@uniweb/kit/prose-tokens.css";${colors.reset}`)
830
940
  }
831
941
 
942
+ // ── uniweb.supports — the services this foundation says it is built against ──
943
+ for (const f of foundations) {
944
+ checkFoundationSupports({
945
+ foundationName: f.name,
946
+ folderName: f.folderName,
947
+ srcDir: resolveFoundationSrcPath(f.path),
948
+ pkg: loadPackageJson(f.path),
949
+ issues
950
+ })
951
+ }
952
+
832
953
  if (extensions.length > 0) {
833
954
  log('')
834
955
  success(`Found ${extensions.length} extension(s):`)
@@ -127,7 +127,7 @@ const say = {
127
127
  //
128
128
  // ⇒ So this branch is implementing the contract, not defending against drift. I
129
129
  // reported the two shapes as a violation of "finished values only" in collab
130
- // framework-backend-812b; the backend checked, found the adjacent ruling that
130
+ // frameworkbackend; the backend checked, found the adjacent ruling that
131
131
  // explains the relative arm, and ratified both. Do not "fix" it by demanding one
132
132
  // shape — the caller's own origin is the missing half on the relative arm, and we
133
133
  // are the caller.
@@ -626,7 +626,7 @@ export async function publish(args = []) {
626
626
  // different statements, and an `if (x)` guard collapses them into one. The
627
627
  // cost is always paid by whoever is downstream trying to tell them apart.
628
628
  //
629
- // Both halves agreed in channel backend-framework-82f2 (2026-09-01); the
629
+ // Both halves agreed in channel backendframework (2026-09-01); the
630
630
  // backend's route accepts an empty `files` array as of the same exchange.
631
631
  say.info('Uploading schema-less record data…')
632
632
  try {
@@ -684,7 +684,7 @@ export async function publish(args = []) {
684
684
  // Stamp deploy-derived info on the site-content entity: the data-bundle URL,
685
685
  // and the PINNED foundation ref (`@scope/name@version`) from the bring-along.
686
686
  // Delivery is version-pinned end-to-end (the gateway serves a foundation only
687
- // by a concrete version — collab framework-backend-5c3e), so pinning the
687
+ // by a concrete version — collab frameworkbackend), so pinning the
688
688
  // released version on the wire is required when site.yml uses an unversioned
689
689
  // local ref; injectInfo overrides info.foundation. A registry/URL ref → fnd.ref
690
690
  // is null → the site.yml ref is forwarded verbatim (already pinned).
@@ -406,7 +406,7 @@ export async function push(args = [], deps = {}) {
406
406
  // and push did neither until 2026-08-19: it sent the authored alias (`src`),
407
407
  // which names a foundation no deployment can resolve. A site created that way
408
408
  // keeps the bad ref, and the backend's create guard now refuses it outright
409
- // (channel backend-framework-787e, their measurement).
409
+ // (channel backendframework, their measurement).
410
410
  //
411
411
  // ⭐ Same shape as the send-only-changed defect fixed this morning, and missed
412
412
  // for the same reason: the rule was applied at the writers under discussion and
@@ -1,16 +1,16 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-09-03T04:51:43.576Z",
3
+ "generatedAt": "2026-09-04T23:59:53.886Z",
4
4
  "packages": {
5
5
  "@uniweb/api": {
6
- "version": "0.2.5",
6
+ "version": "0.2.6",
7
7
  "path": "framework/api",
8
8
  "deps": [
9
9
  "@uniweb/core"
10
10
  ]
11
11
  },
12
12
  "@uniweb/build": {
13
- "version": "0.37.1",
13
+ "version": "0.38.0",
14
14
  "path": "framework/build",
15
15
  "deps": [
16
16
  "@uniweb/content-reader",
@@ -34,7 +34,7 @@
34
34
  "deps": []
35
35
  },
36
36
  "@uniweb/core": {
37
- "version": "0.19.0",
37
+ "version": "0.20.0",
38
38
  "path": "framework/core",
39
39
  "deps": [
40
40
  "@uniweb/semantic-parser",
@@ -47,14 +47,14 @@
47
47
  "deps": []
48
48
  },
49
49
  "@uniweb/icons": {
50
- "version": "0.4.10",
50
+ "version": "0.4.11",
51
51
  "path": "framework/icons",
52
52
  "deps": [
53
53
  "@uniweb/core"
54
54
  ]
55
55
  },
56
56
  "@uniweb/kit": {
57
- "version": "0.15.6",
57
+ "version": "0.15.7",
58
58
  "path": "framework/kit",
59
59
  "deps": [
60
60
  "@uniweb/core",
@@ -73,7 +73,7 @@
73
73
  "deps": []
74
74
  },
75
75
  "@uniweb/projections": {
76
- "version": "0.5.7",
76
+ "version": "0.5.8",
77
77
  "path": "framework/projections",
78
78
  "deps": [
79
79
  "@uniweb/content-writer",
@@ -81,7 +81,7 @@
81
81
  ]
82
82
  },
83
83
  "@uniweb/runtime": {
84
- "version": "0.14.2",
84
+ "version": "0.15.0",
85
85
  "path": "framework/runtime",
86
86
  "deps": [
87
87
  "@uniweb/core",
@@ -87,7 +87,7 @@ export async function uploadSiteData({
87
87
  // Nothing looked wrong at either end: no error, no warning, just a request
88
88
  // that was never sent.
89
89
  //
90
- // Agreed both sides in channel backend-framework-82f2; the backend's plan
90
+ // Agreed both sides in channel backendframework; the backend's plan
91
91
  // route accepted an empty `files` array in the same exchange (it was a 400
92
92
  // before, which is what made the omission look like the only option).
93
93
  const entries = Object.entries(ball?.data || {})