uniweb 0.41.0 → 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 +5 -5
- package/partials/agents.md +59 -16
- package/src/backend/client.js +1 -1
- package/src/backend/foundation-bring-along.js +1 -1
- package/src/backend/site-sync.js +3 -3
- package/src/commands/doctor.js +127 -6
- package/src/commands/publish.js +3 -3
- package/src/commands/push.js +1 -1
- package/src/framework-index.json +10 -10
- package/src/utils/site-data-upload.js +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uniweb",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
45
|
-
"@uniweb/kit": "^0.15.6",
|
|
44
|
+
"@uniweb/core": "^0.20.0",
|
|
46
45
|
"@uniweb/semantic-parser": "^1.4.0",
|
|
47
|
-
"@uniweb/
|
|
46
|
+
"@uniweb/kit": "^0.15.7",
|
|
47
|
+
"@uniweb/runtime": "^0.15.0"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
|
-
"@uniweb/build": "^0.
|
|
50
|
+
"@uniweb/build": "^0.38.0",
|
|
51
51
|
"@uniweb/content-reader": "^1.2.4",
|
|
52
52
|
"@uniweb/semantic-parser": "^1.4.0"
|
|
53
53
|
},
|
package/partials/agents.md
CHANGED
|
@@ -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:`.
|
|
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
|
|
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:
|
|
1809
|
+
articles: acme # a foundation-registered transport handles `data: articles`
|
|
1810
1810
|
events: default # explicitly route back to the default fetcher
|
|
1811
|
-
|
|
1812
|
-
|
|
1811
|
+
acme: # binding config that transport reads
|
|
1812
|
+
apiKey: pk_public_123
|
|
1813
1813
|
```
|
|
1814
1814
|
|
|
1815
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
package/src/backend/client.js
CHANGED
|
@@ -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
|
|
584
|
+
* shipped backend-side — collab backend↔framework):
|
|
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
|
|
177
|
+
* collab framework↔backend), 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.
|
package/src/backend/site-sync.js
CHANGED
|
@@ -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
|
|
348
|
+
* (backend↔framework, 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
|
|
490
|
+
* refused. Measured 2026-08-29; collab framework↔backend.
|
|
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
|
|
1228
|
+
// that say WHERE. (Named by backend in collab framework↔backend,
|
|
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
|
package/src/commands/doctor.js
CHANGED
|
@@ -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
|
|
200
|
+
if (depth > 6) return
|
|
183
201
|
let entries
|
|
184
202
|
try {
|
|
185
203
|
entries = readdirSync(d, { withFileTypes: true })
|
|
186
204
|
} catch {
|
|
187
|
-
return
|
|
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
|
-
|
|
211
|
+
walk(p, depth + 1)
|
|
194
212
|
} else if (/\.(jsx?|tsx?)$/.test(entry.name)) {
|
|
195
213
|
try {
|
|
196
|
-
|
|
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
|
-
|
|
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):`)
|
package/src/commands/publish.js
CHANGED
|
@@ -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
|
|
130
|
+
// framework↔backend; 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
|
|
629
|
+
// Both halves agreed in channel backend↔framework (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
|
|
687
|
+
// by a concrete version — collab framework↔backend), 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).
|
package/src/commands/push.js
CHANGED
|
@@ -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
|
|
409
|
+
// (channel backend↔framework, 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
|
package/src/framework-index.json
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-09-
|
|
3
|
+
"generatedAt": "2026-09-04T23:59:53.886Z",
|
|
4
4
|
"packages": {
|
|
5
5
|
"@uniweb/api": {
|
|
6
|
-
"version": "0.2.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
84
|
+
"version": "0.15.0",
|
|
85
85
|
"path": "framework/runtime",
|
|
86
86
|
"deps": [
|
|
87
87
|
"@uniweb/core",
|
|
@@ -109,7 +109,7 @@
|
|
|
109
109
|
"deps": []
|
|
110
110
|
},
|
|
111
111
|
"@uniweb/templates": {
|
|
112
|
-
"version": "0.11.
|
|
112
|
+
"version": "0.11.3",
|
|
113
113
|
"path": "framework/templates",
|
|
114
114
|
"deps": []
|
|
115
115
|
},
|
|
@@ -119,7 +119,7 @@
|
|
|
119
119
|
"deps": []
|
|
120
120
|
},
|
|
121
121
|
"@uniweb/unipress": {
|
|
122
|
-
"version": "0.9.
|
|
122
|
+
"version": "0.9.4",
|
|
123
123
|
"path": "framework/unipress",
|
|
124
124
|
"deps": [
|
|
125
125
|
"@uniweb/build",
|
|
@@ -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
|
|
90
|
+
// Agreed both sides in channel backend↔framework; 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 || {})
|