uniweb 0.85.0 → 0.87.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.85.0",
3
+ "version": "0.87.0",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,17 +41,17 @@
41
41
  "js-yaml": "^4.1.0",
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.0.0",
44
- "@uniweb/content-writer": "^0.3.4",
45
- "@uniweb/runtime": "^0.29.2",
46
- "@uniweb/kit": "^0.19.18",
47
- "@uniweb/schemas": "^0.12.0",
48
- "@uniweb/core": "^0.37.2",
49
- "@uniweb/semantic-parser": "^1.5.1"
44
+ "@uniweb/kit": "^0.20.0",
45
+ "@uniweb/runtime": "^0.29.4",
46
+ "@uniweb/core": "^0.38.0",
47
+ "@uniweb/semantic-parser": "^1.5.1",
48
+ "@uniweb/schemas": "^0.13.0",
49
+ "@uniweb/content-writer": "^0.3.4"
50
50
  },
51
51
  "peerDependencies": {
52
- "@uniweb/content-reader": "^1.2.8",
53
- "@uniweb/build": "^0.78.0",
54
- "@uniweb/semantic-parser": "^1.5.1"
52
+ "@uniweb/build": "^0.80.0",
53
+ "@uniweb/semantic-parser": "^1.5.1",
54
+ "@uniweb/content-reader": "^1.2.8"
55
55
  },
56
56
  "peerDependenciesMeta": {
57
57
  "@uniweb/build": {
@@ -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
 
@@ -1740,7 +1740,7 @@ source: education
1740
1740
 
1741
1741
  ### Custom layouts
1742
1742
 
1743
- Layouts live in `layouts/` inside the foundation and are auto-discovered. Set `defaultLayout` in `main.js`.
1743
+ Layouts live in `layouts/` inside the foundation and are auto-discovered. Set `defaultLayout` in `main.js`, or name a layout `Default`. With neither, a page that names no layout gets the built-in layout — `header`, the page, `footer`, nothing else — and a `defaultLayout` that names none of your layouts stops the build.
1744
1744
 
1745
1745
  ```jsx
1746
1746
  // layouts/DocsLayout/index.jsx
@@ -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
 
@@ -2026,7 +2039,7 @@ resolves by the same rule — the host's offer, then the site's own address, the
2026
2039
  neither.
2027
2040
 
2028
2041
  ⭐ **Before you render UI for a service, ask whether the site has it** — one predicate per service,
2029
- no arguments: `isSearchEnabled()`, `isSubmitEnabled()`, `isApiEnabled()`, `isAssistantEnabled()`,
2042
+ no arguments: `isSearchEnabled()`, `isSubmitEnabled()`, `isBackendEnabled()`, `isAssistantEnabled()`,
2030
2043
  `isTrackingEnabled()`.
2031
2044
 
2032
2045
  ```jsx
@@ -2457,7 +2470,7 @@ For cases the factory doesn't cover, write handlers directly using `Loom`, `inst
2457
2470
  ## Part 4b — When the site is also an app
2458
2471
 
2459
2472
  Everything above is a site: content the author writes, built into pages. Some sites
2460
- 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
2461
2474
  create and edit. `@uniweb/api` is its client.
2462
2475
 
2463
2476
  ⛔ **Only reach for this when the site actually has one.** A site without one is
@@ -2471,13 +2484,13 @@ npm install @uniweb/api # in the FOUNDATION, beside @uniweb/kit
2471
2484
  ### Ask before you draw
2472
2485
 
2473
2486
  ```jsx
2474
- import { isApiEnabled } from '@uniweb/kit'
2487
+ import { isBackendEnabled } from '@uniweb/kit'
2475
2488
  import { useSession, SignedIn, SignedOut } from '@uniweb/api'
2476
2489
 
2477
- if (!isApiEnabled()) return <StaticVersion /> // synchronous — nothing to await
2490
+ if (!isBackendEnabled()) return <StaticVersion /> // synchronous — nothing to await
2478
2491
  ```
2479
2492
 
2480
- ⛔ **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
2481
2494
  explanation. Same rule as `services` in Part 4: which capabilities a site's operator
2482
2495
  set up is none of a visitor's business, and "sign-in unavailable" reads as breakage
2483
2496
  when it is simply a feature this site does not have. Render the version of your
@@ -2499,7 +2512,7 @@ remove it. `scope: 'mine'` lists only the viewer's own records. Without it, the
2499
2512
  also gets everyone else's.
2500
2513
 
2501
2514
  ⭐ **`absent` and an empty `ready` are different answers, and confusing them is the
2502
- 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
2503
2516
  in) → render the site's authored content. `ready` with `records: []` = the service
2504
2517
  answered and there is nothing there → render your empty state. Showing "nothing yet"
2505
2518
  for the first tells a visitor their content is gone when it was never requested.
@@ -2556,8 +2569,8 @@ You do not need a live backend to build against one. In `site.yml`:
2556
2569
 
2557
2570
  ```yaml
2558
2571
  services:
2559
- api: true # ask your host for an app backend when you publish
2560
- $devApi: ./mock/api.js # what answers it in `uniweb dev`; `$` keys are never published
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
2561
2574
  ```
2562
2575
 
2563
2576
  ```js
@@ -2566,7 +2579,7 @@ import { createMockBackend } from '@uniweb/api/mock'
2566
2579
  export default createMockBackend({ seed }).fetch
2567
2580
  ```
2568
2581
 
2569
- `uniweb dev` answers your site's `api` service with it, at an address the dev server
2582
+ `uniweb dev` answers your site's `backend` service with it, at an address the dev server
2570
2583
  supplies on its own origin, so cookies behave exactly as they will in production. Write
2571
2584
  no address of your own for it: under `services:` an address means a backend you run, and
2572
2585
  asks your host to leave its own off. It **enforces** who
@@ -2596,7 +2609,7 @@ uniweb push / pull / clone / status # Git-style content sync with the Uniweb b
2596
2609
  uniweb refresh / sync # Catch up (git + backend, never pushes) / catch up, then push
2597
2610
  uniweb login --org @acme # The workspace you work in — new sites are created there (see below)
2598
2611
  uniweb register [--scope @scope] # Register a foundation + its data schemas to the registry
2599
- 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
2600
2613
  uniweb org list / create <handle> # Your personal scope, and the orgs you belong to
2601
2614
  uniweb content export [dir] # Package a site (or a built foundation's schema) as .uwx
2602
2615
 
@@ -2673,7 +2686,7 @@ Foundations have their own free path too: `uniweb add ci --target foundation` pu
2673
2686
 
2674
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.
2675
2688
 
2676
- **`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.
2677
2690
 
2678
2691
  **`services:` in `site.yml` is where a site says which services it uses** — site search, form
2679
2692
  handling, analytics, an assistant, accounts — one entry per service:
@@ -2683,12 +2696,13 @@ services:
2683
2696
  search: true # on — your host's, or the built-in index
2684
2697
  submit: false # off
2685
2698
  tracking: https://collector.example.com/events # a provider you bring
2686
- api: # on, with the service's own settings
2699
+ backend: # on, with the service's own settings
2687
2700
  grade: pro
2688
2701
  ```
2689
2702
 
2690
2703
  An entry is `true`, `false`, an address of your own (a string, or `endpoint:` in a map), or a map
2691
- of options. On a site you push or publish it is also **what you ask your host for**: `true` asks for
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
2692
2706
  its service, `false` asks it to turn its service off, and an address asks it to leave its own off so
2693
2707
  yours answers. **On a site your host publishes, a service is off unless `services:` asks for it** —
2694
2708
  list the ones the site uses (the templates list theirs). `records: true` asks for your records to be
@@ -2701,15 +2715,17 @@ your last pull) is left as it is, and one changed both in the file and on the si
2701
2715
  pull stops the push, naming it — `uniweb pull --merge`, then push. `uniweb pull` writes what the site
2702
2716
  has into `services:` (an off service with no settings only where the file already names it) — pull
2703
2717
  before you edit. A change that needs payment is settled in the app: `publish` opens it. **Everything in an entry but a
2704
- credential is public** — it is built into the site, except `api`'s settings, which only your host
2718
+ credential is public** — it is built into the site, except `backend`'s settings, which only your host
2705
2719
  reads; a key or token is set in the app. ⛔ The top-level `search:` / `submit:` / `assistant:` /
2706
- `tracking:` / `api:` keys are retired: the build stops on them and says where each one moves.
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:`.
2707
2723
 
2708
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.
2709
2725
 
2710
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.
2711
2727
 
2712
- **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`.
2713
2729
 
2714
2730
  ### Staying current
2715
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
@@ -28,7 +28,7 @@
28
28
  import { createHash } from 'node:crypto'
29
29
  import { existsSync, readFileSync } from 'node:fs'
30
30
  import { join } from 'node:path'
31
- import { readServicesRequest } from '@uniweb/build/uwx'
31
+ import { readServicesRequest, unreadableServices } from '@uniweb/build/uwx'
32
32
 
33
33
  /**
34
34
  * A stable fingerprint of one declared value, or `null` when the key is absent.
@@ -148,7 +148,7 @@ const names = (list) => list.map((n) => `\`${n}\``).join(', ')
148
148
 
149
149
  /**
150
150
  * Say what `site.yml::services` asks that will not be sent as written — a credential,
151
- * an entry that is not one, an `api` address — and where it and the foundation disagree,
151
+ * an entry that is not one, a `backend` address — and where it and the foundation disagree,
152
152
  * before a push or publish sends the rest. What a push sends is the producer's
153
153
  * (`statedServices`), from the same file.
154
154
  *
@@ -165,6 +165,10 @@ const names = (list) => list.map((n) => `\`${n}\``).join(', ')
165
165
  * @param {string[]|null} [p.supports] - from `foundationSupports`; null = unknown
166
166
  */
167
167
  export function announceServices({ siteYml, say, supports = null }) {
168
+ // An entry the push cannot read stops the package build next, which says what to write
169
+ // (`refuseUnreadableServices`). Nothing to add before it — and what this would say, read
170
+ // past that entry, is wrong: `search: yes` was "site.yml turns on `search`" (F14).
171
+ if (unreadableServices(siteYml?.services).length) return
168
172
  const asks = readServicesRequest(siteYml?.services, { warn: (m) => say.warn(m) }) || []
169
173
  if (!Array.isArray(supports)) return
170
174
  const ignored = new Set(['tracking', 'records'])
@@ -46,7 +46,7 @@ export async function readSitePreview({ siteDir, args = [], client = null }) {
46
46
  refused: [
47
47
  `This site's foundation is ${ref}, and the backend the site is on says where that version is served.`,
48
48
  known.length
49
- ? `The site is on ${known.join(', ')} — not on ${client.origin}, the backend you are logged in to. To preview it: uniweb login --backend <url>`
49
+ ? `The site is on ${known.join(', ')} — not on ${client.origin}, the backend you are logged in to. To preview it: uniweb login --server <url>`
50
50
  : `The site is on no backend yet: \`uniweb push\` puts it on ${client.origin}.`
51
51
  ]
52
52
  }
@@ -12,7 +12,7 @@
12
12
  */
13
13
 
14
14
  import { writeFileSync, readFileSync, mkdirSync, existsSync, unlinkSync } from 'node:fs'
15
- import { join, dirname, relative, isAbsolute } from 'node:path'
15
+ import { join, dirname, relative, isAbsolute, basename } from 'node:path'
16
16
  import yaml from 'js-yaml'
17
17
  import { hasUncommittedContent } from '../utils/git.js'
18
18
  import { recordWritten } from '../utils/pull-written.js'
@@ -44,7 +44,7 @@ import {
44
44
  updateBackendMap,
45
45
  clearBackendSections,
46
46
  normalizeBackendOrigin,
47
- removeYamlScalar,
47
+ writeSiteConfig,
48
48
  harvestRecordItems,
49
49
  storedRecordItems,
50
50
  reprintRecordItems,
@@ -54,7 +54,8 @@ import {
54
54
  writeRegisteredFoundation,
55
55
  backfillLinkUuids,
56
56
  LINK_MODEL,
57
- bankedFileUuids
57
+ bankedFileUuids,
58
+ existingSectionFile
58
59
  } from '@uniweb/build/uwx'
59
60
 
60
61
  // First entity `$`-document out of a `.uwx` we produced or the backend served.
@@ -295,7 +296,15 @@ export function pulledItemVersions(doc, itemVersions) {
295
296
  // The files a unit projects to, under the site's own roots — `site.yml::paths` can
296
297
  // relocate `pages` and `layout`. `site.yml` stands for the `info` unit, which projects
297
298
  // to three files.
298
- function unitFilesOf(siteDir, unitPath) {
299
+ //
300
+ // ⭐ A section unit's path names it by its id (`pages/about/about.md`), and the author's
301
+ // file may carry an ordering prefix or a child mark (`1-about.md`, `@about.md`), which a
302
+ // pull writes back into in place. So the unit is looked up as a pull finds it
303
+ // (`existingSectionFile`). ⛔ Until 2026-10-07 the id path was taken as the file: a push
304
+ // recorded no file for a numbered section, and the next `pull --merge` merged against an
305
+ // older version — a false conflict on a line only the other side had changed (measured
306
+ // on the starter's `1-welcome.md`).
307
+ export function unitFilesOf(siteDir, unitPath) {
299
308
  if (unitPath === 'site.yml') return ['site.yml', 'theme.yml', 'head.html']
300
309
  let paths = {}
301
310
  try {
@@ -304,7 +313,12 @@ function unitFilesOf(siteDir, unitPath) {
304
313
  /* no or unreadable site.yml — the defaults are right */
305
314
  }
306
315
  for (const root of ['pages', 'layout']) {
307
- if (unitPath.startsWith(`${root}/`)) return [`${paths[root] || root}${unitPath.slice(root.length)}`]
316
+ if (!unitPath.startsWith(`${root}/`)) continue
317
+ const rel = `${paths[root] || root}${unitPath.slice(root.length)}`
318
+ if (!rel.endsWith('.md') || existsSync(join(siteDir, rel))) return [rel]
319
+ const id = basename(rel, '.md')
320
+ const found = existingSectionFile(join(siteDir, dirname(rel)), id, id, { child: true })
321
+ return [found ? relative(siteDir, found) : rel]
308
322
  }
309
323
  return []
310
324
  }
@@ -571,7 +585,7 @@ const readMap = (siteDir, backend, key) => {
571
585
  * not change** — a partial site, published successfully, with nothing to
572
586
  * indicate it.
573
587
  *
574
- * The guidance is `uniweb forget --backend <url>` now, which removes the uuid and the
588
+ * The guidance is `uniweb forget --server <url>` now, which removes the uuid and the
575
589
  * maps together, so following it no longer leads here.
576
590
  *
577
591
  * Call this BEFORE `ensureSiteExists`, which mints a uuid and would otherwise make
@@ -718,7 +732,7 @@ export function dropSiteBoundValues(siteDir, backend) {
718
732
  if (
719
733
  y.preview !== undefined &&
720
734
  !isAuthoredPreview(y.preview) &&
721
- removeYamlScalar(file, 'preview')
735
+ writeSiteConfig(siteDir, { preview: null }) === 'updated'
722
736
  ) {
723
737
  dropped.push('preview')
724
738
  }
@@ -1902,7 +1916,7 @@ export async function pushSyncPackages({
1902
1916
  } catch (err) {
1903
1917
  error(describeRequestError(err, client.origin))
1904
1918
  if (!(err instanceof WorkspaceMismatchError))
1905
- note('Is that the backend you meant? Switch with: uniweb login --backend <url>')
1919
+ note('Is that the backend you meant? Switch with: uniweb login --server <url>')
1906
1920
  return null
1907
1921
  }
1908
1922
  if (!res.ok) {
@@ -2067,7 +2081,7 @@ export async function pushSyncPackages({
2067
2081
  error(`${label} push rejected: HTTP ${res.status} ${res.statusText}`)
2068
2082
  if (res.status === 401 || res.status === 403) {
2069
2083
  note(
2070
- "Credentials weren't accepted — log in again (`uniweb login --backend <url>`), or check UNIWEB_TOKEN."
2084
+ "Credentials weren't accepted — log in again (`uniweb login --server <url>`), or check UNIWEB_TOKEN."
2071
2085
  )
2072
2086
  } else if (res.status === 404 && boundUuid) {
2073
2087
  // The clone is bound to a site the backend does not have. There is no CLI
@@ -2097,10 +2111,10 @@ export async function pushSyncPackages({
2097
2111
  'Two causes. Check the cheap one first: is this the backend the site lives on?'
2098
2112
  )
2099
2113
  note(
2100
- ` wrong backend → uniweb login --backend <the right one> (nothing is lost)`
2114
+ ` wrong backend → uniweb login --server <the right one> (nothing is lost)`
2101
2115
  )
2102
2116
  note(
2103
- ` deleted there → uniweb forget --backend ${client.origin}, then push again: it creates a NEW site`
2117
+ ` deleted there → uniweb forget --server ${client.origin}, then push again: it creates a NEW site`
2104
2118
  )
2105
2119
  note('Deleting this folder removes only your local copy, either way.')
2106
2120
  } else if (problem?.reason === 'template_records_not_copyable' && Array.isArray(problem.records)) {
@@ -2467,7 +2481,10 @@ export async function pushSyncPackages({
2467
2481
  // What the backend holds that this copy doesn't. None of it was overwritten, and no
2468
2482
  // later push will overwrite it — but the files are behind until a pull.
2469
2483
  if (notHeld.kept.length || notHeld.foreign.length) {
2470
- const paths = [...new Set([...notHeld.kept, ...notHeld.foreign])].sort()
2484
+ // Named by the file it is in here, not by its id (`unitFilesOf`).
2485
+ const paths = [
2486
+ ...new Set([...notHeld.kept, ...notHeld.foreign].map((p) => (p === 'site.yml' ? p : unitFilesOf(siteDir, p)[0] || p)))
2487
+ ].sort()
2471
2488
  note(`Changed on the backend by someone else, not in your files yet: ${paths.join(', ')}`)
2472
2489
  note('Take them with `uniweb refresh` (or `uniweb pull --merge`). Until then, pushing leaves them as they are.')
2473
2490
  }
@@ -138,10 +138,11 @@ function detectProjectType(projectDir) {
138
138
  */
139
139
  function runCommand(command, args, cwd) {
140
140
  return new Promise((resolve, reject) => {
141
+ // No shell: the command is node and a script path, which needs none — and a shell
142
+ // splits a path with a space in it. (Node warns DEP0190 on args passed with one.)
141
143
  const proc = spawn(command, args, {
142
144
  cwd,
143
- stdio: 'inherit',
144
- shell: true
145
+ stdio: 'inherit'
145
146
  })
146
147
 
147
148
  proc.on('close', (code) => {
@@ -246,6 +247,9 @@ async function buildFoundation(projectDir, options = {}) {
246
247
  log('')
247
248
  log(`${colors.green}${colors.bright}Build complete!${colors.reset}`)
248
249
 
250
+ // The next step is for someone who ran `uniweb build` — not for a push or publish
251
+ // that builds the foundation as one of its own steps (UNIWEB_BUILD_STEP).
252
+ if (process.env.UNIWEB_BUILD_STEP) return
249
253
  log('')
250
254
  log(`${colors.bright}Share with clients:${colors.reset}`)
251
255
  log(
@@ -44,7 +44,7 @@
44
44
  * another workspace stops it.
45
45
  *
46
46
  * Backend: via BackendClient (the site-content pull lane). Origin from
47
- * UNIWEB_REGISTER_URL > the local default (internal dev overrides;
47
+ * UNIWEB_SERVER > the local default (internal dev overrides;
48
48
  * not the user-facing path — `uniweb login` determines the origin).
49
49
  * Auth: UNIWEB_TOKEN > the stored session > `uniweb login`. No `--token` (retired
50
50
  * from the backend commands 2026-09-21).