uniweb 0.70.1 → 0.71.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.70.1",
3
+ "version": "0.71.0",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,15 +41,15 @@
41
41
  "js-yaml": "^4.1.0",
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.0.0",
44
- "@uniweb/core": "^0.30.1",
45
- "@uniweb/runtime": "^0.27.6",
46
- "@uniweb/kit": "^0.19.5",
47
- "@uniweb/schemas": "^0.7.1",
48
- "@uniweb/semantic-parser": "^1.4.1",
49
- "@uniweb/content-writer": "^0.3.4"
44
+ "@uniweb/core": "^0.31.0",
45
+ "@uniweb/runtime": "^0.27.7",
46
+ "@uniweb/schemas": "^0.8.0",
47
+ "@uniweb/content-writer": "^0.3.4",
48
+ "@uniweb/kit": "^0.19.6",
49
+ "@uniweb/semantic-parser": "^1.4.1"
50
50
  },
51
51
  "peerDependencies": {
52
- "@uniweb/build": "^0.64.1",
52
+ "@uniweb/build": "^0.65.0",
53
53
  "@uniweb/content-reader": "^1.2.5",
54
54
  "@uniweb/semantic-parser": "^1.4.1"
55
55
  },
@@ -72,7 +72,7 @@ Then **follow the link the index gives you** rather than constructing one yourse
72
72
  git clone --depth 1 https://github.com/uniweb/docs .uniweb-docs # then gitignore it
73
73
  ```
74
74
 
75
- Same content, greppable, and one network operation instead of many. `grep -ri "deferred" .uniweb-docs` beats any number of fetches while you're still exploring.
75
+ Same content, greppable, and one network operation instead of many. `grep -ri "queryable" .uniweb-docs` beats any number of fetches while you're still exploring.
76
76
 
77
77
  Documentation paths in this guide are given bare — `development/creating-components.md` — so they resolve three ways: as a key in the index, as a file in a clone, or at `https://raw.githubusercontent.com/uniweb/docs/main/{path}`.
78
78
 
@@ -873,30 +873,30 @@ Keep those folders flat: `records/article/design-tips.md` works; `records/articl
873
873
  | `.md` | Items with prose — articles, case studies | Frontmatter holds fields, body holds text; auto-excerpt and first-image extraction |
874
874
  | `.yml` / `.yaml` | Structured records with no prose body — team, products | Cleaner than a markdown file with an empty body |
875
875
  | `.json` | Same as YAML | When the data comes out of another tool |
876
- | `.bib` | Bibliographies | Each `@entry{key, …}` is a record, cite key is the slug; normalized to CSL-JSON, LaTeX accents converted to Unicode |
876
+ | `.bib` | Bibliographies | Each `@entry{key, …}` is a record, its cite key its name (`$name`); normalized to CSL-JSON, LaTeX accents converted to Unicode |
877
877
 
878
- **One record per file, or many.** A single mapping at the top of a file makes one record and the filename stem becomes its `slug`. A top-level array (YAML/JSON) or a multi-entry `.bib` makes many, each carrying its own `slug`. You can mix both in one folder — an exported `refs.bib` beside a hand-written `extras.yml`.
878
+ **One record per file.** A file's name is its record's name (`records/team/alice.yml` is `alice`); a `slug:` key at its top, or a file holding a list, stops the build — to rename a record, rename its file. A `.bib` file holds many entries, each named by its cite key. **A component reads a record's name as `$name`** — the one name it carries on every site; the delivered record has no `slug`. You can mix both in one folder — an exported `refs.bib` beside a hand-written `extras.yml`.
879
879
 
880
880
  Item frontmatter conventionally uses `title`, `date`, `tags`, `image`, `description`, `author` — plus any fields your content needs (`price`, `role`, `order`). Images can sit beside the item file and be referenced with `./`. **`draft: true` keeps a record off the live site without deleting it**: it is still a record, in `records/`, and `pnpm dev` shows it so you can preview it, but a build for hosting leaves it out, and a backend you `uniweb push` it to keeps it in the site's folder without delivering it. (`published: false`, an older spelling, is refused.)
881
881
 
882
- **A record's file follows its data schema's sections.** A schema with one section (the `fields:` form) is written flat, as above. A schema with more than one is written **by section** — each field under its section's name — and a field left at the top stops the build and a push, naming where it goes. `@std/article` has two, its card `article` and `article_body`:
882
+ **A record's file follows its data schema's sections.** A schema with one section (the `fields:` form) is written flat, as above. A schema with more than one is written **by section** — each field under its section's name — and a field left at the top stops the build and a push, naming where it goes. `@std/article` has two, its card `brief` and `body`:
883
883
 
884
884
  ```markdown
885
885
  <!-- records/std/article/hello.md -->
886
886
  ---
887
- article:
887
+ brief:
888
888
  title: Hello
889
889
  date: 2026-05-01
890
- article_body:
890
+ body:
891
891
  author: Ada
892
892
  ---
893
893
 
894
- The body — the value of the schema's content field, `article_body.content`.
894
+ The body — the value of the schema's content field, `body.content`.
895
895
  ```
896
896
 
897
- **A component receives it differently from how the file writes it**, the same way on a static site and a hosted one: the brief's fields at the top (`article.title`), every other section under its name (`article.article_body.content`, `article.article_body.author`), and `$name`, the handle. Lists carry the brief alone unless the query says otherwise (`deferred:`, below). Full rules: `reference/entity-content.md`.
897
+ **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`.
898
898
 
899
- **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, or its `slug:`) — 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.
899
+ **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.
900
900
 
901
901
  **`records/folder.yml` — optional, and only for folders.** It is the one file in `records/` that is not a record, it moves with the directory (`paths.records`), and like a pages folder's `folder.yml` it describes the folder it sits in. Every record sits at the top of the site's records folder unless `folder.yml` places it in a sub-folder. Add one only when a query needs to ask for a *slice* of the records rather than all of them — most sites never do:
902
902
 
@@ -957,15 +957,15 @@ pages/blog/
957
957
 
958
958
  **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.
959
959
 
960
- **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 handle (`$name`, which compiled records carry — equal to their `slug`), `[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]/`.
960
+ **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]/`.
961
961
 
962
- > **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. The runtime never coerces it to an object. See *Data* in Part 4.
962
+ > **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.
963
963
 
964
964
  **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 URL is its folder placement plus its slug (`- folder: rust/2025` in `records/folder.yml` → `/blog/rust/2025/my-post`). A query may bind a part — `scope: :dir` exposes the folder branch, `where: { tag: :dir }` keeps it private — and an unbound 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`.
965
965
 
966
966
  **Two options for bigger sets:**
967
967
 
968
- `deferred: [body]` strips heavy fields from the list payload — cards stay light, while a `[slug]` page still receives the full record automatically and other components fetch the whole record on demand with `useWholeRecord`. It names keys of the record as a component receives it — a field, or a section's name — and a schema with more than one section implies it: every section but the brief (`[article_body]` for `@std/article`), so it is rarely written. File-based records emit per-record files at `/data/<name>/<slug>.json` and need no configuration; an external query names its one-record request with `record:` instead (*Fetching from other sources* in Part 4).
968
+ 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.)
969
969
 
970
970
  `queryable:` declares which fields a reader may filter on, with enough metadata for the foundation to render controls:
971
971
 
@@ -1845,7 +1845,9 @@ Content-less containers appear as group nodes (`hasContent: false`) — use `nav
1845
1845
 
1846
1846
  **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.
1847
1847
 
1848
- **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.
1848
+ **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.
1849
+
1850
+ **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*.
1849
1851
 
1850
1852
  ```jsx
1851
1853
  function Article({ content, block }) {
@@ -1885,7 +1887,7 @@ fetch:
1885
1887
  limit: 3
1886
1888
  ```
1887
1889
 
1888
- **Lean lists with `deferred:`.** A query over records with heavy fields (article bodies, large nested arrays) can declare `deferred: [body]` — keys of the record as a component receives it, a section's name included; a schema with more than one section implies every section but its brief. The list payload omits those fields; per-record full files are emitted at `/data/<name>/<slug>.json`. (An external query declares no `deferred:` — its whole record comes from `record:`, below.) On a parametric page the record's full data is delivered automatically; elsewhere components fetch the whole record on demand with `useWholeRecord(record, { query })`. The hook is safe to call on any query: when the query has no separate source for the whole record it returns the record you passed in, because nothing was stripped from it.
1890
+ **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.
1889
1891
 
1890
1892
  **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.
1891
1893
 
@@ -1904,7 +1906,7 @@ posts:
1904
1906
  url: https://api.example.com/posts/{id}
1905
1907
  ```
1906
1908
 
1907
- It also takes `method: POST` with a `body`, `where` / `sort` / `limit` (evaluated over what arrived, after `transform`) and `queryable`. `record:` takes `url`, `method`, `body` and `transform`: `url` and `method` default to the query's, `body` and `transform` never carry over, and a placeholder named by the page's folder (`{id}` for `[id]`, `{slug}` for `[slug]`) is its URL segment. `schema`, `scope`, `deferred`, `excerpt` and `path` are refused beside `url:` — they describe the site's own records. The visitor's browser fetches an external query (a `fetch:` with `prerender: true` makes the build fetch it instead); it is never asked of a host's records service and compiles no `/data` file. On a `[id]` page the list still decides which records exist — `record:` fetches the one it found, whole. A site published to a Uniweb host reads the host's records with no configuration at all.
1909
+ It also takes `method: POST` with a `body`, `where` / `sort` / `limit` (evaluated over what arrived, after `transform`), and `queryable`. Its records are entries in no folder and have no `$name`, so a `[slug]` page over it names the field the URL is in the query's own `where` — `where: { slug: :slug }` — which drops on the query's own page. `record:` takes `url`, `method`, `body` and `transform`: `url` and `method` default to the query's, `body` and `transform` never carry over, and a placeholder named by the page's folder (`{id}` for `[id]`, `{slug}` for `[slug]`) is its URL segment. `schema`, `scope`, `deferred`, `excerpt` and `path` are refused beside `url:` — they describe the site's own records. The visitor's browser fetches an external query (a `fetch:` with `prerender: true` makes the build fetch it instead); it is never asked of a host's records service and compiles no `/data` file. On a `[id]` page the list still decides which records exist — `record:` fetches the one it found, whole. A site published to a Uniweb host reads the host's records with no configuration at all.
1908
1910
 
1909
1911
  ⛔ A `fetch:` never carries a source: `path`, `url`, `method`, `body`, `transform`, `detail` and `scope` on a fetch stop the build. The `detail:` forms (`rest`, `query`, a URL pattern) are now an external query's `record:`, and `detailUrl:` is `record.url`.
1910
1912
 
@@ -0,0 +1,77 @@
1
+ /**
2
+ * What `uniweb dev` asks the backend before previewing a site whose foundation is a catalog ref —
3
+ * every clone.
4
+ *
5
+ * Such a site has no build of its foundation, and a build does not turn a ref into a URL: where a
6
+ * version is served is the backend's to say. So the dev server is handed the backend's answer, read
7
+ * for it here through the site — the route push and pull read the version's section types from
8
+ * (`client.readRegisteredFoundation`) — and passed on in `UNIWEB_PREVIEW` (`@uniweb/build/site`'s
9
+ * `preview.js` has the contract). The backend it names is also where the site's kept media URLs are
10
+ * fetched from.
11
+ *
12
+ * ⛔ Nothing here is kept. The answer lives as long as the dev server: `site.yml` keeps the ref, and
13
+ * no file a push or pull reads gets the URL — the round trip never sees it.
14
+ *
15
+ * ⚠️ It may not be known. Whether a backend tells the CLI where a version is served is the backend's
16
+ * to decide, and may change, so an answer without a location is an ordinary outcome: the preview
17
+ * says so and does not start. Nothing else reads it — push, pull and publish work without it.
18
+ */
19
+ import { readBackendState, parseCatalogRef } from '@uniweb/build/uwx'
20
+ import { BackendClient } from './client.js'
21
+ import { resolveWorkspace } from './workspace.js'
22
+ import { readDeclaredFoundation } from '../utils/install-integrity.js'
23
+ import { syncedBackends } from '../utils/site-identity.js'
24
+
25
+ /**
26
+ * What the dev server of `siteDir` is handed, when its foundation is a catalog ref.
27
+ *
28
+ * @param {object} p
29
+ * @param {string} p.siteDir
30
+ * @param {string[]} [p.args]
31
+ * @param {BackendClient} [p.client] - injected in tests
32
+ * @returns {Promise<null
33
+ * | { preview: { backend: string, foundation: { ref: string, url: string, cssUrl: string|null } } }
34
+ * | { refused: string[] }>}
35
+ * null when there is nothing to ask — the foundation is not a catalog ref
36
+ */
37
+ export async function readSitePreview({ siteDir, args = [], client = null }) {
38
+ const ref = readDeclaredFoundation(siteDir)
39
+ if (!parseCatalogRef(ref)) return null
40
+
41
+ client = client || new BackendClient({ args, command: 'Previewing' })
42
+ const uuid = readBackendState(siteDir, client.origin).site?.uuid
43
+ if (!uuid) {
44
+ const known = syncedBackends(siteDir)
45
+ return {
46
+ refused: [
47
+ `This site's foundation is ${ref}, and the backend the site is on says where that version is served.`,
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>`
50
+ : `The site is on no backend yet: \`uniweb push\` puts it on ${client.origin}.`
51
+ ]
52
+ }
53
+ }
54
+
55
+ const ws = await resolveWorkspace({ client, args })
56
+ if (ws.refused) return { refused: ['A preview reads the site in one workspace, and none is chosen.', ws.reason] }
57
+ client.setWorkspace(ws.workspace, { source: ws.source })
58
+
59
+ const reply = await client.readRegisteredFoundation(uuid, ref)
60
+ const url = typeof reply?.module_url === 'string' && reply.module_url ? reply.module_url : null
61
+ if (!url) {
62
+ return {
63
+ refused: [
64
+ reply
65
+ ? `${client.origin} did not say where ${ref} is served, so this site cannot be previewed here.`
66
+ : `Could not read ${ref} through this site on ${client.origin}, so this site cannot be previewed here.`,
67
+ 'Push, pull and publish do not need it.'
68
+ ]
69
+ }
70
+ }
71
+ return {
72
+ preview: {
73
+ backend: client.origin,
74
+ foundation: { ref, url, cssUrl: typeof reply.css_url === 'string' && reply.css_url ? reply.css_url : null }
75
+ }
76
+ }
77
+ }
@@ -701,8 +701,13 @@ export async function ensureRegisteredFoundation({ client, siteDir, siteUuid, re
701
701
  const kept = readRegisteredFoundation(siteDir, ref)
702
702
  if (kept) return kept
703
703
  const reply = siteUuid ? await client.readRegisteredFoundation(siteUuid, ref) : null
704
- if (reply) writeRegisteredFoundation(siteDir, ref, reply)
705
- return reply
704
+ if (!reply) return null
705
+ // ⛔ ONLY WHAT WE READ — the section types. The reply carries more than we asked for, a serve
706
+ // location among it, and a serve location is decided at publish and read from the response that
707
+ // carries it, never kept. Until 2026-09-26 the whole reply was kept.
708
+ const sectionTypes = { schema: reply.schema }
709
+ writeRegisteredFoundation(siteDir, ref, sectionTypes)
710
+ return sectionTypes
706
711
  }
707
712
 
708
713
  // The Sections whose declared keys a push reads before it sends an optional one.
@@ -589,7 +589,7 @@ function resolveFoundationDir(projectDir, siteConfig) {
589
589
  * Emits exactly what `uniweb publish` ships to Uniweb hosting:
590
590
  * dist/site-content.json (full sections inlined)
591
591
  * dist/<lang>/site-content.json per non-default locale
592
- * dist/data/<collection>.json (+ per-record files for `deferred:`)
592
+ * dist/data/<query>.json (+ a file per record, dist/data/<query>/<name>.json)
593
593
  * dist/assets/<media> (processed images, video posters, PDF thumbnails)
594
594
  *
595
595
  * Does NOT emit HTML, JS, CSS, source maps, _importmap chunks, or
@@ -24,6 +24,14 @@
24
24
  * site by default (mirrors the `pnpm dev` shortcut `uniweb create` writes).
25
25
  * Use `--site` to pick a different one without editing the root scripts.
26
26
  *
27
+ * A site whose foundation is a catalog ref — every clone — has no build of it, and
28
+ * only the backend the site is on can say where that version is served. So `dev`
29
+ * asks it, through the site, and hands the answer to the dev server it starts in its
30
+ * environment (`UNIWEB_PREVIEW`), with the backend it asked, which is also where the
31
+ * site's kept media URLs are fetched from. Nothing is written: `site.yml` keeps the
32
+ * ref and the round trip never sees the URL. A backend that does not say is an
33
+ * ordinary answer — `dev` says so and starts nothing (`backend/site-preview.js`).
34
+ *
27
35
  * Implementation: shells out to the package manager that invoked the CLI
28
36
  * (detected via npm_config_user_agent), running the workspace-filtered
29
37
  * dev command (`pnpm --filter <name> dev` or `npm -w <name> run dev`).
@@ -121,12 +129,32 @@ export async function dev(args = []) {
121
129
  const [bin, ...rest] = command.split(' ')
122
130
  const sitePath = join(rootDir, site.path)
123
131
 
132
+ // ⭐ A SITE WHOSE FOUNDATION IS A CATALOG REF — a clone — is previewed with what the backend
133
+ // it is on says: where that version is served. Asked here, for this dev server alone, and
134
+ // handed to it in its environment; nothing is written (`backend/site-preview.js`). Any other
135
+ // site needs nothing from a backend, and this asks nothing.
136
+ const { readSitePreview } = await import('../backend/site-preview.js')
137
+ const asked = await readSitePreview({ siteDir: sitePath, args })
138
+ if (asked?.refused) {
139
+ console.error(`${RED}✗${RESET} ${asked.refused[0]}`)
140
+ for (const line of asked.refused.slice(1)) console.error(` ${line}`)
141
+ process.exit(1)
142
+ }
143
+ let env = process.env
144
+ if (asked?.preview) {
145
+ const { PREVIEW_ENV, encodePreview } = await import('@uniweb/build/site')
146
+ env = { ...process.env, [PREVIEW_ENV]: encodePreview(asked.preview) }
147
+ console.error(
148
+ `${DIM}→ Previewing with ${asked.preview.foundation.ref}, served by ${asked.preview.backend}${RESET}`
149
+ )
150
+ }
151
+
124
152
  console.error(
125
153
  `${DIM}→ ${command}${RESET} ${DIM}(site: ${site.name}, dir: ${sitePath})${RESET}`
126
154
  )
127
155
  console.error('')
128
156
 
129
- const child = spawn(bin, rest, { cwd: rootDir, stdio: 'inherit' })
157
+ const child = spawn(bin, rest, { cwd: rootDir, stdio: 'inherit', env })
130
158
  child.on('close', (code) => process.exit(code ?? 0))
131
159
  child.on('error', (err) => {
132
160
  console.error(`${RED}✗${RESET} Failed to start dev server: ${err.message}`)
@@ -984,8 +984,8 @@ function checkGeneratedDataDir({ sitePath, siteName, siteYml, issues, shouldFix,
984
984
  ])
985
985
 
986
986
  if (existsSync(dataDir)) {
987
- // A collection `x` owns `x.json` (the cascade) and `x/` (per-record files
988
- // when it declares `deferred:`). Anything else is unaccounted for.
987
+ // A query `x` owns `x.json` (its list) and `x/` (its records' own files).
988
+ // Anything else is unaccounted for.
989
989
  const orphans = readdirSync(dataDir, { withFileTypes: true })
990
990
  .filter((entry) => {
991
991
  const name = entry.isDirectory()
@@ -1129,7 +1129,8 @@ async function runInitFreeform(siteRoot, config, args) {
1129
1129
  const items = JSON.parse(dataRaw)
1130
1130
 
1131
1131
  for (const item of items) {
1132
- if (item.slug === sectionId) {
1132
+ // A compiled record is named by `$name` — it carries no `slug` since 2026-09-27.
1133
+ if (item.$name === sectionId) {
1133
1134
  sourceContent = item.content
1134
1135
  break
1135
1136
  }
@@ -974,19 +974,6 @@ export async function pull(args = [], deps = {}) {
974
974
  // which is the pre-existing behaviour and right for a fresh clone.
975
975
  }
976
976
 
977
- // ⭐ The Models the site's queries name — what a clone, with no foundation to read them from,
978
- // tells a query's derived `deferred:` from its author's by. ⛔ Until 2026-09-26 a clone wrote
979
- // the derivation into its queries as though authored. One that cannot be read is judged
980
- // without it, which keeps the value: the safe direction.
981
- const models = {}
982
- for (const model of pulledQueryModels) {
983
- try {
984
- const declaration = await readModel(model)
985
- if (declaration) models[model] = declaration
986
- } catch {
987
- // unreadable here
988
- }
989
- }
990
977
  // A foundation named by catalog ref — a clone's: keep its registered version before the
991
978
  // queries are written, since its section types are what type a query named for a data key.
992
979
  if (!keepAuthoredFoundation) {
@@ -995,7 +982,6 @@ export async function pull(args = [], deps = {}) {
995
982
  const report = siteContentDocumentToProject({
996
983
  document: siteDoc,
997
984
  siteRoot: siteDir,
998
- models,
999
985
  // Which backend's asset ids to read back as the author's own paths. Without
1000
986
  // it a pull leaves every image pointing at a backend route.
1001
987
  backend: client.origin,
@@ -46,8 +46,8 @@
46
46
  // lost, and the legacy code could be removed). Implement as `register` flags (or
47
47
  // backend-side policy) when the need is real:
48
48
  //
49
- // • ACCESS POLICY — legacy `--edit-access open|restricted`. On the old platform
50
- // this gated who could act on the foundation in the app. Its meaning is
49
+ // • ACCESS POLICY — legacy `--edit-access open|restricted`. On the old registry
50
+ // this gated who could act on the foundation. Its meaning is
51
51
  // unclear for the new model: there is no editing of a foundation's code or
52
52
  // schema, so it is most likely a LICENSING / access-control concern (who may
53
53
  // use/reference the foundation), not "editing". Revisit as `--access` (or an
@@ -56,9 +56,8 @@
56
56
  // • VERSION PROPAGATION — legacy `--propagate`. Opts a newly-registered version
57
57
  // into the registry's version-update walk: trusting sites whose policy allows
58
58
  // the jump (e.g. auto-patch) adopt it with no rebuild; default was "silent"
59
- // (stored, nothing moves). The SAME concept applies to `runtime register`
60
- // (legacy deploy-runtime had `--propagate` too). Implement once the backend
61
- // has a version-update/propagation policy; until then every register is silent.
59
+ // (stored, nothing moves). Implement once the backend has a
60
+ // version-update/propagation policy; until then every register is silent.
62
61
 
63
62
  import {
64
63
  existsSync,
@@ -1,16 +1,16 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-09-26T21:30:58.826Z",
3
+ "generatedAt": "2026-09-27T19:27:55.485Z",
4
4
  "packages": {
5
5
  "@uniweb/api": {
6
- "version": "0.6.3",
6
+ "version": "0.6.4",
7
7
  "path": "framework/api",
8
8
  "deps": [
9
9
  "@uniweb/core"
10
10
  ]
11
11
  },
12
12
  "@uniweb/build": {
13
- "version": "0.64.1",
13
+ "version": "0.65.0",
14
14
  "path": "framework/build",
15
15
  "deps": [
16
16
  "@uniweb/content-reader",
@@ -35,7 +35,7 @@
35
35
  "deps": []
36
36
  },
37
37
  "@uniweb/core": {
38
- "version": "0.30.1",
38
+ "version": "0.31.0",
39
39
  "path": "framework/core",
40
40
  "deps": [
41
41
  "@uniweb/semantic-parser",
@@ -48,14 +48,14 @@
48
48
  "deps": []
49
49
  },
50
50
  "@uniweb/icons": {
51
- "version": "0.4.21",
51
+ "version": "0.4.22",
52
52
  "path": "framework/icons",
53
53
  "deps": [
54
54
  "@uniweb/core"
55
55
  ]
56
56
  },
57
57
  "@uniweb/kit": {
58
- "version": "0.19.5",
58
+ "version": "0.19.6",
59
59
  "path": "framework/kit",
60
60
  "deps": [
61
61
  "@uniweb/core",
@@ -75,7 +75,7 @@
75
75
  "deps": []
76
76
  },
77
77
  "@uniweb/projections": {
78
- "version": "0.7.4",
78
+ "version": "0.7.5",
79
79
  "path": "framework/projections",
80
80
  "deps": [
81
81
  "@uniweb/content-writer",
@@ -83,7 +83,7 @@
83
83
  ]
84
84
  },
85
85
  "@uniweb/runtime": {
86
- "version": "0.27.6",
86
+ "version": "0.27.7",
87
87
  "path": "framework/runtime",
88
88
  "deps": [
89
89
  "@uniweb/core",
@@ -96,7 +96,7 @@
96
96
  "deps": []
97
97
  },
98
98
  "@uniweb/schemas": {
99
- "version": "0.7.1",
99
+ "version": "0.8.0",
100
100
  "path": "framework/schemas",
101
101
  "deps": []
102
102
  },
@@ -116,7 +116,7 @@
116
116
  "deps": []
117
117
  },
118
118
  "@uniweb/templates": {
119
- "version": "0.17.3",
119
+ "version": "0.17.4",
120
120
  "path": "framework/templates",
121
121
  "deps": []
122
122
  },
@@ -126,7 +126,7 @@
126
126
  "deps": []
127
127
  },
128
128
  "@uniweb/unipress": {
129
- "version": "0.10.7",
129
+ "version": "0.10.8",
130
130
  "path": "framework/unipress",
131
131
  "deps": [
132
132
  "@uniweb/build",