uniweb 0.84.0 → 0.86.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.84.0",
3
+ "version": "0.86.0",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -42,15 +42,15 @@
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.0.0",
44
44
  "@uniweb/content-writer": "^0.3.4",
45
- "@uniweb/kit": "^0.19.18",
46
- "@uniweb/core": "^0.37.2",
45
+ "@uniweb/core": "^0.38.0",
46
+ "@uniweb/runtime": "^0.29.3",
47
47
  "@uniweb/semantic-parser": "^1.5.1",
48
- "@uniweb/runtime": "^0.29.2",
49
- "@uniweb/schemas": "^0.12.0"
48
+ "@uniweb/schemas": "^0.13.0",
49
+ "@uniweb/kit": "^0.20.0"
50
50
  },
51
51
  "peerDependencies": {
52
- "@uniweb/build": "^0.77.0",
53
52
  "@uniweb/content-reader": "^1.2.8",
53
+ "@uniweb/build": "^0.79.0",
54
54
  "@uniweb/semantic-parser": "^1.5.1"
55
55
  },
56
56
  "peerDependenciesMeta": {
@@ -899,7 +899,7 @@ brief:
899
899
  The body — the value of the schema's content field, `body.content`.
900
900
  ```
901
901
 
902
- **A component receives it in one of two shapes, and its `meta.js` says which**, per `data:` key — the same on a static site and a hosted one. `data: { articles: '@std/article' }` gets **briefs**: the brief's fields at the top (`article.title`), no other section. `data: { articles: '@std/article/*' }` gets each record **whole**, as stored: every section under its own name, the brief's included (`article.brief.title`, `article.body.content`). Both carry `$name`, the handle. Lists carry briefs; a section that shows a record in full declares `/*`. A `fields:` schema's brief is the whole record, so its components need no `/*`. Full rules: `reference/entity-content.md`.
902
+ **A component receives it in one of two shapes, and its `meta.js` says which**, per `data:` key — the same on a static site and a hosted one. `data: { articles: '@std/article' }` gets **briefs**: the brief's fields at the top (`article.title`), no other section. `data: { articles: { schema: '@std/article', whole: true } }` gets each record **whole**, as stored: every section under its own name, the brief's included (`article.brief.title`, `article.body.content`). Both carry `$name`, the handle. Lists carry briefs; a section that shows a record in full declares `whole: true`. A `fields:` schema's brief is the whole record, so its components need no `whole: true`. Full rules: `reference/entity-content.md`.
903
903
 
904
904
  **A reference names the record it points at.** A `{ ref: '@/speaker' }` field is written as that record's name — `speaker: ada` for `records/speaker/ada.yml` (its file's name) — and a `many` one as a list of names. A component receives `{ entity, brief }`: the record reduced to its brief, so `talk.speaker.brief.name`. `uniweb validate` and a push report a name that matches no record; a push sends each reference to the record it names (in the same push when that record is new too), and a pull writes the name back.
905
905
 
@@ -962,15 +962,15 @@ pages/blog/
962
962
 
963
963
  **Link a card with `item.$route` — never compose the URL.** Every record a query delivers carries `$route`, the URL of the parametric page whose route query is that query (`/blog/design-tips`), wherever the list appears — the query's own page, the homepage, a sidebar. No page for the query, or no value for the field its URL is built from, means no `$route`, never a broken one. `detailPage: page:<id>` on a fetch links the records to another page instead. `$` marks a field the framework fills, so a record's own `route` field is left alone. ⛔ `route:` on a query is retired and stops the build.
964
964
 
965
- **Which query the URL narrows — the page's route query:** the `[slug]` page's own `query:`, else its parent page's (the usual shape, above), else `site.yml`'s (for a top-level `pages/[slug]/` only — the site's reaches no deeper page); if none declares one, the query its sections all declare. The first query of that level wins. Every section the route query reaches gets the one record; a section declaring a *different* query of its own gets that query as declared. The folder name says what the URL segment matches: `[slug]` the record's name (`$name`, a compiled record's file name) — or the field its route query binds `:slug` to (`where: { slug: :slug }`, how an external API's records, which have no `$name`, get pages), `[uuid]` its `$uuid`, any other `[name]` the record's own field of that name — and when that field holds several values, **any member** matches (the record's own link is its first value). Routing by a field that is not unique picks one record and which one is not guaranteed; the build warns. A folder inside `[slug]/` (`[slug]/cv/` → `/blog/:slug/cv`) is a parametric page too, about the same record: its route query is the `[slug]` page's, wherever that is declared, and a query `cv/` declares itself arrives under its own key. **A section chooses how it uses the page's record with `current:`** on its own `fetch:` — `only` (the default: the record, as a list of one), `exclude` (the others: "related"), `include` (all of them: a pager) — e.g. `fetch: { query: articles, as: related, current: exclude, limit: 3 }`, where `limit` counts the others, all from the query's set. **`current:` follows the query, not the key:** a fetch of the route query gets the record unless it says otherwise, whatever its `as`; a fetch of another query gets that query's records and reads `current:` only when written (`exclude` drops the page's record, `only` keeps just it). `refine: true` / `detail: false` are retired and stop the build. `[dir]` and `[path]` are refused as folder names, and so is any folder inside `[...path]/`.
965
+ **Which query the URL narrows — the page's route query:** the `[slug]` page's own `query:`, else its parent page's (the usual shape, above), else `site.yml`'s (for a top-level `pages/[slug]/` only — the site's reaches no deeper page); if none declares one, the query its sections all declare. The first query of that level wins. Every section the route query reaches gets the one record; a section declaring a *different* query of its own gets that query as declared. The folder name says what the URL segment matches: `[slug]` the record's name (`$name`, a compiled record's file name) — or the field its route query binds `:slug` to (`where: { slug: :slug }`, how an external API's records, which have no `$name`, get pages), `[uuid]` its `$uuid`, any other `[name]` the record's own field of that name — and when that field holds several values, **any member** matches (the record's own link is its first value). Routing by a field that is not unique picks one record and which one is not guaranteed; the build warns. A folder inside `[slug]/` (`[slug]/cv/` → `/blog/:slug/cv`) is a parametric page too, about the same record: its route query is the `[slug]` page's, wherever that is declared, and a query `cv/` declares itself arrives under its own key. **A section chooses how it uses the page's record with `current:`** on its own `fetch:` — `only` (the default: the record), `exclude` (the others: "related"), `include` (all of them: a pager) — e.g. `fetch: { query: articles, as: related, current: exclude, limit: 3 }`, where `limit` counts the others, all from the query's set. **`current:` follows the query, not the key:** a fetch of the route query gets the record unless it says otherwise, whatever its `as`; a fetch of another query gets that query's records and reads `current:` only when written (`exclude` drops the page's record, `only` keeps just it). `refine: true` / `detail: false` are retired and stop the build. `[dir]` and `[path]` are refused as folder names, and so is any folder inside `[...path]/`.
966
966
 
967
- > **The record arrives as a single-element array under the key the component declares** — `content.data.recent[0]` for a component declaring `recent`, `content.data.article[0]` for one declaring `article: '@std/article/*'` over an `@std/article` query. It is the record's brief, or the record whole where the key declares `/*` — a section that shows an article's body declares `/*`. The runtime never coerces it to an object. See *Data* in Part 4.
967
+ > **The record arrives under the key the component declares, as that key says** — one record for a key declared `single: true` (`content.data.article` for `article: { schema: '@std/article', single: true }` over an `@std/article` query; `null` when the URL names none), a single-element array for any other key (`content.data.recent[0]`). It is the record's brief, or the record whole where the key declares `whole: true` — a section that shows an article's body declares `whole: true`. See *Data* in Part 4.
968
968
 
969
- **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 its handle, so the section reads `content.data.recent[0]` as before. The same three variables exist under every parametric page: under `[slug]`, `:slug` and `:path` are the segment and `:dir` is empty. A record's own URL (`$route`, and the page a static build writes) is its name, as under `[slug]`: `/blog/my-post` wherever `records/folder.yml` places it — a record does not carry its folder. A query may bind a part — `scope: :dir` exposes the folder branch, `where: { tag: :dir }` keeps it private — and an unbound or empty variable drops its clause, so one saved query serves the query's page and its parametric page, on a static site and a hosted one alike. Without `scope: :dir` the directory is decoration: the record is found by its handle wherever it sits. Reference: `reference/dynamic-routes.md`.
969
+ **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 its handle, so the section reads it as before. The same three variables exist under every parametric page: under `[slug]`, `:slug` and `:path` are the segment and `:dir` is empty. A record's own URL (`$route`, and the page a static build writes) is its name, as under `[slug]`: `/blog/my-post` wherever `records/folder.yml` places it — a record does not carry its folder. A query may bind a part — `scope: :dir` exposes the folder branch, `where: { tag: :dir }` keeps it private — and an unbound or empty variable drops its clause, so one saved query serves the query's page and its parametric page, on a static site and a hosted one alike. Without `scope: :dir` the directory is decoration: the record is found by its handle wherever it sits. Reference: `reference/dynamic-routes.md`.
970
970
 
971
971
  **Two options for bigger sets:**
972
972
 
973
- A list carries briefs, so a schema that keeps its heavy parts out of the brief — `@std/article`'s body is its own section — keeps lists light with no configuration, while a section declaring `/*` still receives the whole record on a `[slug]` page, and other components fetch it on demand with `useWholeRecord`. File-based records are written one file per record, whole, at `/data/<name>/<name of the record>.json`; an external query names its one-record request with `record:` instead (*Fetching from other sources* in Part 4). (`deferred:` is removed: a schema's brief decides what a list holds.)
973
+ A list carries briefs, so a schema that keeps its heavy parts out of the brief — `@std/article`'s body is its own section — keeps lists light with no configuration, while a section declaring `whole: true` still receives the whole record on a `[slug]` page, and other components fetch it on demand with `useWholeRecord`. File-based records are written one file per record, whole, at `/data/<name>/<name of the record>.json`; an external query names its one-record request with `record:` instead (*Fetching from other sources* in Part 4). (`deferred:` is removed: a schema's brief decides what a list holds.)
974
974
 
975
975
  `queryable:` declares which fields a reader may filter on, with enough metadata for the foundation to render controls:
976
976
 
@@ -1873,21 +1873,34 @@ Content-less containers appear as group nodes (`hasContent: false`) — use `nav
1873
1873
 
1874
1874
  **A section receives the keys its component declares in `meta.js` `data:` — and nothing else** (plus any its foundation declares in `main.js` `data:`). A component that reads `content.data.articles` declares `articles`. Each declared key is filled by, in order: a tagged data block in the section; else the fetch that fills it, level by level from the section's own to the site's — a fetch whose `as` (its query's name by default) is the key, or else, **automatic `as`**, the first fetch under an undeclared key whose query's schema is the key's (`@/x` matches any scope's `x`); else `null`. So `data: { related: '@std/article' }` receives an `articles` query under `related`. `as:` on a fetch picks the key when two keys or two fetches share a schema. A fetch that fills none of a section's keys is not requested for it.
1875
1875
 
1876
- **A query's records always arrive as an array.** On the query's own page, `content.data.articles` holds every record the fetch takes. On a parametric page (`[slug]/`), the record the URL names is delivered as a single-element array under the key the component declares — the section showing it reads `content.data.articles[0]` (or `content.data.article[0]` with `data: { article: '@std/article/*' }`). When nothing matches, the key is `[]`; when nothing fills it, `null`. The runtime never coerces to a single object.
1876
+ **A key says how many records it holds, and how much of each — two flags, independent.** A ref alone (`'@std/article'`) is a **list of briefs**: on the query's own page every record the fetch takes, on a parametric page (`[slug]/`) a single-element array, `[]` when nothing matches. Write the key long to change either:
1877
1877
 
1878
- **Briefs or whole records — `/*`.** A key declared with a bare ref (`'@std/article'`) receives each record's **brief**: the brief section's fields at the top, the section not named. A key declared with `/*` (`'@std/article/*'`) receives each record **whole**, as stored: every section under its own name, the brief's included — `article.brief.title`, `article.body.content`. The runtime asks for what the key declares, lists and parametric pages alike; a `/*` key its source cannot answer whole is `null`. A `fields:` schema's brief is the whole record, so `/*` is only needed for a schema with more than one section. An external query's records have no schema and arrive as its source answers them. Reference: `reference/component-metadata.md` § *Briefs or whole records*.
1878
+ ```js
1879
+ data: {
1880
+ articles: '@std/article', // a list of briefs
1881
+ article: { schema: '@std/article', single: true, whole: true }, // one record, whole
1882
+ card: { schema: '@std/article', single: true }, // one record's brief
1883
+ archive: { schema: '@std/article', whole: true }, // a list of whole records
1884
+ }
1885
+ ```
1886
+
1887
+ - **`single: true`** — the key holds **one record**: the first its binding selects (on a `[slug]` page, the record the URL names), or `null` when there is none. The runtime asks for just that one. A binding over many gives the first: sort the query, or give the binding `limit: 1`.
1888
+ - **`whole: true`** — each record **whole**, as stored: every section under its own name, the brief's included — `article.brief.title`, `article.body.content`. Without it, each record is its **brief**: the brief section's fields at the top, the section not named. The runtime asks for what the key declares; a `whole` key its source cannot answer whole is `null`. A `fields:` schema's brief is the whole record, so `whole` is only needed for a schema with more than one section.
1889
+ - The long form takes `schema`, `single` and `whole`, nothing else — the build stops on any other property, and on `single` or `whole` over a schema whose root is a list (`@std/nav`, `@std/form`: the key holds that list). ⛔ `'@std/article/*'` is retired and stops the build: write `whole: true`.
1890
+
1891
+ When nothing fills a key it is `null`. An external query's records have no schema and arrive as its source answers them. Reference: `reference/component-metadata.md` § *One record, or a list* and § *Briefs or whole records*.
1879
1892
 
1880
1893
  ```jsx
1881
- function Article({ content, block }) {
1894
+ function Article({ content, block }) { // data: { article: { schema: '@std/article', single: true } }
1882
1895
  if (block.dataLoading) return <DataPlaceholder />
1883
- if (block.dataError?.articles) return <LoadFailed /> // the request failed — not "no records"
1884
- const article = content.data.articles?.[0] // focused record on a [slug] page
1896
+ if (block.dataError?.article) return <LoadFailed /> // the request failed — not "no records"
1897
+ const article = content.data.article // the record the [slug] page's URL names, or null
1885
1898
  if (!article) return <NotFound />
1886
1899
  return <ArticleView article={article} />
1887
1900
  }
1888
1901
  ```
1889
1902
 
1890
- When a record genuinely needs to be a single object, that's the foundation's job — read `[0]`, or reshape once with a `handlers.data` hook.
1903
+ Any other reshaping is the foundation's job — once, with a `handlers.data` hook.
1891
1904
 
1892
1905
  **Declaring keys and schemas.** `meta.js` declares each `content.data` key the component receives, with its schema, in a single `data:` field — there is no separate `schemas:` key. Each value is a **named ref**, an **inline field map**, an **inline rich-form** (`{ fields: [...] }`, an editor form), or `{}` for a key with no schema (an external API's records). Refs resolve on disk at build time, never fetched: `@/name` (this foundation's `schemas/`), `@std/name` (shared standards, from `@uniweb/schemas`), `@org/name` (an org's own `@org/schemas` package). A schema drives the editor, checks your data (`uniweb validate`), and lets a fetch of another name fill the key — it fills no field: **a record reaches the component as it is**, so a field it lacks is absent, and what that renders as is your component's choice (`record.status ?? 'published'`). **A named schema declares no `default:`** — the build refuses one; a `default:` belongs only in an inline field map or form, where an editor pre-fills from it. ⛔ **Delivery was default-on until this change** — every fetched key reached every component, and `data:` was a hint; a component that reads a key it does not declare now receives nothing under it. `data: false` declares nothing, like no `data:`. Keys a foundation's handlers (or a shared hook) read go in `main.js` `data:`, in the same form, and every section receives them.
1893
1906
 
@@ -1915,7 +1928,7 @@ fetch:
1915
1928
  limit: 3
1916
1929
  ```
1917
1930
 
1918
- **Lean lists.** A list carries each record's brief, so heavy parts belong in a section of their own — `@std/article`'s body is `body` — and never ride a list. A section that shows them declares `/*`; elsewhere components fetch the whole record on demand with `useWholeRecord(record, { query })`, which returns it as stored — `full.body.content`. Every file-based record gets its own file, `/data/<name>/<name of the record>.json`. An external query's whole record comes from its `record:` request, below. (`deferred:` is removed — the build stops on it.) The hook is safe to call on any query: when the query has no separate source for one record it returns the record you passed in.
1931
+ **Lean lists.** A list carries each record's brief, so heavy parts belong in a section of their own — `@std/article`'s body is `body` — and never ride a list. A section that shows them declares its key `whole: true`; elsewhere components fetch the whole record on demand with `useWholeRecord(record, { query })`, which returns it as stored — `full.body.content`. Every file-based record gets its own file, `/data/<name>/<name of the record>.json`. An external query's whole record comes from its `record:` request, below. (`deferred:` is removed — the build stops on it.) The hook is safe to call on any query: when the query has no separate source for one record it returns the record you passed in.
1919
1932
 
1920
1933
  **Component-side fetching.** When a component genuinely needs to fetch on its own (a search box, "load more", a lazy popover), use the kit hooks — `useFetched`, `useCacheEntry`, `useWholeRecord`. They share the framework's cache and dispatcher with declarative fetches; same-key requests dedupe automatically.
1921
1934
 
@@ -1962,15 +1975,15 @@ When an external query is enough and when a transport is the answer: `developmen
1962
1975
 
1963
1976
  Full model: `reference/data-fetching.md`. Where-object format with examples: `authoring/predicates.md`.
1964
1977
 
1965
- ### Search (`search:`)
1978
+ ### Search (`services.search`)
1966
1979
 
1967
- Search follows the same arrangement as `fetcher:` — the **site** declares where results come from, and a search UI reads them the same way regardless. Never hardcode a search endpoint in a component; that couples the foundation to one host.
1980
+ Search follows the same arrangement as `fetcher:` — the **site** declares where results come from, and a search UI reads them the same way regardless. Never hardcode a search endpoint in a component; that couples the foundation to one host. Search is on by default; its entry under `services:` (see *Services*, below) turns it off, sets options, or — on a site you publish — asks the host for it:
1968
1981
 
1969
1982
  ```yaml
1970
1983
  # site.yml
1971
- search:
1972
- enabled: true
1973
- provider: index # default — download an index, match in the browser
1984
+ services:
1985
+ search:
1986
+ provider: index # default — download an index, match in the browser
1974
1987
  ```
1975
1988
 
1976
1989
  | Provider | Answers with | Trade-off |
@@ -1980,12 +1993,13 @@ search:
1980
1993
  | *any other name* | A foundation-supplied search transport | Fully open — Typesense, Meilisearch, Pagefind, a vendor API |
1981
1994
 
1982
1995
  ```yaml
1983
- search:
1984
- provider: endpoint
1985
- endpoint: _search # REQUIRED — there is no default
1996
+ services:
1997
+ search:
1998
+ provider: endpoint
1999
+ endpoint: _search # REQUIRED — there is no default
1986
2000
  ```
1987
2001
 
1988
- `endpoint:` is **required** with `provider: endpoint`; omit it and the provider refuses the query rather than guessing a path. It resolves against the site's base path — `/` → `/_search`, `base: /docs/` → `/docs/_search`, a subpath-served site follows its subpath. An absolute `https://…` URL points at another origin. A host that serves the site may offer search itself, supplying the address so the site declares none.
2002
+ `endpoint:` is **required** with `provider: endpoint`; omit it and the provider refuses the query rather than guessing a path. It resolves against the site's base path — `/` → `/_search`, `base: /docs/` → `/docs/_search`, a subpath-served site follows its subpath. An absolute `https://…` URL points at another origin. A host that serves the site may offer search itself: `search: true` asks for it, and the host supplies the address. An endpoint of your own asks the host to leave its search off, so yours answers.
1989
2003
 
1990
2004
  **Results have one shape, whatever the provider.** Always present: `id`, `type`, `route`, `href`, `title`, `pageTitle`, `excerpt`, `snippetHtml`. Provider-optional (`null` when absent): `sectionId`, `anchor`, `description`, `component`, `snippetText`, `matches`, `group`, `item`. `type` is `page`, `section` or `record`; on a record hit, `item` holds the record's fields and `group` names the set it came from. Whether an optional field arrives is a deployment fact, not a content fact — the same site yields `item` from a server provider and `null` from the local index — so guard them: `result.item?.image`.
1991
2005
 
@@ -2002,29 +2016,30 @@ const { results, isLoading, query } = useSearch(website) // `query` is a funct
2002
2016
 
2003
2017
  Full reference: `authoring/search.md`.
2004
2018
 
2005
- ### Forms (`submit:`)
2019
+ ### Forms (`services.submit`)
2006
2020
 
2007
2021
  Drawing a form is a foundation's job; delivering what a visitor typed needs a
2008
2022
  server. Where that server is comes from the **site or its host** — never from a
2009
- section type, same arrangement as `fetcher:` and `search:`.
2023
+ section type, same arrangement as `fetcher:` and search.
2010
2024
 
2011
2025
  A form gets its destination from the first of these that applies:
2012
2026
 
2013
- 1. **One the host supplies** — `services.submit` in the served payload. Where the
2014
- host handles submissions, that is the destination and nothing in `site.yml`
2015
- overrides it — so a site published to Uniweb Cloud needs **no `submit:`**: it
2016
- asks for form handling with `services:` instead (*Uniweb Cloud*, below).
2017
- 2. **`submit:` in `site.yml`** — an endpoint you name yourself, for a host that
2018
- does not handle submissions, or a static site.
2027
+ 1. **One the host supplies** — `config.services.submit` in the payload it serves. Where the
2028
+ host handles submissions, that is the destination. A site published to Uniweb
2029
+ Cloud asks for it with `submit: true` under `services:` and names no address.
2030
+ 2. **An address of the site's own** — `submit: <url>` under `services:`, for a
2031
+ static site or a host that does not handle submissions. On a host that does, it
2032
+ asks the host to leave its own off, so this one answers.
2019
2033
  3. **Neither** — there is no destination: render no form, or fall back to contact
2020
2034
  details the site already carries.
2021
2035
 
2022
2036
  That is the general arrangement, not a forms-only one. A host states everything
2023
- it offers in the served payload's `services`, keyed by name, and every service
2024
- resolves by the same rule — the host's offer, then your declaration, then neither.
2037
+ it offers in the served payload's `config.services`, keyed by name, and every service
2038
+ resolves by the same rule — the host's offer, then the site's own address, then
2039
+ neither.
2025
2040
 
2026
2041
  ⭐ **Before you render UI for a service, ask whether the site has it** — one predicate per service,
2027
- no arguments: `isSearchEnabled()`, `isSubmitEnabled()`, `isApiEnabled()`, `isAssistantEnabled()`,
2042
+ no arguments: `isSearchEnabled()`, `isSubmitEnabled()`, `isBackendEnabled()`, `isAssistantEnabled()`,
2028
2043
  `isTrackingEnabled()`.
2029
2044
 
2030
2045
  ```jsx
@@ -2116,10 +2131,15 @@ wherever tracking is configured, with no help from your components. List
2116
2131
  leaving it out does not switch the baseline off.
2117
2132
 
2118
2133
  ```yaml
2119
- # site.yml — only when YOU are providing the endpoint. Publishing to Uniweb
2120
- # Cloud needs nothing here; `uniweb export` and most `deploy --host` targets do.
2121
- submit: /forms # base-relative, resolved like search.endpoint
2122
- submit: https://forms.example.com/intake # or another origin
2134
+ # site.yml
2135
+ services:
2136
+ submit: true # your host's form handling (Uniweb Cloud)
2137
+ ```
2138
+
2139
+ ```yaml
2140
+ # site.yml — when YOU are providing the endpoint: `uniweb export`, most `deploy --host` targets
2141
+ services:
2142
+ submit: https://forms.example.com/intake # or /forms, base-relative like search's endpoint
2123
2143
  ```
2124
2144
 
2125
2145
  ```jsx
@@ -2138,12 +2158,12 @@ if (!canSubmit) return null // nowhere to send — render no form, or fall ba
2138
2158
  ```
2139
2159
 
2140
2160
  > **The framework never invents an endpoint — but a host may supply one.** Don't
2141
- > reach for `submit:` reflexively: on Uniweb Cloud the host's form handling wins
2142
- > wherever it is on, so a `submit:` there answers only when it is off — ask for it
2143
- > with `services:` instead. Reach for `submit:` when you are the one hosting.
2161
+ > write an address reflexively: on Uniweb Cloud an address asks the host to turn its
2162
+ > own form handling off — `submit: true` is the whole declaration there. Reach for an
2163
+ > address when you are the one hosting.
2144
2164
  >
2145
- > `canSubmit` is false only when neither a declaration nor a host supplies a
2146
- > destination. **Check it when you render, not only on the button press** — a
2165
+ > `canSubmit` is false only when neither the host nor an address of the site's own
2166
+ > supplies a destination. **Check it when you render, not only on the button press** — a
2147
2167
  > form nobody can send should not be on the page at all. A read that 404s
2148
2168
  > degrades to `[]` and the page still renders; a write that 404s loses what a
2149
2169
  > person typed, so it gets no silent fallback.
@@ -2197,7 +2217,7 @@ from. `values` keeps the `File` so your input can show its selection.
2197
2217
 
2198
2218
  Full reference: `development/receiving-form-submissions.md`.
2199
2219
 
2200
- ### Tracking (`tracking:`)
2220
+ ### Tracking (`services.tracking`)
2201
2221
 
2202
2222
  A site may declare one **tracking destination**, and everything worth counting
2203
2223
  goes there as an event on a single stream. A page visit is just the event the
@@ -2205,23 +2225,28 @@ runtime emits by itself.
2205
2225
 
2206
2226
  ```yaml
2207
2227
  # site.yml — your own collector, on any host
2208
- tracking: https://collector.example.com/events
2228
+ services:
2229
+ tracking: https://collector.example.com/events
2230
+ ```
2209
2231
 
2232
+ ```yaml
2210
2233
  # or, when it needs more than an address
2211
- tracking:
2212
- endpoint: /collect
2213
- consent: required
2234
+ services:
2235
+ tracking:
2236
+ endpoint: /collect
2237
+ consent: required
2214
2238
  ```
2215
2239
 
2216
- A host may also supply one under `services.tracking`, and the usual precedence
2217
- applies: the host's, then yours, then neither.
2240
+ A host may also supply one — `tracking: true` asks for it on a site you publish —
2241
+ and the usual precedence applies: the host's, then yours, then neither. An
2242
+ endpoint of your own asks the host to leave its collector off, so yours answers.
2218
2243
 
2219
2244
  ⚠️ **The endpoint has to accept the framework's own format** — a batched
2220
2245
  `{ "events": [ … ] }` POST, documented in `reference/site-configuration.md`. It
2221
2246
  is not a third-party analytics product's public API, which expects that
2222
2247
  product's own shape.
2223
2248
 
2224
- A site may also name a vendor's own script under `tracking.scripts`, which the
2249
+ A site may also name a vendor's own script under `services.tracking.scripts`, which the
2225
2250
  runtime loads once, after consent when a gate is declared, and never in a frame
2226
2251
  or during prerender. That is a **separate path with no connection to the stream
2227
2252
  below** — the vendor measures its own way, and nothing you `track()` reaches it.
@@ -2271,21 +2296,24 @@ Narrow or widen that with `emit`:
2271
2296
 
2272
2297
  ```yaml
2273
2298
  # site.yml — your own collector
2274
- tracking:
2275
- endpoint: https://collector.example.com/events
2276
- emit: standard # minimal | standard | all — or a list of event names
2299
+ services:
2300
+ tracking:
2301
+ endpoint: https://collector.example.com/events
2302
+ emit: standard # minimal | standard | all — or a list of event names
2303
+ ```
2277
2304
 
2305
+ ```yaml
2278
2306
  # site.yml — a host that supplies the collector: say what to send, not where
2279
- tracking:
2280
- emit: minimal
2307
+ services:
2308
+ tracking:
2309
+ emit: minimal
2281
2310
  ```
2282
2311
 
2283
2312
  ⭐ **`emit` needs no endpoint of its own.** Where a host provides one, the site
2284
2313
  declares only what it wants sent and the address comes from the host. The two
2285
2314
  are read key by key, so naming `emit` alone overrides nothing else the host
2286
- declared. An `endpoint:` of your own is used wherever the host supplies no
2287
- collector — on a host without one, and on none — and where the host supplies
2288
- one, the host's is used.
2315
+ declared. An `endpoint:` of your own means you collect yourself, and asks the host
2316
+ to leave its collector off.
2289
2317
 
2290
2318
  `minimal` is `page_view` alone. `standard` is the default when the collector is
2291
2319
  your own. `all` is a standing yes, so an event added in a later framework
@@ -2442,7 +2470,7 @@ For cases the factory doesn't cover, write handlers directly using `Loom`, `inst
2442
2470
  ## Part 4b — When the site is also an app
2443
2471
 
2444
2472
  Everything above is a site: content the author writes, built into pages. Some sites
2445
- also have **an `api` service** — accounts, per-visitor data, records their members
2473
+ also have **their own backend, the `backend` service** — accounts, per-visitor data, records their members
2446
2474
  create and edit. `@uniweb/api` is its client.
2447
2475
 
2448
2476
  ⛔ **Only reach for this when the site actually has one.** A site without one is
@@ -2456,13 +2484,13 @@ npm install @uniweb/api # in the FOUNDATION, beside @uniweb/kit
2456
2484
  ### Ask before you draw
2457
2485
 
2458
2486
  ```jsx
2459
- import { isApiEnabled } from '@uniweb/kit'
2487
+ import { isBackendEnabled } from '@uniweb/kit'
2460
2488
  import { useSession, SignedIn, SignedOut } from '@uniweb/api'
2461
2489
 
2462
- if (!isApiEnabled()) return <StaticVersion /> // synchronous — nothing to await
2490
+ if (!isBackendEnabled()) return <StaticVersion /> // synchronous — nothing to await
2463
2491
  ```
2464
2492
 
2465
- ⛔ **When the site has no `api` service, draw nothing** — not a disabled control, and not an
2493
+ ⛔ **When the site has no `backend` service, draw nothing** — not a disabled control, and not an
2466
2494
  explanation. Same rule as `services` in Part 4: which capabilities a site's operator
2467
2495
  set up is none of a visitor's business, and "sign-in unavailable" reads as breakage
2468
2496
  when it is simply a feature this site does not have. Render the version of your
@@ -2484,7 +2512,7 @@ remove it. `scope: 'mine'` lists only the viewer's own records. Without it, the
2484
2512
  also gets everyone else's.
2485
2513
 
2486
2514
  ⭐ **`absent` and an empty `ready` are different answers, and confusing them is the
2487
- mistake to avoid.** `absent` = there is no live source (no `api` service, or nobody signed
2515
+ mistake to avoid.** `absent` = there is no live source (no `backend` service, or nobody signed
2488
2516
  in) → render the site's authored content. `ready` with `records: []` = the service
2489
2517
  answered and there is nothing there → render your empty state. Showing "nothing yet"
2490
2518
  for the first tells a visitor their content is gone when it was never requested.
@@ -2540,8 +2568,9 @@ permission model and a CSS one.
2540
2568
  You do not need a live backend to build against one. In `site.yml`:
2541
2569
 
2542
2570
  ```yaml
2543
- api: /_api # where it answers — the same value in production
2544
- $devApi: ./mock/api.js # what answers it locally; `$` keys are never published
2571
+ services:
2572
+ backend: true # ask your host for it when you publish
2573
+ $devBackend: ./mock/api.js # what answers it in `uniweb dev`; `$` keys are never published
2545
2574
  ```
2546
2575
 
2547
2576
  ```js
@@ -2550,8 +2579,10 @@ import { createMockBackend } from '@uniweb/api/mock'
2550
2579
  export default createMockBackend({ seed }).fetch
2551
2580
  ```
2552
2581
 
2553
- `uniweb dev` mounts it at your `api:` address, same-origin, so cookies and your
2554
- site's configuration behave exactly as they will in production. It **enforces** who
2582
+ `uniweb dev` answers your site's `backend` service with it, at an address the dev server
2583
+ supplies on its own origin, so cookies behave exactly as they will in production. Write
2584
+ no address of your own for it: under `services:` an address means a backend you run, and
2585
+ asks your host to leave its own off. It **enforces** who
2555
2586
  may edit an entry and `append_only`, so a permission you are relying on fails on your
2556
2587
  machine rather than in front of a user. State is in memory; restart to reset.
2557
2588
 
@@ -2578,7 +2609,7 @@ uniweb push / pull / clone / status # Git-style content sync with the Uniweb b
2578
2609
  uniweb refresh / sync # Catch up (git + backend, never pushes) / catch up, then push
2579
2610
  uniweb login --org @acme # The workspace you work in — new sites are created there (see below)
2580
2611
  uniweb register [--scope @scope] # Register a foundation + its data schemas to the registry
2581
- uniweb login / logout # One backend, one workspace at a time: log in (--backend, --org) or out
2612
+ uniweb login / logout # One backend, one workspace at a time: log in (--server, --org) or out
2582
2613
  uniweb org list / create <handle> # Your personal scope, and the orgs you belong to
2583
2614
  uniweb content export [dir] # Package a site (or a built foundation's schema) as .uwx
2584
2615
 
@@ -2655,31 +2686,46 @@ Foundations have their own free path too: `uniweb add ci --target foundation` pu
2655
2686
 
2656
2687
  **Nobody's work is overwritten without asking.** `uniweb push` is refused if the site changed on the backend since your last pull — usually an author editing in the app — and reports what changed; nothing is written. Edits to different sections never collide. `uniweb pull` overwrites rather than merges, so it refuses while you have uncommitted changes. The recovery for both is `uniweb pull --merge` (or `uniweb refresh`, which runs `git pull` first): changes to different parts of a file combine silently, and a genuine overlap leaves conflict markers and a non-zero exit — so `uniweb pull --merge && uniweb push` never ships markers. `--force` is the deliberate overwrite (on `push` it replaces the backend's changes, on `pull` it discards yours); don't use it to get past a refusal. `uniweb sync` is `refresh` then `push`; neither publishes.
2657
2688
 
2658
- **`sync.json` says which site this is, on each backend.** The first push to a backend records what that backend assigned — the site's id and owner, the ids of its records and uploaded files — in `sync.json` beside `site.yml`. Commit it; never edit it. **To make a new site from a copy of a project, run `uniweb forget --all` in the copy before its first push** — the copy carries the original's `sync.json`, so otherwise its push updates the original's site. When two projects in one workspace hold the same site, a push or publish from either is refused until that is done. `uniweb forget --backend <url>` removes just one backend's records, such as a scratch server's. A record file's `$uuid` is its own id and stays in both cases. **`push`, `pull` and `publish` go to the backend you are logged in to** — the last `uniweb login --backend <url>` — and never to one you are not logged in to. A bare `uniweb login` logs in to https://uniweb.app; for any other backend, name it — logging in is how you switch, and the backend commands take no `--backend` of their own. When the project has no site on that backend but has one elsewhere, a push says so before creating a new one.
2689
+ **`sync.json` says which site this is, on each backend.** The first push to a backend records what that backend assigned — the site's id and owner, the ids of its records and uploaded files — in `sync.json` beside `site.yml`. Commit it; never edit it. **To make a new site from a copy of a project, run `uniweb forget --all` in the copy before its first push** — the copy carries the original's `sync.json`, so otherwise its push updates the original's site. When two projects in one workspace hold the same site, a push or publish from either is refused until that is done. `uniweb forget --server <url>` removes just one backend's records, such as a scratch server's. A record file's `$uuid` is its own id and stays in both cases. **`push`, `pull` and `publish` go to the backend you are logged in to** — the last `uniweb login --server <url>` — and never to one you are not logged in to. A bare `uniweb login` logs in to https://uniweb.app; for any other backend, name it — logging in is how you switch, and the backend commands take no `--server` of their own. When the project has no site on that backend but has one elsewhere, a push says so before creating a new one.
2659
2690
 
2660
- **`services:` in `site.yml` asks your host for services** — site search, form handling, accounts:
2691
+ **`services:` in `site.yml` is where a site says which services it uses** — site search, form
2692
+ handling, analytics, an assistant, accounts — one entry per service:
2661
2693
 
2662
2694
  ```yaml
2663
2695
  services:
2664
- search: true # turn it on
2665
- submit: false # turn it off
2666
- api: # turn it on, with the service's own settings
2696
+ search: true # on — your host's, or the built-in index
2697
+ submit: false # off
2698
+ tracking: https://collector.example.com/events # a provider you bring
2699
+ backend: # on, with the service's own settings
2667
2700
  grade: pro
2668
2701
  ```
2669
2702
 
2670
- A service you leave out keeps whatever the site has, and settings you leave out keep theirs — to turn
2671
- one off, say `false`. `uniweb push` and `uniweb publish` send what you changed since your last sync;
2672
- if the site's services changed elsewhere in the meantime — an author in the app — they offer to update
2673
- `site.yml` rather than send your older choice over it, and if both changed the same service they ask
2674
- which to keep. `uniweb pull` writes what the site has into `services:`. A change that needs payment is
2675
- settled in the app: `publish` opens it. ⛔ **Not the same as `search:` / `submit:`**, which configure a
2676
- provider the site brings itself — and `services:` never reaches the built site.
2703
+ An entry is `true`, `false`, an address of your own (a string, or `endpoint:` in a map), or a map
2704
+ of options; anything else stops the build and the push. ⚠️ `yes`, `no`, `on` and `off` are text to
2705
+ YAML, not switches — write `true` or `false`. On a site you push or publish it is also **what you ask your host for**: `true` asks for
2706
+ its service, `false` asks it to turn its service off, and an address asks it to leave its own off so
2707
+ yours answers. **On a site your host publishes, a service is off unless `services:` asks for it** —
2708
+ list the ones the site uses (the templates list theirs). `records: true` asks for your records to be
2709
+ delivered live to the published pages: a query whose records have a data schema shows nothing there
2710
+ without it, and `publish` says so; a static build reads records from files and ignores it. An entry
2711
+ says everything about its service: a setting you remove from it is removed on the next push, and
2712
+ removing the entry switches the service off. `uniweb push` and `uniweb publish` send each service
2713
+ `site.yml` lists; a service the site has that this copy has never seen (turned on in the app since
2714
+ your last pull) is left as it is, and one changed both in the file and on the site since your last
2715
+ pull stops the push, naming it — `uniweb pull --merge`, then push. `uniweb pull` writes what the site
2716
+ has into `services:` (an off service with no settings only where the file already names it) — pull
2717
+ before you edit. A change that needs payment is settled in the app: `publish` opens it. **Everything in an entry but a
2718
+ credential is public** — it is built into the site, except `backend`'s settings, which only your host
2719
+ reads; a key or token is set in the app. ⛔ The top-level `search:` / `submit:` / `assistant:` /
2720
+ `tracking:` / `api:` keys are retired: the build stops on them and says where each one moves. So
2721
+ does `api` under `services:` — the site's own backend is the `backend` service — and `$devApi:`,
2722
+ which is `$devBackend:`.
2677
2723
 
2678
2724
  **The Cloud also provides a real backend for structured data:** a database for every registered data schema, and a CMS that edits both static page content and dynamic data entities typed by those schemas. That's the piece that makes it viable for teams and client work — the client manages records, not markdown files.
2679
2725
 
2680
2726
  Either side can publish. Nothing about this changes how you build: the same foundation and the same site run under `uniweb dev`, `uniweb export`, or a CI deploy with no account at all.
2681
2727
 
2682
- **Publishing vs registering.** Foundations on Uniweb Cloud live in the catalog as `@org/name@version` — `name` from the foundation's `main.js`. When a foundation powers a single site, **don't run `uniweb register` yourself** — `uniweb publish` from the site directory releases the local foundation to the catalog (when its code changed) and goes live in one step. A registered version is immutable, so when the foundation's code changed under a version already registered, `push` and `publish` release the change under the next version and write it into the foundation's `package.json` — commit that file (`--no-release` sends the content without the code). The version it picks is always the next patch: for a change that is not backwards compatible, set a higher minor or major version in the foundation's `package.json` yourself before you push. They stop when the registry holds a version newer than yours, released from another copy: pull that change first, or pass `--bump` to release yours above it. Register deliberately only when the foundation is a product meant for multiple sites; consuming sites then pin `foundation: '@org/name@1.2.3'`. **The catalog is private and access-segregated, not a public package registry** — people see only the foundations licensed to sites they own or edit. The *site* carries the license, and it rides along with site ownership when a developer hands a site to a client. Don't describe publishing as making a foundation publicly discoverable. Schemas can also be registered on their own from a schemas-only package (`@uniweb/schemas`, any `@org/schemas`, or a bare folder of `schemas/*.{yml,json,js}`) — that's how `@std` schemas are published. Auth via `uniweb login` (`uniweb login --backend <url> --token <bearer>` without a terminal) or `UNIWEB_TOKEN`; preview with `--dry-run`.
2728
+ **Publishing vs registering.** Foundations on Uniweb Cloud live in the catalog as `@org/name@version` — `name` from the foundation's `main.js`. When a foundation powers a single site, **don't run `uniweb register` yourself** — `uniweb publish` from the site directory releases the local foundation to the catalog (when its code changed) and goes live in one step. A registered version is immutable, so when the foundation's code changed under a version already registered, `push` and `publish` release the change under the next version and write it into the foundation's `package.json` — commit that file (`--no-release` sends the content without the code). The version it picks is always the next patch: for a change that is not backwards compatible, set a higher minor or major version in the foundation's `package.json` yourself before you push. They stop when the registry holds a version newer than yours, released from another copy: pull that change first, or pass `--bump` to release yours above it. Register deliberately only when the foundation is a product meant for multiple sites; consuming sites then pin `foundation: '@org/name@1.2.3'`. **The catalog is private and access-segregated, not a public package registry** — people see only the foundations licensed to sites they own or edit. The *site* carries the license, and it rides along with site ownership when a developer hands a site to a client. Don't describe publishing as making a foundation publicly discoverable. Schemas can also be registered on their own from a schemas-only package (`@uniweb/schemas`, any `@org/schemas`, or a bare folder of `schemas/*.{yml,json,js}`) — that's how `@std` schemas are published. Auth via `uniweb login` (`uniweb login --server <url> --token <bearer>` without a terminal) or `UNIWEB_TOKEN`; preview with `--dry-run`.
2683
2729
 
2684
2730
  ### Staying current
2685
2731
 
@@ -7,7 +7,7 @@
7
7
  * `fetch(… Authorization: Bearer …)` against a per-command default origin. It
8
8
  * owns the three things that were previously scattered across a dozen files:
9
9
  *
10
- * 1. ORIGIN — where the backend is. resolveBackendOrigin(): UNIWEB_REGISTER_URL >
10
+ * 1. ORIGIN — where the backend is. resolveBackendOrigin(): UNIWEB_SERVER >
11
11
  * the backend you are logged in to > the default backend (below).
12
12
  * Each is reduced to its origin, http(s) only. *(This named a
13
13
  * `--backend` flag as the first tier until 2026-10-05; the verbs
@@ -46,7 +46,7 @@ import { uploadSiteAssets } from '../utils/asset-upload.js'
46
46
  /**
47
47
  * Resolve the backend a command talks to:
48
48
  *
49
- * 1. UNIWEB_REGISTER_URL env — the override for automation (CI, scripts), one process
49
+ * 1. UNIWEB_SERVER env — the override for automation (CI, scripts), one process
50
50
  * 2. ⭐ the backend the user is LOGGED IN TO — the one session there is
51
51
  * 3. the default backend — ~/.uniweb/config.json `registryApiUrl`, else uniweb.app —
52
52
  * where the command's first request then asks the user to log in
@@ -60,9 +60,10 @@ import { uploadSiteAssets } from '../utils/asset-upload.js'
60
60
  * ⛔ **No `--backend` tier, and no project tier.** The backend verbs had a per-command
61
61
  * `--backend` until 2026-09-21 — it predates per-backend sessions, when one session
62
62
  * slot made "aim this one command elsewhere" a flag's job; switching is a login now, and
63
- * a script aims with UNIWEB_REGISTER_URL without touching the machine's login. A
63
+ * a script aims with UNIWEB_SERVER without touching the machine's login. A
64
64
  * project's sync.json and deploy.yml routed commands too, until the same day.
65
- * `--backend` survives only where it SELECTS rather than routes: `login` (where to log
65
+ * A backend flag survives only where it SELECTS rather than routes — `--server`, `--backend`
66
+ * until 2026-10-07: `login` (where to log
66
67
  * in) and `forget` (which backend's records to remove). `logout` needs none: there is
67
68
  * one session.
68
69
  *
@@ -227,6 +228,27 @@ export class WorkspaceMismatchError extends Error {
227
228
  }
228
229
  }
229
230
 
231
+ /**
232
+ * The backend could not be reached at all — the request never got an answer.
233
+ *
234
+ * ⛔ Not an answer, so it must not be read as one. A lookup that folds this into "not
235
+ * found" sends its caller down the not-found path: `readFoundationLatest` did, and a
236
+ * publish with the backend down announced "Releasing the foundation (not yet
237
+ * registered)…", failed inside `register`, and ended on "Fix the foundation".
238
+ */
239
+ export class BackendUnreachableError extends Error {
240
+ /**
241
+ * @param {string} origin
242
+ * @param {unknown} cause - the transport error `fetch` threw
243
+ */
244
+ constructor(origin, cause) {
245
+ super(`Could not reach the backend at ${origin}: ${cause?.message ?? cause}`)
246
+ this.name = 'BackendUnreachableError'
247
+ this.origin = origin
248
+ this.cause = cause
249
+ }
250
+ }
251
+
230
252
  /**
231
253
  * The line to print when a request THREW rather than answered.
232
254
  *
@@ -562,7 +584,9 @@ export class BackendClient {
562
584
 
563
585
  /**
564
586
  * GET /dev/registry/{scope}/{name} → the latest registered foundation version
565
- * + its content digest, or null on 404 / any failure (callers degrade).
587
+ * + its content digest, or null when the backend answers with anything else (callers
588
+ * degrade). ⛔ A backend that cannot be reached is not an answer: that throws
589
+ * `BackendUnreachableError` rather than reading as "not registered".
566
590
  *
567
591
  * The bare `{scope}/{name}` path resolves the latest foundation version (the
568
592
  * data-schema sibling is `/dev/registry/data-schemas/{scope}/{name}`). The
@@ -589,7 +613,11 @@ export class BackendClient {
589
613
  ...body,
590
614
  latest_version: body.latest_version ?? body.version ?? null
591
615
  }
592
- } catch {
616
+ } catch (err) {
617
+ // Node's `fetch` throws `TypeError: fetch failed` when nothing answered.
618
+ if (err instanceof TypeError && /fetch failed/i.test(err.message)) {
619
+ throw new BackendUnreachableError(this.origin, err)
620
+ }
593
621
  return null
594
622
  }
595
623
  }
@@ -785,6 +813,31 @@ export class BackendClient {
785
813
  })
786
814
  }
787
815
 
816
+ /**
817
+ * GET /dev/site — one page of the sites in the workspace the client names
818
+ * (`setWorkspace`; none ⇒ personal): `{ sites: [{ uuid, name?, updated_at,
819
+ * deployment?: { status, published_url, … } }] }`. A page shorter than `limit` is
820
+ * the last. `deployment` is absent for a site never published.
821
+ *
822
+ * @param {{ limit?: number, offset?: number }} [opts]
823
+ * @returns {Promise<Response>}
824
+ */
825
+ async listSites({ limit = 100, offset = 0 } = {}) {
826
+ return this.request('/dev/site', { query: { limit, offset } })
827
+ }
828
+
829
+ /**
830
+ * DELETE /dev/site/{uuid} — delete a site, finally: no trash, no restore. Only an
831
+ * admin of the site may. Refused `409` with `blockers: [{ resource, detail }]` while
832
+ * anything is still active — `published` among them, which `unpublishSite` clears.
833
+ *
834
+ * @param {string} uuid - the site-content uuid
835
+ * @returns {Promise<Response>}
836
+ */
837
+ async deleteSite(uuid) {
838
+ return this.request(`/dev/site/${encodeURIComponent(uuid)}`, { method: 'DELETE' })
839
+ }
840
+
788
841
  /**
789
842
  * GET /dev/site/status/{uuid} → the site's publish lifecycle (Contract 3,
790
843
  * shipped backend-side — collab backend↔framework):
@@ -197,7 +197,7 @@ function writePkgVersion(dir, version) {
197
197
  // and the WORKSPACE this command names (`--org` / `--personal`). The BACKEND travels in
198
198
  // the child's environment (releaseFoundation), and the SESSION is the shared session
199
199
  // file — or UNIWEB_TOKEN, which the child inherits. The backend commands take no
200
- // `--backend` or `--token`.
200
+ // backend flag (`--server` is the login's) and no `--token`.
201
201
  //
202
202
  // ⭐ The workspace travels because a bare name registers under the workspace the command
203
203
  // works in *[Diego, 2026-10-06]*, and a workspace named on this command is this command's
@@ -577,7 +577,9 @@ function buildFoundation(local, cliBin) {
577
577
  execFileSync('node', [cliBin, 'build', '--target', 'foundation'], {
578
578
  cwd: local.dir,
579
579
  stdio: 'inherit',
580
- env: process.env
580
+ // A step of this command, so the build skips the next-step hints meant for
581
+ // someone who ran `uniweb build` themselves.
582
+ env: { ...process.env, UNIWEB_BUILD_STEP: '1' }
581
583
  })
582
584
  }
583
585
 
@@ -592,7 +594,7 @@ function releaseFoundation(local, args, cliBin, say, origin) {
592
594
  execFileSync('node', [cliBin, 'register', ...forwardedFlags(args)], {
593
595
  cwd: local.dir,
594
596
  stdio: 'inherit',
595
- env: origin ? { ...process.env, UNIWEB_REGISTER_URL: origin } : process.env
597
+ env: origin ? { ...process.env, UNIWEB_SERVER: origin } : process.env
596
598
  })
597
599
  console.log('')
598
600
  return true